diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 0b78268e..3d4d94a9 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -196,6 +196,23 @@ Primary implementation evidence: - Go: [`xlsx.go`](minipdf-go/xlsx.go) - Python: [`xlsx.py`](minipdf-python/src/minipdf/xlsx.py) +.NET resolves unnamed XLSX font families from the workbook's minor theme font, +falling back to the first style font when no theme font is available. Explicit +font families and each style's size and weight are preserved. +.NET XLSX double cell borders render as two solid strokes with a clear gap, +including print scaling and merged-cell continuations across pages. Adjacent +single borders do not fill the gap on a shared double edge. +Border conflict resolution is buffered per page to preserve low-memory +conversion behavior. +Automatic Calibri 11 row defaults use Excel's print line height; custom sheet +defaults and explicitly sized rows preserve their authored heights. +Calibri column widths use font-specific maximum-digit print metrics rather +than the generic font calibration. +Unsupported scripts retain Unicode font fallback instead of being forced into +the Normal style's Calibri font. For charts anchored outside data columns, +overflowing larger-font headings use spreadsheet default widths when widths +are not explicit. Other chart sheets retain their existing content sizing. + Java XLSX support is substantially broader than Java DOCX/PPTX support because it uses Apache POI and PDFBox. It should not be described as uniformly "text-only" across every Office format. diff --git a/scripts/Run-Benchmark.ps1 b/scripts/Run-Benchmark.ps1 index ba78b7d0..2916ed2f 100644 --- a/scripts/Run-Benchmark.ps1 +++ b/scripts/Run-Benchmark.ps1 @@ -72,7 +72,7 @@ Write-Host "============================================================`n" -For # Step 0: Install Python dependencies if (-not $SkipInstall) { Write-Host "[Step 0] Installing Python dependencies..." -ForegroundColor Yellow - & $PythonPath -m pip install openpyxl pymupdf Pillow pywin32 --quiet 2>$null + & $PythonPath -m pip install openpyxl Pillow pywin32 --quiet 2>$null if ($LASTEXITCODE -ne 0) { Write-Host " WARNING: pip install had issues. Continuing anyway..." -ForegroundColor DarkYellow } else { diff --git a/scripts/Run-Benchmark_docx.ps1 b/scripts/Run-Benchmark_docx.ps1 index a784c483..1a9a2e1a 100644 --- a/scripts/Run-Benchmark_docx.ps1 +++ b/scripts/Run-Benchmark_docx.ps1 @@ -71,7 +71,7 @@ Write-Host "============================================================`n" -For # Step 0: Install Python dependencies if (-not $SkipInstall) { Write-Host "[Step 0] Installing Python dependencies..." -ForegroundColor Yellow - & $PythonPath -m pip install python-docx pymupdf Pillow pywin32 --quiet 2>$null + & $PythonPath -m pip install python-docx Pillow pywin32 --quiet 2>$null if ($LASTEXITCODE -ne 0) { Write-Host " WARNING: pip install had issues. Continuing anyway..." -ForegroundColor DarkYellow } else { diff --git a/scripts/Run-Benchmark_issues.ps1 b/scripts/Run-Benchmark_issues.ps1 index edd4a265..5a243df3 100644 --- a/scripts/Run-Benchmark_issues.ps1 +++ b/scripts/Run-Benchmark_issues.ps1 @@ -234,7 +234,7 @@ function Write-ComparisonManifest { # Step 0: Install Python dependencies if (-not $SkipInstall) { Write-Host "[Step 0] Installing Python dependencies..." -ForegroundColor Yellow - pip install openpyxl pymupdf python-docx Pillow pywin32 --quiet 2>$null + pip install openpyxl python-docx Pillow pywin32 --quiet 2>$null if ($LASTEXITCODE -ne 0) { Write-Host " WARNING: pip install had issues. Continuing anyway..." -ForegroundColor DarkYellow } else { diff --git a/scripts/Run-Benchmark_pptx.ps1 b/scripts/Run-Benchmark_pptx.ps1 index 88a26ace..a3028251 100644 --- a/scripts/Run-Benchmark_pptx.ps1 +++ b/scripts/Run-Benchmark_pptx.ps1 @@ -48,7 +48,7 @@ Write-Host "============================================================`n" -For if (-not $SkipInstall) { Write-Host "[Step 0] Installing Python dependencies..." -ForegroundColor Yellow - pip install pymupdf Pillow --quiet 2>$null + pip install Pillow --quiet 2>$null if ($LASTEXITCODE -ne 0) { Write-Host " WARNING: pip install had issues. Continuing anyway..." -ForegroundColor DarkYellow } else { diff --git a/src/MiniPdf/ExcelReader.cs b/src/MiniPdf/ExcelReader.cs index 6d2dcbbb..5d8c7ff3 100644 --- a/src/MiniPdf/ExcelReader.cs +++ b/src/MiniPdf/ExcelReader.cs @@ -51,7 +51,7 @@ internal static List ReadSheets(Stream stream, System.Globalization. var normalFontFamily = ReadNormalFontFamily(archive); var normalFontSize = ReadNormalFontSize(archive); var maxDigitWidthPx = ExcelSheet.LookupMaxDigitWidthPx(normalFontFamily, normalFontSize); - var fontStyles = ReadFontStyles(archive, themeColors); + var fontStyles = ReadFontStyles(archive, themeColors, normalFontFamily); var fillColors = ReadFillColors(archive, themeColors); var borders = ReadBorders(archive, themeColors); var numberFormats = ReadNumberFormats(archive); @@ -87,7 +87,7 @@ internal static List ReadSheets(Stream stream, System.Globalization. var drawingShapes = ReadSheetShapes(archive, entry.FullName, themeColors); var (colWidths, defaultColWidth) = ReadColumnWidths(entry); var mergedCells = ReadMergedCells(entry); - var (rowHeights, defaultRowHeight, customHeightRows) = ReadRowHeights(entry); + var (rowHeights, defaultRowHeight, customHeightRows) = ReadRowHeights(entry, normalFontFamily, normalFontSize); if (rows.Count >= 1000 && colWidths.Count == 0 && defaultColWidth <= 0f && Math.Abs(defaultRowHeight - 15f) < 0.001f) { @@ -115,7 +115,7 @@ internal static List ReadSheets(Stream stream, System.Globalization. var images = ReadSheetImages(archive, entry.FullName); var (colWidths, defaultColWidth) = ReadColumnWidths(entry); var mergedCells = ReadMergedCells(entry); - var (rowHeights, defaultRowHeight, customHeightRows) = ReadRowHeights(entry); + var (rowHeights, defaultRowHeight, customHeightRows) = ReadRowHeights(entry, normalFontFamily, normalFontSize); var pageSetup = ReadPageSetup(entry); var ignoreInferredFitToPage = pageSetup.FitToPageInferred && rows.Count >= 1000 && colWidths.Count == 0 && defaultColWidth <= 0f; sheets.Add(new ExcelSheet("Sheet1", rows, images, colWidths, defaultColWidth, mergedCells: mergedCells, rowHeights: rowHeights, defaultRowHeight: defaultRowHeight, customHeightRows: customHeightRows, isLandscape: pageSetup.IsLandscape, printScale: pageSetup.Scale, paperSize: ignoreInferredFitToPage && pageSetup.PaperSize == 1 ? 9 : pageSetup.PaperSize, marginLeftPt: pageSetup.MarginLeftPt, marginRightPt: pageSetup.MarginRightPt, marginTopPt: pageSetup.MarginTopPt, marginBottomPt: pageSetup.MarginBottomPt, fitToPage: ignoreInferredFitToPage ? false : pageSetup.FitToPage, fitToWidth: ignoreInferredFitToPage ? 0 : pageSetup.FitToWidth, fitToHeight: ignoreInferredFitToPage ? 0 : pageSetup.FitToHeight, horizontalCentered: pageSetup.HorizontalCentered, verticalCentered: pageSetup.VerticalCentered, maxDigitWidthPx: maxDigitWidthPx)); @@ -333,7 +333,7 @@ private static string GetRelationshipPartPath(string partPath) : $"{directory}/_rels/{fileName}.rels"; } - private static List ReadFontStyles(ZipArchive archive, List themeColors) + private static List ReadFontStyles(ZipArchive archive, List themeColors, string? normalFontFamily) { var styles = new List(); var entry = archive.GetEntry("xl/styles.xml"); @@ -364,7 +364,7 @@ private static List ReadFontStyles(ZipArchive archive, List> ReadSheet(ZipArchiveEntry entry, IList column default. if (rowDefaultFill != null) @@ -3255,7 +3263,8 @@ internal record PageSetupInfo( /// Reads row heights from the sheet XML. /// Returns a dictionary of 0-based row index → height in points, plus the default row height. /// - private static (Dictionary heights, float defaultHeight, HashSet customHeightRows) ReadRowHeights(ZipArchiveEntry entry) + private static (Dictionary heights, float defaultHeight, HashSet customHeightRows) ReadRowHeights( + ZipArchiveEntry entry, string? normalFontFamily, float normalFontSize) { var heights = new Dictionary(); var customHeightRows = new HashSet(); @@ -3279,6 +3288,15 @@ private static (Dictionary heights, float defaultHeight, HashSet 7.52f, "century gothic" => 8.13f, "franklin gothic medium" => 8.5f, "franklin gothic book" => 8.2f, @@ -4943,7 +4962,7 @@ internal static float LookupMaxDigitWidthPx(string? fontName, float fontSizePt = "corbel" => 7.6f, "times new roman" => 6.8f, "consolas" or "courier new" => 7.3f, - _ => 7.378f, // Calibri / Arial / Aptos and other common fonts + _ => 7.378f, // Arial / Aptos and other common fonts }; } diff --git a/src/MiniPdf/ExcelToPdfConverter.cs b/src/MiniPdf/ExcelToPdfConverter.cs index 16908424..9f581003 100644 --- a/src/MiniPdf/ExcelToPdfConverter.cs +++ b/src/MiniPdf/ExcelToPdfConverter.cs @@ -86,6 +86,8 @@ internal sealed class ConversionOptions internal System.Globalization.CultureInfo? Culture { get; set; } internal bool LowMemory { get; set; } + + internal bool UseCalibriFontMetrics { get; set; } } /// @@ -122,6 +124,8 @@ internal static PdfDocument Convert(Stream excelStream, ConversionOptions? optio sheets = FilterSheets(sheets, options.Sheets, options.SheetIndexes); sheets = ApplyRenderLimits(sheets, options.MaxRows, options.MaxColumns); sheets = ApplyLayoutOptions(sheets, options); + options.UseCalibriFontMetrics = sheets.Any(sheet => sheet.Rows.Any(row => + row.Any(cell => cell.Bold && string.Equals(cell.FontName, "Calibri", StringComparison.OrdinalIgnoreCase)))); if (options.LowMemory) foreach (var sheet in sheets) if (sheet.FitToPage && sheet.FitToHeight > 0) @@ -737,6 +741,7 @@ private static void RenderSheet(PdfDocument doc, ExcelSheet sheet, ConversionOpt IncludeSheetName = options.IncludeSheetName, ScaleCellFonts = scaleCellFonts, LowMemory = options.LowMemory, + UseCalibriFontMetrics = options.UseCalibriFontMetrics, }; // Apply print area: limit rows and columns to the defined range @@ -951,8 +956,12 @@ private static void RenderSheet(PdfDocument doc, ExcelSheet sheet, ConversionOpt var hasExplicitColWidths = sheet.ColumnWidths.Count > 0 || sheet.DefaultColumnWidth > 0f; var isLargeGeneratedTable = sheet.Rows.Count >= 1000 && sheet.ColumnWidths.Count == 0 && sheet.DefaultColumnWidth <= 0f; + var useDefaultWidthsForChartHeading = sheet.Charts.Any(chart => chart.AnchorCol >= maxColsFromRows) && + sheet.Rows.Any(row => row.Count(cell => !string.IsNullOrEmpty(cell.Text)) == 1 && + row.Any(cell => cell.FontSize > options.FontSize && + MeasureStringWidth(cell.Text, options.FontSize) > sheet.CharUnitsToPointsDefaultScaled(8.43f))); var columnPadding = options.ColumnPadding; - if (hasExplicitColWidths || sheet.Images.Count > 0 || (sheet.FitToPage && sheet.FitToWidth >= 1)) + if (hasExplicitColWidths || sheet.Images.Count > 0 || useDefaultWidthsForChartHeading || (sheet.FitToPage && sheet.FitToWidth >= 1)) { // When the spreadsheet defines explicit column widths, or fitToPage is // active, use zero inter-column padding to match Excel/LibreOffice layout @@ -966,7 +975,7 @@ private static void RenderSheet(PdfDocument doc, ExcelSheet sheet, ConversionOpt // Calculate natural (unscaled) column widths to decide on grouping var naturalWidths = CalculateNaturalColumnWidths(sheet, maxCols, usableWidth, options, - (workbookHasMultipleSheets || sheet.Images.Count > 0) && !hasExplicitColWidths); + (workbookHasMultipleSheets || sheet.Images.Count > 0 || useDefaultWidthsForChartHeading) && !hasExplicitColWidths); // Apply print scale to column widths so more columns fit per page. // Font size is already scaled in options, but Excel explicit column widths @@ -1139,6 +1148,23 @@ private static void RenderSheetRows(PdfDocument doc, ExcelSheet sheet, Conversio var borderScaleFactor = printScaleFactor * (fitToPageScale < 1f ? fitToPageScale : 1f); var cellTextInset = Math.Min(2.5f, Math.Max(1f, options.FontSize * 0.18f)); + float MeasureCellTextWidth(string text, float fontSize, bool bold, string? fontName) + { + if (options.UseCalibriFontMetrics && + string.Equals(fontName, "Calibri", StringComparison.OrdinalIgnoreCase)) + { + if (PdfWriter.TryMeasurePreferredFontWidth(fontName, text, fontSize, bold, false, 0, out var width)) + return width; + var total = 0f; + foreach (var character in text) + total += PdfWriter.TryMeasurePreferredFontWidth(fontName, character.ToString(), fontSize, bold, false, 0, out width) + ? width + : (float)MeasureHelveticaWidth(character.ToString(), fontSize, bold); + return total; + } + return (float)MeasureHelveticaWidth(text, fontSize, bold); + } + // Use the sheet's default row height if available, otherwise fall back to font-based calculation var defaultLineHeight = sheet.DefaultRowHeight > 0 ? sheet.DefaultRowHeight : options.FontSize * options.LineSpacing; // Apply print scale to row heights (like column widths, these define content size) @@ -1148,6 +1174,7 @@ private static void RenderSheetRows(PdfDocument doc, ExcelSheet sheet, Conversio if (fitToPageScale < 1f) defaultLineHeight *= fitToPageScale; var lineHeight = defaultLineHeight; + var cellBorders = new List<(BorderSide Side, float X1, float Y1, float X2, float Y2)>(); PdfPage? currentPage = null; var currentY = 0f; var accumulatedOverflowHeight = 0f; @@ -1173,6 +1200,7 @@ void EnsurePage() { if (currentPage == null || currentY < options.MarginBottom) { + FlushCellBorders(); currentPage = doc.AddPage(pageWidth, pageHeight); currentY = pageHeight - options.MarginTop; } @@ -1263,32 +1291,64 @@ void DrawCellBorder(List row, int rowIndex, int i, int col, float x, for (var mc = i + 1; mc < columns.Length && columns[mc] <= borderEndCol; mc++) bxRight += columnPadding + colWidths[mc]; } - var borderColor = new PdfColor(0f, 0f, 0f); + QueueCellBorder(border.Left, bx, byTop, bx, byBottom); + QueueCellBorder(border.Right, bxRight, byTop, bxRight, byBottom); + QueueCellBorder(border.Top, bx, byTop, bxRight, byTop); + QueueCellBorder(border.Bottom, bx, byBottom, bxRight, byBottom); + } - if (border.Left is { Style: not "none" and not "" }) - { - var bc = border.Left.Color ?? borderColor; - var bw = Math.Max(0.08f, BorderStyleWidth(border.Left.Style) * borderScaleFactor); - currentPage!.AddLine(bx, byTop, bx, byBottom, bc, bw, BorderDashPattern(border.Left.Style, bw)); - } - if (border.Right is { Style: not "none" and not "" }) - { - var bc = border.Right.Color ?? borderColor; - var bw = Math.Max(0.08f, BorderStyleWidth(border.Right.Style) * borderScaleFactor); - currentPage!.AddLine(bxRight, byTop, bxRight, byBottom, bc, bw, BorderDashPattern(border.Right.Style, bw)); - } - if (border.Top is { Style: not "none" and not "" }) - { - var bc = border.Top.Color ?? borderColor; - var bw = Math.Max(0.08f, BorderStyleWidth(border.Top.Style) * borderScaleFactor); - currentPage!.AddLine(bx, byTop, bxRight, byTop, bc, bw, BorderDashPattern(border.Top.Style, bw)); - } - if (border.Bottom is { Style: not "none" and not "" }) + void QueueCellBorder(BorderSide? side, float x1, float y1, float x2, float y2) + { + if (side is { Style: not "none" and not "" }) + cellBorders.Add((side, x1, y1, x2, y2)); + } + + void FlushCellBorders() + { + if (cellBorders.Count == 0) return; + + var page = currentPage!; + var doubleEdges = cellBorders.Where(border => border.Side.Style == "double") + .ToLookup(border => (Vertical: border.X1 == border.X2, + Position: border.X1 == border.X2 ? border.X1 : border.Y1)); + foreach (var (side, x1, y1, x2, y2) in cellBorders) { - var bc = border.Bottom.Color ?? borderColor; - var bw = Math.Max(0.08f, BorderStyleWidth(border.Bottom.Style) * borderScaleFactor); - currentPage!.AddLine(bx, byBottom, bxRight, byBottom, bc, bw, BorderDashPattern(border.Bottom.Style, bw)); + var vertical = x1 == x2; + var key = (vertical, vertical ? x1 : y1); + if (side.Style == "double" || !doubleEdges.Contains(key)) + { + AddCellBorder(page, side, x1, y1, x2, y2, borderScaleFactor); + continue; + } + + // Resolve shared edges before releasing the page, including + // partial merged edges, without painting the double gap. + var start = vertical ? Math.Min(y1, y2) : x1; + var end = vertical ? Math.Max(y1, y2) : x2; + void AddSegment(float from, float to) + { + if (vertical) + AddCellBorder(page, side, x1, to, x2, from, borderScaleFactor); + else + AddCellBorder(page, side, from, y1, to, y2, borderScaleFactor); + } + var intervals = doubleEdges[key].Select(edge => ( + Start: vertical ? Math.Min(edge.Y1, edge.Y2) : edge.X1, + End: vertical ? Math.Max(edge.Y1, edge.Y2) : edge.X2)).OrderBy(edge => edge.Start); + foreach (var interval in intervals) + { + if (interval.End <= start || interval.Start >= end) + continue; + if (interval.Start > start) + AddSegment(start, interval.Start); + start = Math.Max(start, interval.End); + if (start >= end) + break; + } + if (start < end) + AddSegment(start, end); } + cellBorders.Clear(); } float ScaledRowHeightAt(int rowIndex) @@ -1377,18 +1437,9 @@ void RenderVerticalMergeContinuations(int startRow) var border = EffectiveBorderForCell(originRow, mergeStartRow, startCol, cell.Border); if (border != null) { - var borderColor = new PdfColor(0f, 0f, 0f); - void AddBorder(BorderSide? side, float x1, float y1, float x2, float y2) - { - if (side is not { Style: not "none" and not "" }) return; - var borderWidth = Math.Max(0.08f, BorderStyleWidth(side.Style) * borderScaleFactor); - currentPage.AddLine(x1, y1, x2, y2, side.Color ?? borderColor, - borderWidth, BorderDashPattern(side.Style, borderWidth)); - } - - AddBorder(border.Left, x, currentY, x, segmentBottom); - AddBorder(border.Right, x + width, currentY, x + width, segmentBottom); - AddBorder(border.Bottom, x, segmentBottom, x + width, segmentBottom); + QueueCellBorder(border.Left, x, currentY, x, segmentBottom); + QueueCellBorder(border.Right, x + width, currentY, x + width, segmentBottom); + QueueCellBorder(border.Bottom, x, segmentBottom, x + width, segmentBottom); } if (string.IsNullOrEmpty(cell.Text)) continue; @@ -1422,12 +1473,12 @@ void AddBorder(BorderSide? side, float x1, float y1, float x2, float y2) var textX = x + cellTextInset; if (cell.Alignment == "center") { - var textWidth = (float)MeasureHelveticaWidth(line, fontSize, bold: isBold); + var textWidth = MeasureCellTextWidth(line, fontSize, isBold, cell.FontName); textX = x + (width - textWidth) / 2f; } else if (cell.Alignment == "right") { - var textWidth = (float)MeasureHelveticaWidth(line, fontSize, bold: isBold); + var textWidth = MeasureCellTextWidth(line, fontSize, isBold, cell.FontName); textX = x + width - cellTextInset - textWidth; } @@ -1652,7 +1703,7 @@ float RenderPrintTitleRows() var titleIsBold = ShouldUsePdfBold(cell?.Bold ?? false, cellFs, cell?.FontName); var titleIndentOff = (cell?.Indent ?? 0) * avgCharWidth; var titleAcctPrefixW = cell?.AccountingPrefix != null - ? (float)MeasureHelveticaWidth(cell.AccountingPrefix, cellFs, bold: titleIsBold) + ? MeasureCellTextWidth(cell.AccountingPrefix, cellFs, titleIsBold, cell.FontName) : 0f; var titleContentX = x + cellTextInset; var titleContentWidth = Math.Max(1f, cellWidth - 2f * cellTextInset); @@ -1664,7 +1715,7 @@ float RenderPrintTitleRows() var lineMaxWidth = titleClipWidths[i]; if (alignment == "right" || titleAcctPrefixW > 0) { - var tw = (float)MeasureHelveticaWidth(titleCellLines[i][lineIdx], cellFs, bold: titleIsBold); + var tw = MeasureCellTextWidth(titleCellLines[i][lineIdx], cellFs, titleIsBold, cell?.FontName); textX = titleContentX + titleContentWidth - tw - titleIndentOff; if (titleAcctPrefixW > 0 && textX < titleContentX + titleAcctPrefixW) { @@ -1674,7 +1725,7 @@ float RenderPrintTitleRows() } else if (alignment == "center") { - var tw = (float)MeasureHelveticaWidth(titleCellLines[i][lineIdx], cellFs, bold: titleIsBold); + var tw = MeasureCellTextWidth(titleCellLines[i][lineIdx], cellFs, titleIsBold, cell?.FontName); textX = titleContentX + (titleContentWidth - tw) / 2f; } AddExcelText(currentPage!, titleCellLines[i][lineIdx], textX, cellY, cellFs, cell?.Color, @@ -1778,6 +1829,7 @@ float RenderPrintTitleRows() // Manual row break: force a new page before this row if (sheet.RowBreaks.Contains(excelRowIndex) && currentPage != null && currentY < pageHeight - options.MarginTop) { + FlushCellBorders(); currentPage = doc.AddPage(pageWidth, pageHeight); currentY = pageHeight - options.MarginTop; // Render print title rows on the new page @@ -2033,6 +2085,7 @@ float RenderPrintTitleRows() if (currentY - rowHeight < options.MarginBottom && currentPage != null) { + FlushCellBorders(); currentPage = doc.AddPage(pageWidth, pageHeight); currentY = pageHeight - options.MarginTop; // Render print title rows on the new page (skip if current row is within the title range) @@ -2084,7 +2137,7 @@ float RenderPrintTitleRows() var mpIsBold = ShouldUsePdfBold(cell?.Bold ?? false, options.FontSize, cell?.FontName); var mpIndentOff = (cell?.Indent ?? 0) * avgCharWidth; var mpAcctPrefixW = cell?.AccountingPrefix != null - ? (float)MeasureHelveticaWidth(cell.AccountingPrefix, options.FontSize, bold: mpIsBold) + ? MeasureCellTextWidth(cell.AccountingPrefix, options.FontSize, mpIsBold, cell.FontName) : 0f; var mpContentX = x + cellTextInset; var mpContentWidth = Math.Max(1f, mpCellWidth - 2f * cellTextInset); @@ -2096,7 +2149,7 @@ float RenderPrintTitleRows() float mpLineMaxWidth = 0; if (mpAlignment == "right" || mpAcctPrefixW > 0) { - var tw = (float)MeasureHelveticaWidth(lines[lineIdx], options.FontSize, bold: mpIsBold); + var tw = MeasureCellTextWidth(lines[lineIdx], options.FontSize, mpIsBold, cell?.FontName); textX = mpContentX + mpContentWidth - tw - mpIndentOff; if (mpAcctPrefixW > 0 && textX < mpContentX + mpAcctPrefixW) { @@ -2106,7 +2159,7 @@ float RenderPrintTitleRows() } else if (mpAlignment == "center") { - var tw = (float)MeasureHelveticaWidth(lines[lineIdx], options.FontSize, bold: mpIsBold); + var tw = MeasureCellTextWidth(lines[lineIdx], options.FontSize, mpIsBold, cell?.FontName); textX = mpContentX + (mpContentWidth - tw) / 2f; } AddExcelText(currentPage!, lines[lineIdx], textX, cellY, options.FontSize, color, @@ -2128,6 +2181,7 @@ float RenderPrintTitleRows() if (linesRendered < maxLinesInRow) { + FlushCellBorders(); currentPage = doc.AddPage(pageWidth, pageHeight); currentY = pageHeight - options.MarginTop; } @@ -2303,7 +2357,7 @@ float RenderPrintTitleRows() // For accounting format, reserve space for the left-aligned prefix // so the right-aligned number doesn't overlap it. var acctPrefixWidth = cell?.AccountingPrefix != null - ? (float)MeasureHelveticaWidth(cell.AccountingPrefix, cellFontSize, bold: isBold) + ? MeasureCellTextWidth(cell.AccountingPrefix, cellFontSize, isBold, cell.FontName) : 0f; var contentX = x + cellTextInset; var contentWidth = Math.Max(1f, cellWidth - 2f * cellTextInset); @@ -2319,7 +2373,7 @@ float RenderPrintTitleRows() // left-aligned prefix and the right-aligned number. if (alignment == "right" || acctPrefixWidth > 0) { - var textWidth = (float)MeasureHelveticaWidth(lines[lineIdx], cellFontSize, bold: isBold); + var textWidth = MeasureCellTextWidth(lines[lineIdx], cellFontSize, isBold, cell?.FontName); textX = contentX + contentWidth - textWidth - indentOff; // Clamp: don't start before the accounting prefix if (acctPrefixWidth > 0 && textX < contentX + acctPrefixWidth) @@ -2330,7 +2384,7 @@ float RenderPrintTitleRows() } else if (alignment == "center") { - var textWidth = (float)MeasureHelveticaWidth(lines[lineIdx], cellFontSize, bold: isBold); + var textWidth = MeasureCellTextWidth(lines[lineIdx], cellFontSize, isBold, cell?.FontName); textX = contentX + (contentWidth - textWidth) / 2f; } @@ -2416,6 +2470,8 @@ float RenderPrintTitleRows() excelRowIndex++; } + FlushCellBorders(); + // Emit accumulated virtual overflow pages at the end (matching LibreOffice layout). if (accumulatedOverflowHeight > 0) { @@ -3952,6 +4008,13 @@ private static string[] WrapCellText(string text, float widthPts, float fontSize private static void AddExcelText(PdfPage page, string text, float x, float y, float fontSize, PdfColor? color = null, (float, float, float, float)? clipRect = null, float? maxWidth = null, bool bold = false, bool italic = false, bool underline = false, float charSpacing = 0, float wordSpacing = 0, string? preferredFontName = null, float? underlineWidth = null, bool strikethrough = false) { + if (string.Equals(preferredFontName, "Calibri", StringComparison.OrdinalIgnoreCase) && + text.Any(character => character > '\u00FF' && char.IsLetter(character)) && + !PdfWriter.TryMeasurePreferredFontWidth(preferredFontName, text, fontSize, bold, italic, charSpacing, out _)) + { + preferredFontName = null; + } + if (!ContainsIndicText(text) || !TryRenderComplexScriptText(text, fontSize, color ?? PdfColor.Black, bold, italic, preferredFontName, maxWidth, out var png, out var widthPt, out var heightPt, out var baselineFromBottomPt)) { page.AddText(text, x, y, fontSize, color, clipRect, maxWidth, bold, italic, underline, charSpacing, wordSpacing, preferredFontName, underlineWidth, strikethrough); @@ -4306,12 +4369,31 @@ private static bool ShouldUsePdfBold(bool requestedBold, float fontSize, string? /// private static float BorderStyleWidth(string style) => style switch { - "thick" => 1.5f, - "medium" or "mediumDashed" or "mediumDashDot" or "mediumDashDotDot" => 1f, + "thick" => 3f, + "medium" or "mediumDashed" or "mediumDashDot" or "mediumDashDotDot" => 2f, + "thin" => 1f, "dotted" or "hair" => 0.1f, - _ => 0.3f // thin, dashed, dashDot, dashDotDot, double, slantDashDot + _ => 0.3f // dashed, dashDot, dashDotDot, slantDashDot }; + private static void AddCellBorder(PdfPage page, BorderSide side, + float x1, float y1, float x2, float y2, float scale) + { + var color = side.Color ?? new PdfColor(0f, 0f, 0f); + if (side.Style == "double") + { + var strokeWidth = Math.Max(0.08f, scale); + var offsetX = x1 == x2 ? strokeWidth : 0f; + var offsetY = y1 == y2 ? strokeWidth : 0f; + page.AddLine(x1 - offsetX, y1 - offsetY, x2 - offsetX, y2 - offsetY, color, strokeWidth); + page.AddLine(x1 + offsetX, y1 + offsetY, x2 + offsetX, y2 + offsetY, color, strokeWidth); + return; + } + + var width = Math.Max(0.08f, BorderStyleWidth(side.Style) * scale); + page.AddLine(x1, y1, x2, y2, color, width, BorderDashPattern(side.Style, width)); + } + /// /// Maps OOXML border style names to PDF dash patterns. /// Returns null for solid lines. @@ -4460,10 +4542,16 @@ private static float[] CalculateNaturalColumnWidths(ExcelSheet sheet, int maxCol // This accounts for CJK characters being ~2× wider than Latin chars. var colMaxWidthPts = new float[maxCols]; - foreach (var row in sheet.Rows) + var mergedOrigins = new HashSet<(int, int)>(sheet.MergedCells + .Where(merge => merge.EndCol > merge.StartCol) + .Select(merge => (merge.StartRow, merge.StartCol))); + for (var rowIndex = 0; rowIndex < sheet.Rows.Count; rowIndex++) { + var row = sheet.Rows[rowIndex]; for (var col = 0; col < row.Count && col < maxCols; col++) { + if (mergedOrigins.Contains((rowIndex, col))) + continue; var w = MeasureStringWidth(row[col].Text, options.FontSize); if (w > colMaxWidthPts[col]) colMaxWidthPts[col] = w; } diff --git a/tests/MiniPdf.Benchmark/README.md b/tests/MiniPdf.Benchmark/README.md index 9e97ba95..ae346cdb 100644 --- a/tests/MiniPdf.Benchmark/README.md +++ b/tests/MiniPdf.Benchmark/README.md @@ -72,7 +72,7 @@ report, images, and heatmaps. ```bash # 1. Python 3.10+ & dependencies (pywin32 is required for Microsoft 365 references) -pip install openpyxl pymupdf pywin32 +pip install openpyxl python-docx Pillow pywin32 # 2. Desktop Microsoft Excel/Word/PowerPoint (primary references) @@ -81,8 +81,51 @@ pip install openpyxl pymupdf pywin32 # or: winget install LibreOffice # 4. The toolchain required by the selected MiniPdf implementation + +# 5. Java 11+ on PATH, and Maven to build the shared PDFBox evaluator once +mvn -f tests/MiniPdf.Benchmark/pdfbox/pom.xml package +``` + +### PDFBox Evaluator and Score Migration + +The shared comparator uses Apache PDFBox 3.0.8 (Apache-2.0) for page counts, +positioned text extraction, and RGB rasterization. PyMuPDF/MuPDF is no longer +required by this pipeline. All six language benchmark entry points use this +same evaluator. The helper is independent of the Java MiniPdf implementation. +Rebuild it with the command above after changing its Java source or dependencies. +The shaded helper preserves dependency LICENSE and NOTICE resources, including +PDFBox's separately licensed resources; the complete artifact is not exclusively +Apache-2.0. Keep those notices with any binary redistribution. +Missing Java, an unbuilt helper, or PDF parsing/rendering failures stop the +comparison explicitly; they do not produce text-only or raw-string fallback scores. + +The scoring formula is unchanged: 40% text, 40% visual, and 20% page-count match +(the existing optional AI visual override is also unchanged). PDFBox and MuPDF +can differ in antialiasing, font substitution, image dimensions, and extracted +text boundaries. Consequently, scores can change even when candidate and reference +PDFs are byte-for-byte unchanged. These changes do not demonstrate a MiniPdf +rendering regression or improvement. + +Keep old reports separately and regenerate a complete baseline with PDFBox before +using score deltas or the existing `MinimumScore` threshold as a regression gate. +Do not lower thresholds automatically to compensate for an evaluator change. +Each new JSON result contains `evaluator` metadata and Markdown reports display +it. Filtered runs refuse to merge results from a different or unversioned +evaluator. Report-only regeneration preserves the stored evaluator identity. +Use the same Java version, operating system, and installed fonts for reproducible +comparisons. The PDFBox version and normalization/scoring revision are recorded; +the host font environment is not bundled. + +Backend and comparator integration tests (no PyMuPDF required): + +```powershell +python -m unittest discover -s tests\MiniPdf.Benchmark -p test_pdfbox_backend.py -v ``` +This migration covers the shared benchmark, not the standalone historical +diagnostic scripts under `scripts/` that still import PyMuPDF. External reference +tools, fonts, and their redistribution terms remain separate licensing concerns. + ### One-Click Execution ```powershell diff --git a/tests/MiniPdf.Benchmark/compare_pdfs.py b/tests/MiniPdf.Benchmark/compare_pdfs.py index b61e4fe0..7e3e52d5 100644 --- a/tests/MiniPdf.Benchmark/compare_pdfs.py +++ b/tests/MiniPdf.Benchmark/compare_pdfs.py @@ -5,11 +5,11 @@ - Text content diff (extracted text comparison) - Page count comparison - File size comparison - - Visual pixel diff (if pdf2image / Poppler is available) + - Visual pixel diff using Apache PDFBox Prerequisites: - pip install pymupdf # for text extraction + rendering - pip install Pillow # for heatmaps and composite images + Java 11+ and the built pdfbox/target/benchmark-pdfbox.jar helper + pip install Pillow # for images, heatmaps and composites Usage: python compare_pdfs.py [--minipdf-dir ./minipdf_pdfs] [--reference-dir ./reference_pdfs] [--report-dir ./reports] @@ -23,18 +23,12 @@ import os import re import sys +from contextlib import ExitStack from datetime import datetime from pathlib import Path from typing import Optional -# Try to import fitz (PyMuPDF) for text extraction and visual comparison -try: - import fitz # PyMuPDF - HAS_FITZ = True -except ImportError: - HAS_FITZ = False - print("WARNING: PyMuPDF not installed. Install with: pip install pymupdf") - print(" Text extraction and visual comparison will be disabled.\n") +from pdfbox_backend import EVALUATOR, PdfDocument, check_backend, extract_text # Try to import openai for AI-based visual comparison try: @@ -326,82 +320,13 @@ def _encode(path: str) -> str: return {"error": str(e)} -def extract_text_pymupdf(pdf_path: str) -> list[str]: - """Extract text from each page using PyMuPDF. - Groups spans at the same Y position (same visual row) into a single - space-joined line so that MiniPdf and LibreOffice outputs are comparable - regardless of whether each cell is a separate span or part of a row span.""" - pages = [] - doc = fitz.open(pdf_path) - for page in doc: - data = page.get_text("dict", sort=True) - spans = [] - for block in data.get("blocks", []): - if block.get("type", 0) != 0: - continue - for line in block.get("lines", []): - for span in line.get("spans", []): - text = span.get("text", "").strip() - if text: - # Use the text origin (baseline) Y instead of bbox top Y. - # Different fonts (e.g. CJK vs Latin) report different - # ascent metrics, making bbox Y inconsistent even when - # the actual baseline is identical. - y_key = span.get("origin", (span["bbox"][0], span["bbox"][1]))[1] - spans.append((round(y_key, 1), span["bbox"][0], text)) - # Sort by Y then X position - spans.sort() - # Group spans that share the same Y row (within 1.0 pt tolerance) into - # a single whitespace-separated line — this handles the structural - # difference between PDF renderers that emit one span per cell vs - # one span per row. - # Within each group we sort by X so that left-to-right column order is - # preserved even when spans from different renderers land at slightly - # different sub-pixel Y positions (< 1 pt apart). - lines = [] - current_y = None - current_tokens: list[tuple[float, str]] = [] # (x, text) - for y, x, text in spans: - if current_y is None or abs(y - current_y) > 1.0: - if current_tokens: - current_tokens.sort() - lines.append(" ".join(t for _, t in current_tokens)) - current_y = y - current_tokens = [(x, text)] - else: - current_tokens.append((x, text)) - if current_tokens: - current_tokens.sort() - lines.append(" ".join(t for _, t in current_tokens)) - pages.append("\n".join(lines)) - doc.close() - return pages - - -def extract_text_fallback(pdf_path: str) -> list[str]: - """Fallback: extract raw ASCII strings from PDF binary.""" - with open(pdf_path, "rb") as f: - data = f.read() - # Very rough extraction: find text between BT..ET operators - text = data.decode("latin-1", errors="replace") - # Extract parenthesized strings (PDF text objects) - import re - strings = re.findall(r"\(([^)]*)\)", text) - return ["\n".join(strings)] - - def render_page_to_pixels(pdf_path: str, page_num: int, dpi: int = 150): - """Render a PDF page to a pixel map using PyMuPDF. Returns (width, height, samples).""" - doc = fitz.open(pdf_path) - if page_num >= len(doc): - doc.close() - return None - page = doc[page_num] - mat = fitz.Matrix(dpi / 72, dpi / 72) - pix = page.get_pixmap(matrix=mat, alpha=False) - result = (pix.width, pix.height, pix.samples) - doc.close() - return result + """Render a page as RGB with PDFBox; return None only for a missing page.""" + with PdfDocument(pdf_path) as document: + if page_num >= document.page_count: + return None + pix = document.render(page_num, dpi) + return pix.width, pix.height, pix.samples def _aligned_rgb_images(pix1, pix2): @@ -641,9 +566,6 @@ def save_visual_diff( max_compare_pages: int = 0, ): """Save visual diff images for each page.""" - if not HAS_FITZ: - return [] - labels = labels or {} candidate_label = labels.get("candidate", "MiniPdf") reference_label = labels.get("reference", "LibreOffice Reference") @@ -670,86 +592,62 @@ def save_visual_diff( os.remove(old) diff_images = [] - doc1 = fitz.open(pdf1_path) - doc2 = fitz.open(pdf2_path) - doc3 = fitz.open(auxiliary_pdf_path) if auxiliary_pdf_path and os.path.isfile(auxiliary_pdf_path) else None - max_pages = max(len(doc1), len(doc2), len(doc3) if doc3 else 0) - if max_compare_pages > 0: - max_pages = min(max_pages, max_compare_pages) - - for i in range(max_pages): - mat = fitz.Matrix(dpi / 72, dpi / 72) - - if i < len(doc1): - pix1 = doc1[i].get_pixmap(matrix=mat, alpha=False) - else: - pix1 = None - - if i < len(doc2): - pix2 = doc2[i].get_pixmap(matrix=mat, alpha=False) - else: - pix2 = None - - pix3 = None - if doc3 and i < len(doc3): - pix3 = doc3[i].get_pixmap(matrix=mat, alpha=False) - - # Save individual renderings - path1 = None - path2 = None - path3 = None - - if pix1: - path1 = os.path.join(output_dir, f"{name}_p{i+1}_minipdf.png") - - if pix2: - path2 = os.path.join(output_dir, f"{name}_p{i+1}_reference.png") - - if pix3: - path3 = os.path.join(output_dir, f"{name}_p{i+1}_{auxiliary_slug}.png") - - _save_normalized_pixmaps( - (pix1, pix2, pix3), - (path1, path2, path3), + with ExitStack() as documents: + doc1 = documents.enter_context(PdfDocument(pdf1_path)) + doc2 = documents.enter_context(PdfDocument(pdf2_path)) + doc3 = ( + documents.enter_context(PdfDocument(auxiliary_pdf_path)) + if auxiliary_pdf_path and os.path.isfile(auxiliary_pdf_path) else None ) + max_pages = max(doc1.page_count, doc2.page_count, doc3.page_count if doc3 else 0) + if max_compare_pages > 0: + max_pages = min(max_pages, max_compare_pages) - entry = { - "page": i + 1, - "minipdf_img": f"{name}_p{i+1}_minipdf.png" if pix1 else None, - "reference_img": f"{name}_p{i+1}_reference.png" if pix2 else None, - } - if doc3 is not None: - entry["auxiliary_img"] = f"{name}_p{i+1}_{auxiliary_slug}.png" if pix3 else None - if heatmaps and pix1 and pix2: - heatmap_name = f"{name}_p{i+1}_heatmap.png" - heatmap_path = os.path.join(output_dir, heatmap_name) - heatmap_metrics = save_difference_heatmap( - (pix1.width, pix1.height, pix1.samples), - (pix2.width, pix2.height, pix2.samples), - heatmap_path, - threshold=heatmap_threshold, - gain=heatmap_gain, + for i in range(max_pages): + pix1 = doc1.render(i, dpi) if i < doc1.page_count else None + pix2 = doc2.render(i, dpi) if i < doc2.page_count else None + pix3 = doc3.render(i, dpi) if doc3 and i < doc3.page_count else None + + path1 = os.path.join(output_dir, f"{name}_p{i+1}_minipdf.png") if pix1 else None + path2 = os.path.join(output_dir, f"{name}_p{i+1}_reference.png") if pix2 else None + path3 = os.path.join(output_dir, f"{name}_p{i+1}_{auxiliary_slug}.png") if pix3 else None + _save_normalized_pixmaps( + (pix1, pix2, pix3), + (path1, path2, path3), ) - if heatmap_metrics: - entry["heatmap_img"] = heatmap_name - entry["heatmap_metrics"] = heatmap_metrics - if composite_output_dir: - engine_slugs = [slugify_label(candidate_label), slugify_label(reference_label)] - components = [(candidate_label, path1), (reference_label, path2)] + + entry = { + "page": i + 1, + "minipdf_img": f"{name}_p{i+1}_minipdf.png" if pix1 else None, + "reference_img": f"{name}_p{i+1}_reference.png" if pix2 else None, + } if doc3 is not None: - engine_slugs.append(auxiliary_slug) - components.append((auxiliary_label, path3)) - - composite_name = f"{name}_p{i+1}_{'_vs_'.join(engine_slugs)}.png" - composite_path = os.path.join(composite_output_dir, composite_name) - if save_composite_image(components, composite_path): - entry["composite_img"] = composite_name - diff_images.append(entry) - - doc1.close() - doc2.close() - if doc3: - doc3.close() + entry["auxiliary_img"] = f"{name}_p{i+1}_{auxiliary_slug}.png" if pix3 else None + if heatmaps and pix1 and pix2: + heatmap_name = f"{name}_p{i+1}_heatmap.png" + heatmap_path = os.path.join(output_dir, heatmap_name) + heatmap_metrics = save_difference_heatmap( + (pix1.width, pix1.height, pix1.samples), + (pix2.width, pix2.height, pix2.samples), + heatmap_path, + threshold=heatmap_threshold, + gain=heatmap_gain, + ) + if heatmap_metrics: + entry["heatmap_img"] = heatmap_name + entry["heatmap_metrics"] = heatmap_metrics + if composite_output_dir: + engine_slugs = [slugify_label(candidate_label), slugify_label(reference_label)] + components = [(candidate_label, path1), (reference_label, path2)] + if doc3 is not None: + engine_slugs.append(auxiliary_slug) + components.append((auxiliary_label, path3)) + + composite_name = f"{name}_p{i+1}_{'_vs_'.join(engine_slugs)}.png" + composite_path = os.path.join(composite_output_dir, composite_name) + if save_composite_image(components, composite_path): + entry["composite_img"] = composite_name + diff_images.append(entry) return diff_images @@ -856,6 +754,7 @@ def compare_single( """Compare a single pair of PDFs and return a detailed result.""" result = { "name": name, + "evaluator": dict(EVALUATOR), "minipdf_exists": os.path.isfile(minipdf_path), "reference_exists": os.path.isfile(reference_path), } @@ -883,29 +782,13 @@ def compare_single( print(f" ⚠ PDF structure errors in {name}: {validity['errors'][:3]}") # Page counts - if HAS_FITZ: - doc_m = fitz.open(minipdf_path) - doc_r = fitz.open(reference_path) - result["minipdf_pages"] = len(doc_m) - result["reference_pages"] = len(doc_r) - doc_m.close() - doc_r.close() - else: - result["minipdf_pages"] = "?" - result["reference_pages"] = "?" + with PdfDocument(minipdf_path) as doc_m, PdfDocument(reference_path) as doc_r: + result["minipdf_pages"] = doc_m.page_count + result["reference_pages"] = doc_r.page_count # Text extraction and comparison - if HAS_FITZ: - try: - text_m = extract_text_pymupdf(minipdf_path) - text_r = extract_text_pymupdf(reference_path) - except Exception as e: - text_m = extract_text_fallback(minipdf_path) - text_r = extract_text_fallback(reference_path) - result["text_extract_warning"] = str(e) - else: - text_m = extract_text_fallback(minipdf_path) - text_r = extract_text_fallback(reference_path) + text_m = extract_text(minipdf_path) + text_r = extract_text(reference_path) if max_compare_pages > 0: text_m = text_m[:max_compare_pages] @@ -964,15 +847,20 @@ def compare_single( # Visual comparison visual_scores = [] - if HAS_FITZ: + with ExitStack() as score_documents: + doc_m = score_documents.enter_context(PdfDocument(minipdf_path)) + doc_r = score_documents.enter_context(PdfDocument(reference_path)) max_pages = max(result["minipdf_pages"], result["reference_pages"]) if max_compare_pages > 0: max_pages = min(max_pages, max_compare_pages) for p in range(max_pages): - pix_m = render_page_to_pixels(minipdf_path, p) - pix_r = render_page_to_pixels(reference_path, p) + image_m = doc_m.render(p) if p < doc_m.page_count else None + image_r = doc_r.render(p) if p < doc_r.page_count else None + pix_m = (image_m.width, image_m.height, image_m.samples) if image_m else None + pix_r = (image_r.width, image_r.height, image_r.samples) if image_r else None score = pixel_diff_score(pix_m, pix_r) visual_scores.append(round(score, 4)) + score_documents.close() result["visual_scores"] = visual_scores result["visual_avg"] = round(sum(visual_scores) / len(visual_scores), 4) if visual_scores else 0.0 @@ -1061,6 +949,14 @@ def generate_report(results: list[dict], report_dir: str, labels: Optional[dict] with open(md_path, "w", encoding="utf-8") as f: f.write(f"# {candidate_label} vs {reference_label} PDF Comparison Report\n\n") f.write(f"Generated: {datetime.now().isoformat()}\n\n") + evaluators = { + json.dumps(r.get("evaluator"), sort_keys=True) + for r in results + } + f.write("Evaluator metadata (legacy reports without metadata are unversioned):\n\n") + for evaluator in sorted(evaluators): + f.write(f"- `{evaluator}`\n") + f.write("\nScores from different evaluators are not directly comparable.\n\n") # Summary table f.write("## Summary\n\n") @@ -1520,6 +1416,7 @@ def main(): print(" 3. python generate_reference_pdfs.py (generate reference PDFs)") sys.exit(1) + check_backend() filtered_mode = bool(args.filter) existing_results_by_name = {} if filtered_mode and os.path.isfile(json_path): @@ -1533,6 +1430,11 @@ def main(): print(f"Filtered comparison mode: loaded {len(existing_results_by_name)} existing results to preserve.") except Exception as e: print(f"WARNING: Failed to load existing report for merge: {e}") + if any(r.get("evaluator") != EVALUATOR for r in existing_results_by_name.values()): + parser.error( + "Cannot merge filtered results from different evaluators. " + "Use a new report directory or rerun the complete comparison without --filter." + ) results = [] for case in sorted(cases, key=lambda c: natural_sort_key(c["name"])): diff --git a/tests/MiniPdf.Benchmark/pdfbox/pom.xml b/tests/MiniPdf.Benchmark/pdfbox/pom.xml new file mode 100644 index 00000000..e6046078 --- /dev/null +++ b/tests/MiniPdf.Benchmark/pdfbox/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + io.github.mini-software + minipdf-benchmark-pdfbox + 1.0.0 + + 11 + UTF-8 + + + + org.apache.pdfbox + pdfbox + 3.0.8 + + + + benchmark-pdfbox + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.1 + + + package + shade + + false + + + BenchmarkPdfBox + + + META-INF/LICENSE + + + + + + + + + + diff --git a/tests/MiniPdf.Benchmark/pdfbox/src/main/java/BenchmarkPdfBox.java b/tests/MiniPdf.Benchmark/pdfbox/src/main/java/BenchmarkPdfBox.java new file mode 100644 index 00000000..01652535 --- /dev/null +++ b/tests/MiniPdf.Benchmark/pdfbox/src/main/java/BenchmarkPdfBox.java @@ -0,0 +1,112 @@ +import java.awt.image.BufferedImage; +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.ImageType; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.apache.pdfbox.text.PDFTextStripper; +import org.apache.pdfbox.text.TextPosition; + +public final class BenchmarkPdfBox { + private static void reply(DataOutputStream output, int status, byte[] payload) throws IOException { + output.writeByte(status); + output.writeInt(payload.length); + output.write(payload); + output.flush(); + } + + private static byte[] text(PDDocument document, int page) throws IOException { + List spans = new ArrayList<>(); + PDFTextStripper stripper = new PDFTextStripper() { + @Override + protected void writeString(String value, List positions) throws IOException { + if (positions.isEmpty() || value.trim().isEmpty()) { + return; + } + TextPosition first = positions.get(0); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try (DataOutputStream span = new DataOutputStream(buffer)) { + span.writeFloat(first.getYDirAdj()); + span.writeFloat(first.getXDirAdj()); + byte[] utf8 = value.getBytes(StandardCharsets.UTF_8); + span.writeInt(utf8.length); + span.write(utf8); + } + spans.add(buffer.toByteArray()); + } + }; + stripper.setSortByPosition(true); + stripper.setStartPage(page + 1); + stripper.setEndPage(page + 1); + stripper.getText(document); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try (DataOutputStream data = new DataOutputStream(buffer)) { + data.writeInt(spans.size()); + for (byte[] span : spans) { + data.write(span); + } + } + return buffer.toByteArray(); + } + + private static byte[] render(PDFRenderer renderer, int page, int dpi) throws IOException { + if (dpi <= 0) { + throw new IllegalArgumentException("DPI must be positive"); + } + BufferedImage image = renderer.renderImageWithDPI(page, dpi, ImageType.RGB); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try (DataOutputStream data = new DataOutputStream(buffer)) { + data.writeInt(image.getWidth()); + data.writeInt(image.getHeight()); + int[] row = new int[image.getWidth()]; + for (int y = 0; y < image.getHeight(); y++) { + image.getRGB(0, y, image.getWidth(), 1, row, 0, image.getWidth()); + for (int pixel : row) { + data.writeByte(pixel >> 16); + data.writeByte(pixel >> 8); + data.writeByte(pixel); + } + } + } + return buffer.toByteArray(); + } + + public static void main(String[] args) throws IOException { + DataOutputStream output = new DataOutputStream(System.out); + String path = new String(Base64.getDecoder().decode(args[0]), StandardCharsets.UTF_8); + try (PDDocument document = Loader.loadPDF(new File(path))) { + reply(output, 0, Integer.toString(document.getNumberOfPages()).getBytes(StandardCharsets.US_ASCII)); + PDFRenderer renderer = new PDFRenderer(document); + BufferedReader input = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.US_ASCII)); + String command; + while ((command = input.readLine()) != null && !command.equals("close")) { + String[] parts = command.split(" "); + int page = Integer.parseInt(parts[1]); + if (page < 0 || page >= document.getNumberOfPages()) { + throw new IllegalArgumentException("Page index out of range: " + page); + } + if (parts[0].equals("text")) { + reply(output, 0, text(document, page)); + } else if (parts[0].equals("render")) { + reply(output, 0, render(renderer, page, Integer.parseInt(parts[2]))); + } else { + throw new IllegalArgumentException("Unknown command: " + command); + } + } + } catch (IOException | RuntimeException error) { + error.printStackTrace(System.err); + reply(output, 1, error.toString().getBytes(StandardCharsets.UTF_8)); + System.exit(1); + } + } +} diff --git a/tests/MiniPdf.Benchmark/pdfbox_backend.py b/tests/MiniPdf.Benchmark/pdfbox_backend.py new file mode 100644 index 00000000..d05346b8 --- /dev/null +++ b/tests/MiniPdf.Benchmark/pdfbox_backend.py @@ -0,0 +1,172 @@ +"""Apache PDFBox transport for the shared visual benchmark (Java 11+).""" + +import base64 +import shutil +import struct +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path +from types import TracebackType +from typing import BinaryIO, Optional + + +EVALUATOR = { + "name": "Apache PDFBox", + "version": "3.0.8", + "text_normalization": "baseline-rows-v1", + "scoring": "text40-visual40-pages20-v1", + "dpi": 150, +} +JAR = Path(__file__).parent / "pdfbox" / "target" / "benchmark-pdfbox.jar" + + +def check_backend() -> str: + java = shutil.which("java") + if java is None: + raise RuntimeError("The PDFBox benchmark requires Java 11+ on PATH.") + if not JAR.is_file(): + raise RuntimeError( + "PDFBox benchmark helper is not built. Run: " + f'mvn -f "{JAR.parent.parent / "pom.xml"}" package' + ) + return java + + +@dataclass(frozen=True) +class Pixmap: + width: int + height: int + samples: bytes + n: int = 3 + + def save(self, path: str) -> None: + from PIL import Image + + Image.frombytes("RGB", (self.width, self.height), self.samples).save(path) + + +def _read_exact(stream: BinaryIO, size: int) -> bytes: + chunks = bytearray() + while len(chunks) < size: + chunk = stream.read(size - len(chunks)) + if not chunk: + raise RuntimeError("PDFBox helper terminated before completing its response.") + chunks.extend(chunk) + return bytes(chunks) + + +class PdfDocument: + def __init__(self, path: str): + java = check_backend() + self._log = tempfile.TemporaryFile() + encoded_path = base64.b64encode(str(Path(path).resolve()).encode("utf-8")).decode("ascii") + try: + self._process = subprocess.Popen( + [java, "-Djava.awt.headless=true", "-jar", JAR.name, encoded_path], + cwd=JAR.parent, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=self._log, + ) + except OSError: + self._log.close() + raise + try: + self.page_count = int(self._response().decode("ascii")) + except (RuntimeError, ValueError): + self.close() + raise + + def _response(self) -> bytes: + assert self._process.stdout is not None + try: + status, length = struct.unpack(">BI", _read_exact(self._process.stdout, 5)) + payload = _read_exact(self._process.stdout, length) + except RuntimeError as error: + self._log.seek(0) + details = self._log.read().decode("utf-8", errors="replace") + raise RuntimeError(f"{error}\n{details}") from error + if status != 0: + raise RuntimeError(f"PDFBox: {payload.decode('utf-8', errors='replace')}") + return payload + + def _request(self, command: str, page: int) -> bytes: + if not 0 <= page < self.page_count: + raise IndexError(f"PDF page index out of range: {page}") + assert self._process.stdin is not None + self._process.stdin.write((command + "\n").encode("ascii")) + self._process.stdin.flush() + return self._response() + + def text_spans(self, page: int) -> list[tuple[float, float, str]]: + payload = self._request(f"text {page}", page) + count = struct.unpack_from(">I", payload)[0] + offset = 4 + spans = [] + for _ in range(count): + y, x, length = struct.unpack_from(">ffI", payload, offset) + offset += 12 + text = payload[offset:offset + length].decode("utf-8").strip() + offset += length + if text: + spans.append((round(y, 1), x, text)) + return spans + + def render(self, page: int, dpi: int = 150) -> Pixmap: + if not isinstance(dpi, int) or dpi <= 0: + raise ValueError("DPI must be a positive integer.") + payload = self._request(f"render {page} {dpi}", page) + width, height = struct.unpack_from(">II", payload) + samples = payload[8:] + if len(samples) != width * height * 3: + raise RuntimeError("PDFBox returned an invalid RGB image payload.") + return Pixmap(width, height, samples) + + def close(self) -> None: + process = self._process + try: + if process.stdin is not None: + process.stdin.close() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + finally: + if process.stdout is not None: + process.stdout.close() + self._log.close() + + def __enter__(self) -> "PdfDocument": + return self + + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + self.close() + + +def extract_text(pdf_path: str) -> list[str]: + pages = [] + with PdfDocument(pdf_path) as document: + for page in range(document.page_count): + spans = sorted(document.text_spans(page)) + lines = [] + current_y = None + tokens: list[tuple[float, str]] = [] + for y, x, text in spans: + if current_y is None or abs(y - current_y) > 1.0: + if tokens: + lines.append(" ".join(text for _, text in sorted(tokens))) + current_y = y + tokens = [(x, text)] + else: + tokens.append((x, text)) + if tokens: + lines.append(" ".join(text for _, text in sorted(tokens))) + pages.append("\n".join(lines)) + return pages diff --git a/tests/MiniPdf.Benchmark/run_benchmark.py b/tests/MiniPdf.Benchmark/run_benchmark.py index 401ccbe9..a083d823 100644 --- a/tests/MiniPdf.Benchmark/run_benchmark.py +++ b/tests/MiniPdf.Benchmark/run_benchmark.py @@ -5,7 +5,8 @@ This is the single entry point for the full "self-evolution" pipeline. Prerequisites: - pip install openpyxl pymupdf pywin32 + pip install openpyxl Pillow pywin32 + Java 11+ and the built PDFBox benchmark helper (see README.md) Microsoft 365 Excel installed (default reference engine) .NET 9 SDK (for MiniPdf) diff --git a/tests/MiniPdf.Benchmark/run_benchmark_docx.py b/tests/MiniPdf.Benchmark/run_benchmark_docx.py index 1ca2611e..49489e06 100644 --- a/tests/MiniPdf.Benchmark/run_benchmark_docx.py +++ b/tests/MiniPdf.Benchmark/run_benchmark_docx.py @@ -5,7 +5,8 @@ This mirrors the XLSX benchmark pipeline but for Word documents. Prerequisites: - pip install python-docx pymupdf Pillow pywin32 + pip install python-docx Pillow pywin32 + Java 11+ and the built PDFBox benchmark helper (see README.md) LibreOffice installed (for reference PDF generation) .NET 9 SDK (for MiniPdf) diff --git a/tests/MiniPdf.Benchmark/run_benchmark_pptx.py b/tests/MiniPdf.Benchmark/run_benchmark_pptx.py index 62561eed..0e018ff1 100644 --- a/tests/MiniPdf.Benchmark/run_benchmark_pptx.py +++ b/tests/MiniPdf.Benchmark/run_benchmark_pptx.py @@ -3,7 +3,8 @@ files to PDF via MiniPdf and LibreOffice, compares the results. Prerequisites: - pip install pymupdf Pillow + pip install Pillow + Java 11+ and the built PDFBox benchmark helper (see README.md) LibreOffice installed (for reference PDF generation) .NET 9 SDK (for MiniPdf) diff --git a/tests/MiniPdf.Benchmark/test_pdfbox_backend.py b/tests/MiniPdf.Benchmark/test_pdfbox_backend.py new file mode 100644 index 00000000..1e2e1b63 --- /dev/null +++ b/tests/MiniPdf.Benchmark/test_pdfbox_backend.py @@ -0,0 +1,197 @@ +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch +from zipfile import ZipFile + +from pdfbox_backend import EVALUATOR, JAR, PdfDocument, check_backend, extract_text +from compare_pdfs import compare_single, generate_report, render_page_to_pixels + + +def write_fixture(path: Path, page_count: int = 3) -> None: + content = ( + b"BT /F1 12 Tf 20 260 Td (Left) Tj ET\n" + b"BT /F1 12 Tf 110 260.5 Td (Right) Tj ET\n" + b"BT /F1 12 Tf 20 230 Td (Caf\\351) Tj ET\n" + ) + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + (b"<< /Type /Pages /Kids [" + + b" ".join(f"{page + 3} 0 R".encode("ascii") for page in range(page_count)) + + f"] /Count {page_count} >>".encode("ascii")), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] " + b"/Resources << /Font << /F1 6 0 R >> >> /Contents 7 0 R >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] " + b"/CropBox [10 20 110 220] /Rotate 90 /Resources << >> /Contents 8 0 R >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] " + b"/Resources << >> /Contents 8 0 R >>", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + b"<< /Length " + str(len(content)).encode("ascii") + b" >>\nstream\n" + content + b"\nendstream", + b"<< /Length 0 >>\nstream\n\nendstream", + ] + data = bytearray(b"%PDF-1.4\n") + offsets = [0] + for number, obj in enumerate(objects, 1): + offsets.append(len(data)) + data.extend(f"{number} 0 obj\n".encode("ascii") + obj + b"\nendobj\n") + xref = len(data) + data.extend(f"xref\n0 {len(objects) + 1}\n0000000000 65535 f \n".encode("ascii")) + for offset in offsets[1:]: + data.extend(f"{offset:010d} 00000 n \n".encode("ascii")) + data.extend( + f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n" + f"startxref\n{xref}\n%%EOF\n".encode("ascii") + ) + path.write_bytes(data) + + +class PdfBoxBackendTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + check_backend() + + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.directory = Path(self.temporary.name) + self.pdf = self.directory / "fixture \u6d4b\u8bd5.pdf" + write_fixture(self.pdf) + + def test_text_rows_unicode_and_blank_pages(self): + self.assertEqual(extract_text(str(self.pdf)), ["Left Right\nCaf\u00e9", "", ""]) + + def test_rgb_dimensions_rotation_crop_and_dpi(self): + with PdfDocument(str(self.pdf)) as document: + self.assertEqual(document.page_count, 3) + pix = document.render(0, 72) + self.assertEqual((pix.width, pix.height, pix.n), (200, 300, 3)) + self.assertEqual(len(pix.samples), 200 * 300 * 3) + self.assertEqual(pix.samples[:3], b"\xff\xff\xff") + self.assertLess(min(pix.samples), 255) + rotated = document.render(1, 72) + self.assertEqual((rotated.width, rotated.height), (200, 100)) + scaled = document.render(2, 144) + self.assertEqual((scaled.width, scaled.height), (400, 600)) + self.assertEqual(set(scaled.samples), {255}) + saved = self.directory / "page.png" + pix.save(str(saved)) + from PIL import Image + with Image.open(saved) as image: + self.assertEqual((image.mode, image.size), ("RGB", (200, 300))) + self.assertIsNotNone(document._process.poll()) + + def test_page_and_dpi_validation(self): + with PdfDocument(str(self.pdf)) as document: + for index in (-1, 3): + with self.assertRaises(IndexError): + document.render(index) + for dpi in (0, -72, 1.5): + with self.assertRaises(ValueError): + document.render(0, dpi) + self.assertIsNone(render_page_to_pixels(str(self.pdf), 3)) + + def test_invalid_pdf_is_an_error_not_an_empty_document(self): + invalid = self.directory / "invalid.pdf" + invalid.write_text("not a PDF", encoding="ascii") + with self.assertRaisesRegex(RuntimeError, "PDFBox"): + PdfDocument(str(invalid)) + with self.assertRaisesRegex(RuntimeError, "PDFBox"): + compare_single(str(invalid), str(self.pdf), str(self.directory), "invalid") + + def test_missing_pdf_is_an_explicit_case_error(self): + result = compare_single(str(self.pdf) + ".missing", str(self.pdf), str(self.directory), "missing") + self.assertEqual(result["error"], "MiniPdf PDF not found") + self.assertEqual(result["evaluator"], EVALUATOR) + + def test_identical_pdf_scores_and_all_report_images(self): + images = self.directory / "images" + composites = self.directory / "side-by-side" + result = compare_single( + str(self.pdf), str(self.pdf), str(images), "identical", + auxiliary_path=str(self.pdf), composite_images=True, + composite_output_dir=str(composites), heatmaps=True, + ) + self.assertTrue(result["pdf_valid"]) + self.assertEqual(result["minipdf_pages"], 3) + self.assertEqual(result["reference_pages"], 3) + self.assertEqual(result["visual_scores"], [1.0, 1.0, 1.0]) + self.assertEqual(result["text_similarity"], 1.0) + self.assertEqual(result["overall_score"], 1.0) + self.assertEqual(result["evaluator"], EVALUATOR) + self.assertEqual(len(result["diff_images"]), 3) + for page in result["diff_images"]: + for key in ("minipdf_img", "reference_img", "auxiliary_img", "heatmap_img"): + self.assertTrue((images / page[key]).is_file(), key) + self.assertTrue((composites / page["composite_img"]).is_file()) + generate_report([result], str(self.directory)) + stored = json.loads((self.directory / "comparison_report.json").read_text(encoding="utf-8")) + self.assertEqual(stored[0]["evaluator"], EVALUATOR) + self.assertIn("Apache PDFBox", (self.directory / "comparison_report.md").read_text(encoding="utf-8")) + + def test_cli_rejects_mixing_legacy_and_pdfbox_scores(self): + report = self.directory / "report" + report.mkdir() + (report / "comparison_report.json").write_text( + json.dumps([{"name": self.pdf.stem, "overall_score": 1.0}]), encoding="utf-8" + ) + process = subprocess.run( + [sys.executable, str(Path(__file__).with_name("compare_pdfs.py")), + "--minipdf-dir", str(self.directory), "--reference-dir", str(self.directory), + "--report-dir", str(report), "--filter", "fixture"], + capture_output=True, text=True, encoding="utf-8", + env={**os.environ, "PYTHONIOENCODING": "utf-8"}, + ) + self.assertNotEqual(process.returncode, 0) + self.assertIn("Cannot merge filtered results from different evaluators", process.stderr) + + def test_page_mismatch_keeps_penalty_and_missing_page_images(self): + shorter = self.directory / "short.pdf" + write_fixture(shorter, page_count=1) + images = self.directory / "mismatch" + result = compare_single(str(self.pdf), str(shorter), str(images), "mismatch") + self.assertEqual((result["minipdf_pages"], result["reference_pages"]), (3, 1)) + self.assertEqual(result["visual_scores"][1:], [0.0, 0.0]) + self.assertEqual(len(result["diff_images"]), 3) + self.assertIsNone(result["diff_images"][1]["reference_img"]) + expected = round( + result["text_similarity"] * 0.4 + result["visual_avg"] * 0.4 + 0.5 * 0.2, 4 + ) + self.assertEqual(result["overall_score"], expected) + + def test_report_only_does_not_relabel_legacy_results(self): + legacy = { + "name": "legacy", "minipdf_pages": 1, "reference_pages": 1, + "overall_score": 1.0, "text_similarity": 1.0, "visual_avg": 1.0, + } + generate_report([legacy], str(self.directory)) + stored = json.loads((self.directory / "comparison_report.json").read_text(encoding="utf-8")) + self.assertNotIn("evaluator", stored[0]) + markdown = (self.directory / "comparison_report.md").read_text(encoding="utf-8") + self.assertIn("unversioned", markdown) + self.assertNotIn("Apache PDFBox", markdown) + + def test_missing_runtime_and_helper_are_actionable_errors(self): + with patch("pdfbox_backend.shutil.which", return_value=None): + with self.assertRaisesRegex(RuntimeError, "Java 11"): + check_backend() + with patch("pdfbox_backend.JAR", self.directory / "missing.jar"): + with self.assertRaisesRegex(RuntimeError, "mvn"): + check_backend() + + def test_helper_preserves_dependency_licenses_and_notices(self): + with ZipFile(JAR) as jar: + license_text = jar.read("META-INF/LICENSE").decode("utf-8") + notices = jar.read("META-INF/NOTICE").decode("utf-8") + self.assertIn("Apache License", license_text) + self.assertIn("SIL OPEN FONT LICENSE", license_text) + self.assertIn("Apache PDFBox", notices) + self.assertIn("Apache FontBox", notices) + self.assertIn("Apache Commons Logging", notices) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/MiniPdf.Tests/ExcelToPdfConverterTests.cs b/tests/MiniPdf.Tests/ExcelToPdfConverterTests.cs index 728ce9c0..7d0c124c 100644 --- a/tests/MiniPdf.Tests/ExcelToPdfConverterTests.cs +++ b/tests/MiniPdf.Tests/ExcelToPdfConverterTests.cs @@ -1,10 +1,504 @@ using System.IO.Compression; using System.Text; +using System.Xml.Linq; namespace MiniSoftware.Tests; public class ExcelToPdfConverterTests { + [Theory] + [InlineData("Calibri", false)] + [InlineData("Arial", false)] + [InlineData("Calibri", true)] + public void Convert_FontWithoutFamily_UsesWorkbookNormalFont(string normalFont, bool includeTheme) + { + using var excelStream = CreateSimpleExcel(new[] { new[] { "Company", "Explicit", "Normal" } }, + columnWidths: new[] { 20f, 20f, 20f }); + using (var archive = new ZipArchive(excelStream, ZipArchiveMode.Update, leaveOpen: true)) + { + AddEntry(archive, "xl/styles.xml", $$""" + + + + + + + + + + + """); + if (includeTheme) + AddEntry(archive, "xl/theme/theme1.xml", $$""" + + + + + + """); + archive.GetEntry("xl/worksheets/sheet1.xml")!.Delete(); + AddEntry(archive, "xl/worksheets/sheet1.xml", """ + + + + Company + Explicit + Normal + + + """); + } + excelStream.Position = 0; + using var doc = ExcelToPdfConverter.Convert(excelStream); + var blocks = Assert.Single(doc.Pages).TextBlocks; + var company = Assert.Single(blocks, block => block.Text == "Company"); + Assert.Equal(normalFont, company.PreferredFontName); + Assert.Equal(10f, company.FontSize); + Assert.True(company.Bold); + var explicitFont = Assert.Single(blocks, block => block.Text == "Explicit"); + Assert.Equal("Verdana", explicitFont.PreferredFontName); + var normal = Assert.Single(blocks, block => block.Text == "Normal"); + Assert.Equal(normalFont, normal.PreferredFontName); + Assert.False(normal.Bold); + Assert.Equal(11f, normal.FontSize); + if (normalFont == "Calibri" && + PdfWriter.TryMeasurePreferredFontWidth(normalFont, company.Text, company.FontSize, true, false, 0, out var width)) + Assert.Equal(explicitFont.X - 3.96f, company.X + width, 2); + } + + [Theory] + [InlineData("thin", 1f)] + [InlineData("medium", 2f)] + [InlineData("thick", 3f)] + public void Convert_SolidBorder_PreservesLineWeight(string style, float width) + { + using var excelStream = CreateSimpleExcel(new[] { new[] { "Border" } }); + using (var archive = new ZipArchive(excelStream, ZipArchiveMode.Update, leaveOpen: true)) + { + AddEntry(archive, "xl/styles.xml", $$""" + + + + + """); + archive.GetEntry("xl/worksheets/sheet1.xml")!.Delete(); + AddEntry(archive, "xl/worksheets/sheet1.xml", """ + + + Border + + + """); + } + excelStream.Position = 0; + using var doc = ExcelToPdfConverter.Convert(excelStream); + Assert.Equal(width, Assert.Single(Assert.Single(doc.Pages).LineBlocks).LineWidth); + } + + [Fact] + public void Convert_MergedHeading_DoesNotInflateIndividualColumnWidths() + { + using var excelStream = CreateSimpleExcel(new[] + { + new[] { "A long heading spanning both columns", "" }, + new[] { "Left", "Right" }, + }); + using (var archive = new ZipArchive(excelStream, ZipArchiveMode.Update, leaveOpen: true)) + { + var entry = archive.GetEntry("xl/worksheets/sheet1.xml")!; + XDocument worksheet; + using (var input = entry.Open()) + worksheet = XDocument.Load(input); + var ns = worksheet.Root!.Name.Namespace; + worksheet.Root.Add(new XElement(ns + "mergeCells", + new XElement(ns + "mergeCell", new XAttribute("ref", "A1:B1")))); + entry.Delete(); + AddEntry(archive, "xl/worksheets/sheet1.xml", worksheet.ToString()); + } + excelStream.Position = 0; + using var doc = ExcelToPdfConverter.Convert(excelStream); + var right = Assert.Single(Assert.Single(doc.Pages).TextBlocks, block => block.Text == "Right"); + Assert.InRange(right.X, 100f, 115f); + } + + [Theory] + [InlineData("top", 100)] + [InlineData("bottom", 100)] + [InlineData("left", 100)] + [InlineData("right", 100)] + [InlineData("top", 50)] + [InlineData("left", 50)] + public void Convert_DoubleBorder_RendersTwoScaledSolidStrokes(string side, int printScale) + { + using var excelStream = CreateSimpleExcel(new[] { new[] { "Total" } }); + using (var archive = new ZipArchive(excelStream, ZipArchiveMode.Update, leaveOpen: true)) + { + AddEntry(archive, "xl/styles.xml", $$""" + + <{{side}} style="double"> + + + """); + archive.GetEntry("xl/worksheets/sheet1.xml")!.Delete(); + AddEntry(archive, "xl/worksheets/sheet1.xml", $$""" + + Total + + + """); + } + excelStream.Position = 0; + using var doc = ExcelToPdfConverter.Convert(excelStream); + var lines = Assert.Single(doc.Pages).LineBlocks; + Assert.Equal(2, lines.Count); + var scale = printScale / 100f; + Assert.All(lines, line => + { + Assert.Equal(scale, line.LineWidth, 3); + Assert.Equal(new PdfColor(1f, 0f, 0f), line.Color); + Assert.Null(line.DashPattern); + }); + if (side is "top" or "bottom") + { + Assert.All(lines, line => Assert.Equal(line.Y1, line.Y2)); + Assert.Equal(lines[0].X1, lines[1].X1); + Assert.Equal(lines[0].X2, lines[1].X2); + Assert.Equal(2f * scale, Math.Abs(lines[0].Y1 - lines[1].Y1), 3); + } + else + { + Assert.All(lines, line => Assert.Equal(line.X1, line.X2)); + Assert.Equal(lines[0].Y1, lines[1].Y1); + Assert.Equal(lines[0].Y2, lines[1].Y2); + Assert.Equal(2f * scale, Math.Abs(lines[0].X1 - lines[1].X1), 3); + } + } + + [Theory] + [InlineData("top", "bottom")] + [InlineData("bottom", "top")] + [InlineData("left", "right")] + [InlineData("right", "left")] + public void Convert_DoubleBorder_TakesPrecedenceOverAdjacentThinBorder(string side, string opposite) + { + using var excelStream = CreateSimpleExcel(new[] { new[] { "Total", "Other" } }, columnWidths: new[] { 20f, 20f }); + using (var archive = new ZipArchive(excelStream, ZipArchiveMode.Update, leaveOpen: true)) + { + AddEntry(archive, "xl/styles.xml", $$""" + + + <{{opposite}} style="thin"/> + <{{side}} style="double"/> + + + + """); + var firstStyle = side is "bottom" or "right" ? 1 : 0; + var secondStyle = 1 - firstStyle; + var cells = side is "top" or "bottom" + ? $"""FirstSecond""" + : $"""FirstSecond"""; + archive.GetEntry("xl/worksheets/sheet1.xml")!.Delete(); + AddEntry(archive, "xl/worksheets/sheet1.xml", $$""" + + + {{cells}} + + """); + } + excelStream.Position = 0; + using var doc = ExcelToPdfConverter.Convert(excelStream); + var lines = Assert.Single(doc.Pages).LineBlocks; + Assert.Equal(2, lines.Count); + Assert.All(lines, line => Assert.Equal(1f, line.LineWidth)); + var separation = side is "top" or "bottom" + ? Math.Abs(lines[0].Y1 - lines[1].Y1) + : Math.Abs(lines[0].X1 - lines[1].X1); + Assert.Equal(2f, separation, 3); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void Convert_DoubleBordersAcrossPages_PreserveSharedEdges(bool lowMemory, bool manualBreaks) + { + using var excelStream = CreateSimpleExcel( + Enumerable.Range(1, 120).Select(row => new[] { $"Detail {row}" }).ToArray(), + columnWidths: new[] { 20f }); + using (var archive = new ZipArchive(excelStream, ZipArchiveMode.Update, leaveOpen: true)) + { + AddEntry(archive, "xl/styles.xml", """ + + + + + """); + var entry = archive.GetEntry("xl/worksheets/sheet1.xml")!; + XDocument worksheet; + using (var input = entry.Open()) + worksheet = XDocument.Load(input); + var ns = worksheet.Root!.Name.Namespace; + foreach (var cell in worksheet.Descendants(ns + "c")) + cell.SetAttributeValue("s", 0); + if (manualBreaks) + worksheet.Root.Add(new XElement(ns + "rowBreaks", + new XAttribute("count", 2), new XAttribute("manualBreakCount", 2), + new XElement(ns + "brk", new XAttribute("id", 25), new XAttribute("man", 1)), + new XElement(ns + "brk", new XAttribute("id", 75), new XAttribute("man", 1)))); + entry.Delete(); + AddEntry(archive, "xl/worksheets/sheet1.xml", worksheet.ToString()); + } + excelStream.Position = 0; + using var doc = ExcelToPdfConverter.Convert(excelStream, + new ExcelToPdfConverter.ConversionOptions { LowMemory = lowMemory }); + Assert.True(doc.Pages.Count > 1); + Assert.Equal(120, doc.Pages.Sum(page => page.TextBlocks.Count)); + Assert.All(doc.Pages, page => + { + Assert.NotEmpty(page.TextBlocks); + Assert.Equal(2 * page.TextBlocks.Count + 1, page.LineBlocks.Count); + Assert.All(page.LineBlocks, line => + { + Assert.Equal(line.Y1, line.Y2); + Assert.Equal(1f, line.LineWidth); + Assert.Null(line.DashPattern); + }); + }); + using var actual = new MemoryStream(); + doc.Save(actual); + excelStream.Position = 0; + using var expected = ExcelToPdfConverter.Convert(excelStream); + using var expectedPdf = new MemoryStream(); + expected.Save(expectedPdf); + Assert.Equal(expectedPdf.ToArray(), actual.ToArray()); + } + + [Theory] + [InlineData("Calibri", false, 15f, 14.52f)] + [InlineData("Calibri", true, 15f, 15f)] + [InlineData("Calibri", false, 20f, 20f)] + [InlineData("Arial", false, 15f, 15f)] + public void Convert_DefaultRowHeight_PreservesCustomDefaults( + string fontName, bool customHeight, float defaultHeight, float expectedHeight) + { + using var excelStream = CreateSimpleExcel(new[] { new[] { "First" }, new[] { "Second" } }); + using (var archive = new ZipArchive(excelStream, ZipArchiveMode.Update, leaveOpen: true)) + { + AddEntry(archive, "xl/styles.xml", $$""" + + + + + """); + archive.GetEntry("xl/worksheets/sheet1.xml")!.Delete(); + var height = defaultHeight.ToString(System.Globalization.CultureInfo.InvariantCulture); + AddEntry(archive, "xl/worksheets/sheet1.xml", $$""" + + + + First + Second + + + """); + } + excelStream.Position = 0; + using var doc = ExcelToPdfConverter.Convert(excelStream); + var blocks = Assert.Single(doc.Pages).TextBlocks; + var first = Assert.Single(blocks, block => block.Text == "First"); + var second = Assert.Single(blocks, block => block.Text == "Second"); + Assert.Equal(expectedHeight, first.Y - second.Y, 3); + } + + [Theory] + [InlineData(true, 2)] + [InlineData(false, 3)] + public void Convert_DoubleBorder_ResolvesOverlappingMergedEdges(bool mergeDouble, int expectedLines) + { + using var excelStream = CreateSimpleExcel(new[] { new[] { "First", "Second" } }); + using (var archive = new ZipArchive(excelStream, ZipArchiveMode.Update, leaveOpen: true)) + { + AddEntry(archive, "xl/styles.xml", """ + + + + + + + + """); + var mergeRef = mergeDouble ? "A2:B2" : "A1:B1"; + archive.GetEntry("xl/worksheets/sheet1.xml")!.Delete(); + AddEntry(archive, "xl/worksheets/sheet1.xml", $$""" + + + + First + Total + + + + """); + } + excelStream.Position = 0; + using var doc = ExcelToPdfConverter.Convert(excelStream); + var lines = Assert.Single(doc.Pages).LineBlocks; + Assert.Equal(expectedLines, lines.Count); + var doubleLines = lines.OrderBy(line => line.Y1).ToArray(); + Assert.Equal(2f, doubleLines[^1].Y1 - doubleLines[0].Y1, 3); + if (!mergeDouble) + { + var middle = doubleLines[1]; + Assert.Equal(doubleLines[0].X2, middle.X1, 3); + Assert.True(middle.X2 > middle.X1); + } + } + + [Fact] + public void Convert_CalibriColumnWidths_MatchExcelPrintMetrics() + { + using var excelStream = CreateSimpleExcel(new[] { new[] { "A", "B", "C" } }, + columnWidths: new[] { 25f, 15f, 15f }); + using (var archive = new ZipArchive(excelStream, ZipArchiveMode.Update, leaveOpen: true)) + { + AddEntry(archive, "xl/styles.xml", """ + + + + + """); + } + excelStream.Position = 0; + using var doc = ExcelToPdfConverter.Convert(excelStream); + var blocks = Assert.Single(doc.Pages).TextBlocks; + var a = Assert.Single(blocks, block => block.Text == "A"); + var b = Assert.Single(blocks, block => block.Text == "B"); + var c = Assert.Single(blocks, block => block.Text == "C"); + Assert.InRange(b.X - a.X, 140.65f, 141.45f); + Assert.InRange(c.X - b.X, 84.22f, 85.02f); + } + + [Fact] + public void Convert_DoubleBorder_VerticalMergeContinuesAcrossPages() + { + using var excelStream = CreateSimpleExcel(new[] { new[] { "Total", "Detail" } }); + using (var archive = new ZipArchive(excelStream, ZipArchiveMode.Update, leaveOpen: true)) + { + AddEntry(archive, "xl/styles.xml", """ + + + + + """); + var rows = new StringBuilder(); + for (var row = 1; row <= 80; row++) + { + rows.Append($""""""); + if (row == 1) + rows.Append("""Total"""); + rows.Append($"""Detail {row}"""); + } + archive.GetEntry("xl/worksheets/sheet1.xml")!.Delete(); + AddEntry(archive, "xl/worksheets/sheet1.xml", $$""" + + + {{rows}} + + + """); + } + excelStream.Position = 0; + using var doc = ExcelToPdfConverter.Convert(excelStream); + Assert.Equal(2, doc.Pages.Count); + Assert.All(doc.Pages, page => + { + var lines = page.LineBlocks.OrderBy(line => line.X1).ToArray(); + Assert.Equal(4, lines.Length); + Assert.All(lines, line => + { + Assert.Equal(line.X1, line.X2); + Assert.Equal(1f, line.LineWidth); + Assert.Null(line.DashPattern); + }); + Assert.Equal(2f, lines[1].X1 - lines[0].X1, 3); + Assert.Equal(2f, lines[3].X1 - lines[2].X1, 3); + }); + } + + [Fact] + public void Convert_DefaultCalibriFont_PreservesUnsupportedScriptFallback() + { + const string sample = "\u0E20\u0E32\u0E29\u0E32\u0E44\u0E17\u0E22"; + using var excelStream = CreateSimpleExcel(new[] { new[] { "Label", sample } }, + columnWidths: new[] { 20f, 30f }); + using (var archive = new ZipArchive(excelStream, ZipArchiveMode.Update, leaveOpen: true)) + { + AddEntry(archive, "xl/styles.xml", """ + + + + + """); + } + excelStream.Position = 0; + using var doc = ExcelToPdfConverter.Convert(excelStream); + var page = Assert.Single(doc.Pages); + var label = Assert.Single(page.TextBlocks, block => block.Text == "Label"); + var script = Assert.Single(page.TextBlocks, block => block.Text == sample); + Assert.Equal("Calibri", label.PreferredFontName); + var supported = PdfWriter.TryMeasurePreferredFontWidth("Calibri", sample, 11f, false, false, 0f, out _); + Assert.Equal(supported ? "Calibri" : null, script.PreferredFontName); + } + + [Theory] + [InlineData(0, false)] + [InlineData(3, true)] + public void Convert_ChartAnchorsOutsideData_UseDefaultWidths(int anchorCol, bool useDefaultWidths) + { + using var excelStream = CreateExcelWithBarChart(new[] { "Category" }, new[] { 140.0 }); + using (var archive = new ZipArchive(excelStream, ZipArchiveMode.Update, leaveOpen: true)) + { + AddEntry(archive, "xl/styles.xml", """ + + + + + """); + var entry = archive.GetEntry("xl/worksheets/sheet1.xml")!; + XDocument worksheet; + using (var input = entry.Open()) + worksheet = XDocument.Load(input); + var ns = worksheet.Root!.Name.Namespace; + worksheet.Descendants(ns + "sheetData").Single().Add( + new XElement(ns + "row", new XAttribute("r", 1), + new XElement(ns + "c", new XAttribute("r", "A1"), new XAttribute("s", 1), new XAttribute("t", "inlineStr"), + new XElement(ns + "is", new XElement(ns + "t", "A long overflowing worksheet heading")))), + new XElement(ns + "row", new XAttribute("r", 2), + new XElement(ns + "c", new XAttribute("r", "B2"), new XAttribute("t", "inlineStr"), + new XElement(ns + "is", new XElement(ns + "t", "Cell B2"))))); + entry.Delete(); + AddEntry(archive, "xl/worksheets/sheet1.xml", worksheet.ToString()); + var drawingEntry = archive.GetEntry("xl/drawings/drawing1.xml")!; + XDocument drawing; + using (var input = drawingEntry.Open()) + drawing = XDocument.Load(input); + var drawingNs = drawing.Root!.Name.Namespace; + drawing.Descendants(drawingNs + "from").Single().Element(drawingNs + "col")!.Value = + anchorCol.ToString(System.Globalization.CultureInfo.InvariantCulture); + drawingEntry.Delete(); + AddEntry(archive, "xl/drawings/drawing1.xml", drawing.ToString()); + } + excelStream.Position = 0; + using var doc = ExcelToPdfConverter.Convert(excelStream); + var cell = Assert.Single(doc.Pages.SelectMany(page => page.TextBlocks), block => block.Text == "Cell B2"); + if (useDefaultWidths) + Assert.InRange(cell.X, 103f, 104f); + else + Assert.True(cell.X > 150f); + } + [Fact] public void Convert_SimpleExcel_ProducesValidPdf() { diff --git a/tests/MiniPdf.Tests/XlsxIssueFileTests.cs b/tests/MiniPdf.Tests/XlsxIssueFileTests.cs index 1ee26e76..781163cf 100644 --- a/tests/MiniPdf.Tests/XlsxIssueFileTests.cs +++ b/tests/MiniPdf.Tests/XlsxIssueFileTests.cs @@ -103,10 +103,10 @@ public void AcademicAchievement_ManualPageBreak_UsesIntegerScaleForFixedRows() .ToArray(); var repeatedFixedRows = horizontalGridLines .Zip(horizontalGridLines.Skip(1), (top, bottom) => top - bottom) - .Count(height => Math.Abs(height - 28.4625f) < 0.001f); + .Count(height => Math.Abs(height - 28.083f) < 0.001f); Assert.True(repeatedFixedRows >= 8, - $"Expected repeated 37.95pt rows at 75% scale, found {repeatedFixedRows} matching rows."); + $"Expected repeated 37.95pt rows at 74% scale, found {repeatedFixedRows} matching rows."); } [Fact]