diff --git a/src/MiniPdf/DocxReader.cs b/src/MiniPdf/DocxReader.cs index 0dd00f64..d3b5a6d6 100644 --- a/src/MiniPdf/DocxReader.cs +++ b/src/MiniPdf/DocxReader.cs @@ -2414,8 +2414,8 @@ private static string GetRelationshipsPath(string partPath) return partPath.Substring(0, slashIndex) + "/_rels/" + partPath.Substring(slashIndex + 1) + ".rels"; } - private static byte[]? TryConvertMetafileToPng(byte[] sourceBytes, long widthEmu, long heightEmu, - float cropL = 0, float cropT = 0, float cropR = 0, float cropB = 0) + internal static byte[]? TryConvertMetafileToPng(byte[] sourceBytes, long widthEmu, long heightEmu, + float cropL = 0, float cropT = 0, float cropR = 0, float cropB = 0, int rasterHeight = 512) { if (!Compat.IsWindows()) return null; @@ -2438,7 +2438,7 @@ private static string GetRelationshipsPath(string partPath) ? (double)widthEmu / heightEmu : (meta.Width > 0 && meta.Height > 0 ? (double)meta.Width / meta.Height : 1.0); - var targetHeight = 512; + var targetHeight = rasterHeight; var targetWidth = (int)Math.Round(targetHeight * aspect); targetWidth = Compat.Clamp(targetWidth, 32, 4096); targetHeight = Compat.Clamp(targetHeight, 32, 4096); diff --git a/src/MiniPdf/ExcelReader.cs b/src/MiniPdf/ExcelReader.cs index bf434011..c53d2bd5 100644 --- a/src/MiniPdf/ExcelReader.cs +++ b/src/MiniPdf/ExcelReader.cs @@ -53,7 +53,7 @@ internal static List ReadSheets(Stream stream, System.Globalization. var borders = ReadBorders(archive, themeColors); var numberFormats = ReadNumberFormats(archive); var dxfStyles = ReadDxfStyles(archive, themeColors); - var (cellXfFontIndices, cellXfFillIndices, cellXfNumFmtIds, cellXfAlignments, cellXfVerticalAlignments, cellXfBorderIndices, cellXfWrapTexts, cellXfIndents) = ReadCellXfStyles(archive); + var (cellXfFontIndices, cellXfFillIndices, cellXfNumFmtIds, cellXfAlignments, cellXfVerticalAlignments, cellXfTextRotations, cellXfBorderIndices, cellXfWrapTexts, cellXfIndents) = ReadCellXfStyles(archive); // Read workbook to get sheet names and order var (sheetInfos, printAreas, printTitleRows) = ReadWorkbook(archive); @@ -79,7 +79,7 @@ internal static List ReadSheets(Stream stream, System.Globalization. if (entry == null) continue; var hyperlinks = ReadWorksheetHyperlinks(archive, entry.FullName); - var rows = ReadSheet(entry, sharedStrings, boldPrefixLengths, fontStyles, fillColors, borders, numberFormats, cellXfFontIndices, cellXfFillIndices, cellXfNumFmtIds, cellXfAlignments, cellXfVerticalAlignments, cellXfBorderIndices, cellXfWrapTexts, cellXfIndents, hyperlinks, culture); + var rows = ReadSheet(entry, sharedStrings, boldPrefixLengths, fontStyles, fillColors, borders, numberFormats, cellXfFontIndices, cellXfFillIndices, cellXfNumFmtIds, cellXfAlignments, cellXfVerticalAlignments, cellXfTextRotations, cellXfBorderIndices, cellXfWrapTexts, cellXfIndents, hyperlinks, culture); var images = ReadSheetImages(archive, entry.FullName); var drawingShapes = ReadSheetShapes(archive, entry.FullName, themeColors); var (colWidths, defaultColWidth) = ReadColumnWidths(entry); @@ -96,7 +96,7 @@ internal static List ReadSheets(Stream stream, System.Globalization. var hasPrintArea = printAreas.TryGetValue(currentIndex, out var printArea); var hasPrintTitleRows = printTitleRows.TryGetValue(currentIndex, out var printTitleRow); ApplyTableStyleFormatting(archive, entry.FullName, rows, dxfStyles); - sheets.Add(new ExcelSheet(info.Name, rows, images, colWidths, defaultColWidth, mergedCells: mergedCells, shapes: drawingShapes, rowHeights: rowHeights, defaultRowHeight: defaultRowHeight, customHeightRows: customHeightRows, isLandscape: pageSetup.IsLandscape, printScale: pageSetup.Scale, paperSize: ignoreInferredFitToPage && pageSetup.PaperSize == 1 ? 9 : pageSetup.PaperSize, printArea: hasPrintArea ? printArea : null, 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, printTitleRows: hasPrintTitleRows ? printTitleRow : null, rowBreaks: rowBreaks, oddFooter: pageSetup.OddFooter, footerMarginPt: pageSetup.FooterMarginPt, maxDigitWidthPx: maxDigitWidthPx)); + sheets.Add(new ExcelSheet(info.Name, rows, images, colWidths, defaultColWidth, mergedCells: mergedCells, shapes: drawingShapes, rowHeights: rowHeights, defaultRowHeight: defaultRowHeight, customHeightRows: customHeightRows, isLandscape: pageSetup.IsLandscape, printScale: pageSetup.Scale, paperSize: ignoreInferredFitToPage && pageSetup.PaperSize == 1 ? 9 : pageSetup.PaperSize, printArea: hasPrintArea ? printArea : null, 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, printTitleRows: hasPrintTitleRows ? printTitleRow : null, rowBreaks: rowBreaks, oddFooter: pageSetup.OddFooter, footerMarginPt: pageSetup.FooterMarginPt, maxDigitWidthPx: maxDigitWidthPx)); sheetEntries.Add(entry); sheetEntryPaths.Add(entry.FullName); } @@ -108,14 +108,14 @@ internal static List ReadSheets(Stream stream, System.Globalization. if (entry != null) { var hyperlinks = ReadWorksheetHyperlinks(archive, entry.FullName); - var rows = ReadSheet(entry, sharedStrings, boldPrefixLengths, fontStyles, fillColors, borders, numberFormats, cellXfFontIndices, cellXfFillIndices, cellXfNumFmtIds, cellXfAlignments, cellXfVerticalAlignments, cellXfBorderIndices, cellXfWrapTexts, cellXfIndents, hyperlinks, culture); + var rows = ReadSheet(entry, sharedStrings, boldPrefixLengths, fontStyles, fillColors, borders, numberFormats, cellXfFontIndices, cellXfFillIndices, cellXfNumFmtIds, cellXfAlignments, cellXfVerticalAlignments, cellXfTextRotations, cellXfBorderIndices, cellXfWrapTexts, cellXfIndents, hyperlinks, culture); var images = ReadSheetImages(archive, entry.FullName); var (colWidths, defaultColWidth) = ReadColumnWidths(entry); var mergedCells = ReadMergedCells(entry); var (rowHeights, defaultRowHeight, customHeightRows) = ReadRowHeights(entry); 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, maxDigitWidthPx: maxDigitWidthPx)); + 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)); sheetEntryPaths.Add(entry.FullName); } } @@ -814,18 +814,19 @@ private static bool ColorsMatch(PdfColor? a, PdfColor? b) /// Reads cellXf style entries from styles.xml. /// Returns (fontIndices, fillIndices, numFmtIds) parallel lists. /// - private static (List FontIndices, List FillIndices, List NumFmtIds, List Alignments, List VerticalAlignments, List BorderIndices, List WrapTexts, List Indents) ReadCellXfStyles(ZipArchive archive) + private static (List FontIndices, List FillIndices, List NumFmtIds, List Alignments, List VerticalAlignments, List TextRotations, List BorderIndices, List WrapTexts, List Indents) ReadCellXfStyles(ZipArchive archive) { var fontIndices = new List(); var fillIndices = new List(); var numFmtIds = new List(); var alignments = new List(); var verticalAlignments = new List(); + var textRotations = new List(); var borderIndices = new List(); var wrapTexts = new List(); var indents = new List(); var entry = archive.GetEntry("xl/styles.xml"); - if (entry == null) return (fontIndices, fillIndices, numFmtIds, alignments, verticalAlignments, borderIndices, wrapTexts, indents); + if (entry == null) return (fontIndices, fillIndices, numFmtIds, alignments, verticalAlignments, textRotations, borderIndices, wrapTexts, indents); using var stream = entry.Open(); var doc = XDocument.Load(stream); @@ -833,7 +834,7 @@ private static (List FontIndices, List FillIndices, List NumFmtId // Read -> elements var cellXfs = doc.Descendants(ns + "cellXfs").FirstOrDefault(); - if (cellXfs == null) return (fontIndices, fillIndices, numFmtIds, alignments, verticalAlignments, borderIndices, wrapTexts, indents); + if (cellXfs == null) return (fontIndices, fillIndices, numFmtIds, alignments, verticalAlignments, textRotations, borderIndices, wrapTexts, indents); foreach (var xf in cellXfs.Elements(ns + "xf")) { @@ -849,20 +850,24 @@ private static (List FontIndices, List FillIndices, List NumFmtId var alignment = xf.Element(ns + "alignment")?.Attribute("horizontal")?.Value ?? "general"; alignments.Add(alignment); - var verticalAlignment = xf.Element(ns + "alignment")?.Attribute("vertical")?.Value ?? "bottom"; + var alignmentElement = xf.Element(ns + "alignment"); + var verticalAlignment = alignmentElement?.Attribute("vertical")?.Value ?? "bottom"; verticalAlignments.Add(verticalAlignment); + var textRotationAttr = alignmentElement?.Attribute("textRotation")?.Value; + textRotations.Add(int.TryParse(textRotationAttr, out var textRotation) ? textRotation : 0); + var borderId = xf.Attribute("borderId")?.Value; borderIndices.Add(int.TryParse(borderId, out var bid) ? bid : 0); - var wrapTextAttr = xf.Element(ns + "alignment")?.Attribute("wrapText")?.Value; + var wrapTextAttr = alignmentElement?.Attribute("wrapText")?.Value; wrapTexts.Add(wrapTextAttr == "1" || string.Equals(wrapTextAttr, "true", StringComparison.OrdinalIgnoreCase)); - var indentAttr = xf.Element(ns + "alignment")?.Attribute("indent")?.Value; + var indentAttr = alignmentElement?.Attribute("indent")?.Value; indents.Add(int.TryParse(indentAttr, out var ind) ? ind : 0); } - return (fontIndices, fillIndices, numFmtIds, alignments, verticalAlignments, borderIndices, wrapTexts, indents); + return (fontIndices, fillIndices, numFmtIds, alignments, verticalAlignments, textRotations, borderIndices, wrapTexts, indents); } /// @@ -1811,7 +1816,7 @@ private static FontStyleInfo ResolveFontStyle(int styleIndex, List> ReadSheet(ZipArchiveEntry entry, List sharedStrings, Dictionary boldPrefixLengths, List fontStyles, List fillColors, List borders, Dictionary numberFormats, - List cellXfFontIndices, List cellXfFillIndices, List cellXfNumFmtIds, List cellXfAlignments, List cellXfVerticalAlignments, List cellXfBorderIndices, List cellXfWrapTexts, List cellXfIndents, + List cellXfFontIndices, List cellXfFillIndices, List cellXfNumFmtIds, List cellXfAlignments, List cellXfVerticalAlignments, List cellXfTextRotations, List cellXfBorderIndices, List cellXfWrapTexts, List cellXfIndents, Dictionary? hyperlinks = null, System.Globalization.CultureInfo? culture = null) { var rows = new List>(); @@ -1950,6 +1955,7 @@ private static List> ReadSheet(ZipArchiveEntry entry, List> ReadSheet(ZipArchiveEntry entry, List= 0 && styleIndex < cellXfVerticalAlignments.Count) cellVerticalAlignment = cellXfVerticalAlignments[styleIndex]; + if (styleIndex >= 0 && styleIndex < cellXfTextRotations.Count) + cellTextRotation = cellXfTextRotations[styleIndex]; if (styleIndex >= 0 && styleIndex < cellXfWrapTexts.Count) wrapText = cellXfWrapTexts[styleIndex]; if (styleIndex >= 0 && styleIndex < cellXfIndents.Count) @@ -2061,7 +2069,7 @@ private static List> ReadSheet(ZipArchiveEntry entry, List @@ -3338,7 +3348,7 @@ private static (int row, int col) ParseCellRef(string cellRef) /// private static List ReadSheetImages(ZipArchive archive, string worksheetPath) { - var images = new List(); + var images = ReadLegacyDrawingImages(archive, worksheetPath); // Step 1: Find the sheet relationships file to locate the drawing var sheetRelsPath = GetRelationshipPartPath(worksheetPath); @@ -3612,6 +3622,154 @@ static bool IsRasterImagePath(string path) return images; } + private static List ReadLegacyDrawingImages(ZipArchive archive, string worksheetPath) + { + var images = new List(); + var sheetRelsEntry = archive.GetEntry(GetRelationshipPartPath(worksheetPath)); + if (sheetRelsEntry == null) return images; + + string? vmlPath = null; + using (var relsStream = sheetRelsEntry.Open()) + { + var relsDoc = XDocument.Load(relsStream); + var relationship = relsDoc.Descendants().FirstOrDefault(element => + element.Attribute("Type")?.Value.EndsWith("/vmlDrawing", StringComparison.OrdinalIgnoreCase) == true); + var target = relationship?.Attribute("Target")?.Value; + if (!string.IsNullOrEmpty(target)) + vmlPath = ResolveRelationshipTarget(GetPartDirectory(worksheetPath), target); + } + + if (vmlPath == null) return images; + var vmlEntry = archive.GetEntry(vmlPath); + var vmlRelsEntry = archive.GetEntry(GetRelationshipPartPath(vmlPath)); + if (vmlEntry == null || vmlRelsEntry == null) return images; + + var relationshipTargets = new Dictionary(StringComparer.OrdinalIgnoreCase); + using (var relsStream = vmlRelsEntry.Open()) + { + var relsDoc = XDocument.Load(relsStream); + foreach (var relationship in relsDoc.Descendants()) + { + var id = relationship.Attribute("Id")?.Value; + var target = relationship.Attribute("Target")?.Value; + if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(target)) + relationshipTargets[id] = ResolveRelationshipTarget(GetPartDirectory(vmlPath), target); + } + } + + using var vmlStream = vmlEntry.Open(); + var vmlDoc = XDocument.Load(vmlStream); + var v = XNamespace.Get("urn:schemas-microsoft-com:vml"); + var o = XNamespace.Get("urn:schemas-microsoft-com:office:office"); + var x = XNamespace.Get("urn:schemas-microsoft-com:office:excel"); + + foreach (var shape in vmlDoc.Descendants(v + "shape")) + { + var clientData = shape.Element(x + "ClientData"); + if (!string.Equals(clientData?.Attribute("ObjectType")?.Value, "Pict", StringComparison.OrdinalIgnoreCase)) + continue; + + var imageData = shape.Element(v + "imagedata"); + var relationshipId = imageData?.Attribute(o + "relid")?.Value; + var anchor = ParseVmlAnchor(clientData?.Element(x + "Anchor")?.Value); + if (string.IsNullOrEmpty(relationshipId) || anchor == null || + !relationshipTargets.TryGetValue(relationshipId, out var mediaPath)) + continue; + + var mediaEntry = archive.GetEntry(mediaPath); + if (mediaEntry == null) continue; + byte[] data; + using (var output = new MemoryStream()) + { + using var input = mediaEntry.Open(); + input.CopyTo(output); + data = output.ToArray(); + } + + var style = shape.Attribute("style")?.Value ?? ""; + var leftPt = ReadVmlStylePoints(style, "margin-left"); + var topPt = ReadVmlStylePoints(style, "margin-top"); + var widthPt = ReadVmlStylePoints(style, "width"); + var heightPt = ReadVmlStylePoints(style, "height"); + if (leftPt == null || topPt == null || widthPt is not > 0 || heightPt is not > 0) + continue; + + var widthEmu = (long)Math.Round(widthPt.Value * 12700f); + var heightEmu = (long)Math.Round(heightPt.Value * 12700f); + var extension = Path.GetExtension(mediaPath).TrimStart('.').ToLowerInvariant(); + if (extension is "emf" or "wmf") + { + var cropLeft = ReadVmlCrop(imageData?.Attribute("cropleft")?.Value); + var cropTop = ReadVmlCrop(imageData?.Attribute("croptop")?.Value); + var cropRight = ReadVmlCrop(imageData?.Attribute("cropright")?.Value); + var cropBottom = ReadVmlCrop(imageData?.Attribute("cropbottom")?.Value); + var visibleHeightFraction = Math.Max(0.01f, 1f - cropTop - cropBottom); + var rasterHeight = (int)Math.Ceiling(heightPt.Value * 300f / 72f / visibleHeightFraction); + var converted = DocxReader.TryConvertMetafileToPng(data, widthEmu, heightEmu, + cropLeft, cropTop, cropRight, cropBottom, rasterHeight); + if (converted == null) continue; + data = converted; + extension = "png"; + } + + images.Add(new ExcelEmbeddedImage( + AnchorRow: anchor.Value.FromRow, + AnchorCol: anchor.Value.FromCol, + SpanRows: Math.Max(1, anchor.Value.ToRow - anchor.Value.FromRow), + SpanCols: Math.Max(1, anchor.Value.ToCol - anchor.Value.FromCol), + Data: data, + Extension: extension, + WidthEmu: widthEmu, + HeightEmu: heightEmu, + AbsoluteLeftPt: leftPt, + AbsoluteTopPt: topPt, + VmlFromColOffset: anchor.Value.FromColOffset / 1024f, + VmlFromRowOffset: anchor.Value.FromRowOffset / 256f, + VmlToColOffset: anchor.Value.ToColOffset / 1024f, + VmlToRowOffset: anchor.Value.ToRowOffset / 256f)); + } + + return images; + } + + private static (int FromCol, int FromColOffset, int FromRow, int FromRowOffset, + int ToCol, int ToColOffset, int ToRow, int ToRowOffset)? ParseVmlAnchor(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var values = value.Split(',') + .Select(part => int.TryParse(part.Trim(), out var parsed) ? (int?)parsed : null) + .ToArray(); + if (values.Length != 8 || values.Any(item => item == null)) return null; + return (values[0]!.Value, values[1]!.Value, values[2]!.Value, values[3]!.Value, + values[4]!.Value, values[5]!.Value, values[6]!.Value, values[7]!.Value); + } + + private static float? ReadVmlStylePoints(string style, string property) + { + foreach (var declaration in style.Split(';')) + { + var parts = declaration.Split(':', 2); + if (parts.Length != 2 || !string.Equals(parts[0].Trim(), property, StringComparison.OrdinalIgnoreCase)) + continue; + var value = parts[1].Trim(); + if (!value.EndsWith("pt", StringComparison.OrdinalIgnoreCase)) return null; + if (float.TryParse(value[..^2], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var points)) + return points; + } + return null; + } + + private static float ReadVmlCrop(string? value) + { + if (string.IsNullOrWhiteSpace(value) || !value.EndsWith("f", StringComparison.OrdinalIgnoreCase)) + return 0f; + return float.TryParse(value[..^1], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var fixedPoint) + ? Compat.Clamp(fixedPoint / 65536f, 0f, 1f) + : 0f; + } + /// /// Reads decorative shape elements (rectangles, rounded rectangles) from a worksheet's drawing. /// @@ -4476,7 +4634,8 @@ internal sealed record ExcelCell( string? FontName = null, int Indent = 0, int BoldPrefixLength = 0, - string? Link = null + string? Link = null, + int TextRotation = 0 ); /// @@ -4513,7 +4672,13 @@ internal sealed record ExcelEmbeddedImage( long ToColOffEmu = 0, // sub-cell X offset within the "to" column (EMU) long ToRowOffEmu = 0, // sub-cell Y offset within the "to" row (EMU) long OffsetXEmu = 0, // additional X offset from anchor (for grouped images) - long OffsetYEmu = 0 // additional Y offset from anchor (for grouped images) + long OffsetYEmu = 0, // additional Y offset from anchor (for grouped images) + float? AbsoluteLeftPt = null, + float? AbsoluteTopPt = null, + float? VmlFromColOffset = null, + float? VmlFromRowOffset = null, + float? VmlToColOffset = null, + float? VmlToRowOffset = null ); /// @@ -4606,6 +4771,8 @@ internal sealed class ExcelSheet public int FitToHeight { get; } /// Whether to center content horizontally on the page. public bool HorizontalCentered { get; } + /// Whether to center content vertically on each printed page. + public bool VerticalCentered { get; } /// Row range to repeat at the top of each printed page (startRow, endRow) 0-based, or null. public (int StartRow, int EndRow)? PrintTitleRows { get; } /// Set of 0-based row indices where a manual page break occurs (break BEFORE this row). @@ -4660,6 +4827,7 @@ internal ExcelSheet(string name, List> rows, bool fitToPage = false, int fitToWidth = 1, int fitToHeight = 1, bool horizontalCentered = false, + bool verticalCentered = false, (int StartRow, int EndRow)? printTitleRows = null, HashSet? rowBreaks = null, string? oddFooter = null, @@ -4689,6 +4857,7 @@ internal ExcelSheet(string name, List> rows, FitToWidth = fitToWidth; FitToHeight = fitToHeight; HorizontalCentered = horizontalCentered; + VerticalCentered = verticalCentered; PrintTitleRows = printTitleRows; RowBreaks = rowBreaks ?? new HashSet(); OddFooter = oddFooter; diff --git a/src/MiniPdf/ExcelToPdfConverter.cs b/src/MiniPdf/ExcelToPdfConverter.cs index 03ef57cc..fd55ee23 100644 --- a/src/MiniPdf/ExcelToPdfConverter.cs +++ b/src/MiniPdf/ExcelToPdfConverter.cs @@ -493,6 +493,7 @@ private static void RenderSheet(PdfDocument doc, ExcelSheet sheet, ConversionOpt { // Skip only if there's truly nothing to render (no rows AND no images). if (sheet.Rows.Count == 0 && sheet.Images.Count == 0 && sheet.Charts.Count == 0) return; + var firstSheetPage = doc.Pages.Count; // Apply sheet page setup: paper size, orientation, margins, and print scale // Determine base page dimensions from paper size @@ -577,6 +578,7 @@ private static void RenderSheet(PdfDocument doc, ExcelSheet sheet, ConversionOpt fitToWidth: sheet.FitToWidth, fitToHeight: sheet.FitToHeight, horizontalCentered: sheet.HorizontalCentered, + verticalCentered: sheet.VerticalCentered, printArea: sheet.PrintArea, printTitleRows: sheet.PrintTitleRows, rowBreaks: sheet.RowBreaks, @@ -627,7 +629,7 @@ private static void RenderSheet(PdfDocument doc, ExcelSheet sheet, ConversionOpt marginLeftPt: sheet.MarginLeftPt, marginRightPt: sheet.MarginRightPt, marginTopPt: sheet.MarginTopPt, marginBottomPt: sheet.MarginBottomPt, fitToPage: sheet.FitToPage, fitToWidth: sheet.FitToWidth, fitToHeight: sheet.FitToHeight, - horizontalCentered: sheet.HorizontalCentered, printArea: sheet.PrintArea, + horizontalCentered: sheet.HorizontalCentered, verticalCentered: sheet.VerticalCentered, printArea: sheet.PrintArea, printTitleRows: sheet.PrintTitleRows, rowBreaks: sheet.RowBreaks, oddFooter: sheet.OddFooter, footerMarginPt: sheet.FooterMarginPt, maxDigitWidthPx: sheet.MaxDigitWidthPx); @@ -833,6 +835,7 @@ private static void RenderSheet(PdfDocument doc, ExcelSheet sheet, ConversionOpt fitToWidth: sheet.FitToWidth, fitToHeight: sheet.FitToHeight, horizontalCentered: sheet.HorizontalCentered, + verticalCentered: sheet.VerticalCentered, rowBreaks: trimmedRowBreaks, printTitleRows: trimmedPrintTitleRows, oddFooter: sheet.OddFooter, footerMarginPt: sheet.FooterMarginPt, @@ -990,6 +993,12 @@ private static void RenderSheet(PdfDocument doc, ExcelSheet sheet, ConversionOpt colWidths, 0f); } + if (sheet.VerticalCentered) + { + for (var pageIndex = firstSheetPage; pageIndex < doc.Pages.Count; pageIndex++) + doc.Pages[pageIndex].CenterContentVertically(options.MarginBottom, pageHeight - options.MarginTop); + } + // Note: trailing empty page logic disabled — it was previously intended to // match LibreOffice behavior but caused extra pages in multi-sheet workbooks. } @@ -1304,6 +1313,109 @@ static float TextBaselineY(string verticalAlignment, float topY, float height, f return topY - height + descent - ascentCompensation + lineHeight * (lineCount - 1); } + void RenderVerticalMergeContinuations(int startRow) + { + if (currentPage == null) return; + + foreach (var (mergeStartRow, startCol, endRow, endCol) in sheet.MergedCells) + { + if (mergeStartRow >= startRow || endRow < startRow) + continue; + + var columnIndex = Array.IndexOf(columns, startCol); + if (columnIndex < 0 || mergeStartRow >= sheet.Rows.Count || startCol >= sheet.Rows[mergeStartRow].Count) + continue; + + var originRow = sheet.Rows[mergeStartRow]; + var cell = originRow[startCol]; + var x = colXStarts[columnIndex]; + var width = colWidths[columnIndex]; + for (var i = columnIndex + 1; i < columns.Length && columns[i] <= endCol; i++) + width += colWidths[i] + columnPadding; + + var consumedHeight = 0f; + for (var rowIndex = mergeStartRow; rowIndex < startRow && rowIndex < sheet.Rows.Count; rowIndex++) + consumedHeight += ScaledRowHeightAt(rowIndex); + + var remainingHeight = 0f; + for (var rowIndex = startRow; rowIndex <= endRow && rowIndex < sheet.Rows.Count; rowIndex++) + remainingHeight += ScaledRowHeightAt(rowIndex); + if (remainingHeight <= 0f) continue; + + var virtualTop = currentY + consumedHeight; + var height = consumedHeight + remainingHeight; + var segmentBottom = currentY - remainingHeight; + + if (cell.FillColor is { } fillColor) + currentPage.AddRectangle(x, segmentBottom, width, remainingHeight, fillColor); + + 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); + } + + if (string.IsNullOrEmpty(cell.Text)) continue; + + var fontSize = cell.FontSize * printScaleFactor; + var availableWidth = Math.Max(1f, width - 2f * cellTextInset); + string[] lines; + if (cell.TextRotation == 255) + lines = cell.Text.Select(character => character.ToString()).ToArray(); + else if (cell.Text.Contains('\n')) + lines = cell.Text.Split('\n'); + else if (cell.WrapText) + lines = WrapCellText(cell.Text, availableWidth, fontSize, cell.FontName, cell.BoldPrefixLength); + else + lines = new[] { cell.Text }; + + var cellLineHeight = cell.TextRotation == 255 ? fontSize * 1.213f : lineHeight; + var textBlockHeight = fontSize + cellLineHeight * (lines.Length - 1); + var descent = options.FontSize * 0.31f; + var ascentCompensation = fontSize > options.FontSize + ? (fontSize - options.FontSize) * 0.1f + : 0f; + var textY = TextBaselineY(cell.VerticalAlignment, virtualTop, height, textBlockHeight, + fontSize, descent, ascentCompensation, lines.Length, lineHeight); + var isBold = ShouldUsePdfBold(cell.Bold, fontSize, cell.FontName); + foreach (var line in lines) + { + var intersectsSegment = textY + fontSize >= segmentBottom && textY - fontSize <= currentY; + if (!string.IsNullOrEmpty(line) && intersectsSegment) + { + var textX = x + cellTextInset; + if (cell.Alignment == "center") + { + var textWidth = (float)MeasureHelveticaWidth(line, fontSize, bold: isBold); + textX = x + (width - textWidth) / 2f; + } + else if (cell.Alignment == "right") + { + var textWidth = (float)MeasureHelveticaWidth(line, fontSize, bold: isBold); + textX = x + width - cellTextInset - textWidth; + } + + AddExcelText(currentPage, line, textX, textY, fontSize, cell.Color, + maxWidth: availableWidth, bold: isBold, + underline: cell.Underline, strikethrough: cell.Strikethrough, + preferredFontName: cell.FontName); + } + textY -= cellLineHeight; + } + } + } + // Helper: render print title rows at the current page position. // Returns the total height consumed by the title rows. float RenderPrintTitleRows() @@ -1336,7 +1448,14 @@ float RenderPrintTitleRows() var cellFs = titleRow[col].FontSize * printScaleFactor; if (fitToPageScale < 1f) cellFs *= fitToPageScale; - if (cellText.Contains('\n')) + if (titleRow[col].TextRotation == 255) + { + titleCellLines[i] = cellText.Select(character => character.ToString()).ToArray(); + titleClipWidths[i] = Math.Max(1f, colWidths[i] - 2f * cellTextInset); + titleMaxLines = Math.Max(titleMaxLines, titleCellLines[i].Length); + continue; + } + else if (cellText.Contains('\n')) { titleCellLines[i] = cellText.Split('\n'); titleClipWidths[i] = Math.Max(1f, colWidths[i] - 2f * cellTextInset); @@ -1490,7 +1609,8 @@ float RenderPrintTitleRows() // Text var descent = options.FontSize * 0.31f; - var textBlock = cellFs + lineHeight * (titleCellLines[i].Length - 1); + var cellLineHeight = cell?.TextRotation == 255 ? cellFs * 1.213f : lineHeight; + var textBlock = cellFs + cellLineHeight * (titleCellLines[i].Length - 1); var cellY = TextBaselineY(vertAlign, currentY, titleCellHeight, textBlock, cellFs, descent, 0f, titleCellLines[i].Length, lineHeight); @@ -1539,7 +1659,7 @@ float RenderPrintTitleRows() strikethrough: cell?.Strikethrough ?? false, preferredFontName: cell?.FontName); } - cellY -= lineHeight; + cellY -= cellLineHeight; } x += colWidths[i] + columnPadding; @@ -1632,6 +1752,7 @@ float RenderPrintTitleRows() // Render print title rows on the new page if (printTitleStart >= 0 && (excelRowIndex < printTitleStart || excelRowIndex > printTitleEnd)) RenderPrintTitleRows(); + RenderVerticalMergeContinuations(excelRowIndex); } // Determine this row's effective height @@ -1662,6 +1783,7 @@ float RenderPrintTitleRows() { var emptyH = hasExplicitHeight ? explicitRowHeight : lineHeight; currentY -= emptyH; + currentPage!.IncludeWorksheetVerticalRange(rowTopY[excelRowIndex], currentY); excelRowIndex++; continue; } @@ -1695,7 +1817,11 @@ float RenderPrintTitleRows() // Handle explicit newlines in cell text (e.g., Alt+Enter in Excel). // Otherwise write full text as a single line. - if (cellText.Contains('\n')) + if (row[col].TextRotation == 255) + { + cellLines[i] = cellText.Select(character => character.ToString()).ToArray(); + } + else if (cellText.Contains('\n')) { cellLines[i] = cellText.Split('\n'); } @@ -1881,6 +2007,7 @@ float RenderPrintTitleRows() // Render print title rows on the new page (skip if current row is within the title range) if (printTitleStart >= 0 && (excelRowIndex < printTitleStart || excelRowIndex > printTitleEnd)) RenderPrintTitleRows(); + RenderVerticalMergeContinuations(excelRowIndex); // Update the row's top position on the new page rowTopY[excelRowIndex] = currentY; rowPage[excelRowIndex] = currentPage; @@ -1964,7 +2091,9 @@ float RenderPrintTitleRows() } linesRendered += linesToRender; + var batchTopY = currentY; currentY -= linesToRender * lineHeight; + currentPage!.IncludeWorksheetVerticalRange(batchTopY, currentY); if (linesRendered < maxLinesInRow) { @@ -2036,7 +2165,8 @@ float RenderPrintTitleRows() // Only the merge origin cell renders the fill covering the full merged area. var isInsideMerge = mergeInterior.Contains((excelRowIndex, col)); var cellHeight = isInsideMerge ? rowHeight : MergedHeightForCell(excelRowIndex, col, rowHeight); - var textBlock = cellFontSize + lineHeight * (lines.Length - 1); + var cellLineHeight = cell?.TextRotation == 255 ? cellFontSize * 1.213f : lineHeight; + var textBlock = cellFontSize + cellLineHeight * (lines.Length - 1); cellY = TextBaselineY(verticalAlignment, currentY, cellHeight, textBlock, cellFontSize, descent, ascentCompensation, lines.Length, lineHeight); @@ -2111,10 +2241,25 @@ float RenderPrintTitleRows() cellWidth += colWidths[mc] + columnPadding; } + (float X, float Y, float Width, float Height)? mergedPageClip = null; + if (mergeEndRow.TryGetValue((excelRowIndex, col), out var textEndRow) && textEndRow > excelRowIndex) + { + var visibleHeight = 0f; + for (var mergedRow = excelRowIndex; mergedRow <= textEndRow && mergedRow < sheet.Rows.Count; mergedRow++) + { + var mergedRowHeight = ScaledRowHeightAt(mergedRow); + if (visibleHeight > 0f && currentY - visibleHeight - mergedRowHeight < options.MarginBottom) + break; + visibleHeight += mergedRowHeight; + } + mergedPageClip = (x, currentY - visibleHeight, cellWidth, visibleHeight); + } + // Render accounting prefix (e.g., " $") left-aligned for accounting format cells. if (cell?.AccountingPrefix != null && lines.Length > 0 && !string.IsNullOrEmpty(lines[0])) { currentPage!.AddText(cell.AccountingPrefix, x + cellTextInset, cellY, cellFontSize, color, + clipRect: mergedPageClip, maxWidth: cellClipWidth[i], bold: ShouldUsePdfBold(cell.Bold, cellFontSize, cell.FontName), underline: false, @@ -2183,6 +2328,7 @@ float RenderPrintTitleRows() var boldWidth = (float)MeasureFontWidth(boldPart, cellFontSize, bold: true, cell?.FontName); AddExcelText(currentPage!, boldPart, textX, cellY, cellFontSize, color, + clipRect: mergedPageClip, maxWidth: boldWidth, bold: true, underline: cell?.Underline ?? false, @@ -2194,6 +2340,7 @@ float RenderPrintTitleRows() var normalX = textX + boldWidth + spaceGap; var normalMax = lineMaxWidth > 0 ? lineMaxWidth - boldWidth - spaceGap : 0f; AddExcelText(currentPage!, normalPart, normalX, cellY, cellFontSize, color, + clipRect: mergedPageClip, maxWidth: normalMax > 0 ? normalMax : 0f, bold: false, underline: cell?.Underline ?? false, @@ -2205,6 +2352,7 @@ float RenderPrintTitleRows() else { AddExcelText(currentPage!, lines[lineIdx], textX, cellY, cellFontSize, color, + clipRect: mergedPageClip, maxWidth: lineMaxWidth, bold: isBold, underline: cell?.Underline ?? false, @@ -2217,13 +2365,14 @@ float RenderPrintTitleRows() // Empty line still consumes bold prefix chars (newline splits) boldPrefixRemaining = Math.Max(0, boldPrefixRemaining - 1); } - cellY -= lineHeight; + cellY -= cellLineHeight; } x += colWidths[i] + columnPadding; } currentY -= rowHeight; + currentPage!.IncludeWorksheetVerticalRange(rowTopY[excelRowIndex], currentY); } // Accumulate virtual overflow height but don't emit pages yet. @@ -2369,6 +2518,9 @@ float RenderPrintTitleRows() { imgPage = currentPage!; } + var isLegacyVmlImage = img.VmlFromRowOffset != null; + if (isLegacyVmlImage) + imgPage.MarkLegacyVmlImage(); // Calculate X: find position of anchor column within group var colGroupIdx = Array.IndexOf(columns, img.AnchorCol); @@ -2379,14 +2531,27 @@ float RenderPrintTitleRows() } var imgX = colXStarts[colGroupIdx]; + if (img.AbsoluteLeftPt is { } absoluteLeftPt) + imgX = options.MarginLeft + absoluteLeftPt * drawingScale; + // Apply sub-cell column offset (fromColOff) — shift image right within anchor column - var fromColOffPt = Math.Min(img.FromColOffEmu * drawingEmuToPt, colWidths[colGroupIdx]); + var fromColOffPt = img.VmlFromColOffset is { } vmlFromColOffset + ? colWidths[colGroupIdx] * vmlFromColOffset + : Math.Min(img.FromColOffEmu * drawingEmuToPt, colWidths[colGroupIdx]); imgX += fromColOffPt; // Apply sub-cell row offset (fromRowOff) — shift image down within anchor row - var fromRowOffPt = img.FromRowOffEmu * drawingEmuToPt; + var fromRowOffPt = img.VmlFromRowOffset is { } vmlFromRowOffset + ? ScaledRowHeightAt(img.AnchorRow) * vmlFromRowOffset + : img.FromRowOffEmu * drawingEmuToPt; imgTopY -= fromRowOffPt; + if (img.VmlFromRowOffset == null && img.AbsoluteTopPt is { } absoluteTopPt) + imgTopY = pageHeight - options.MarginTop - absoluteTopPt * drawingScale; + else if (isLegacyVmlImage && sheet.VerticalCentered) + imgTopY -= imgPage.GetWorksheetVerticalCenterOffset(options.MarginBottom, + pageHeight - options.MarginTop); + // Apply group-relative offset (for images inside grouped shapes) imgX += img.OffsetXEmu * drawingEmuToPt; imgTopY -= img.OffsetYEmu * drawingEmuToPt; @@ -2415,6 +2580,10 @@ float RenderPrintTitleRows() var toColOffPt = Math.Min(img.ToColOffEmu * drawingEmuToPt, colWidths[toColGroupIdx]); imgRenderWidth += toColOffPt + columnPadding; } + else if (toColGroupIdx < columns.Length && img.VmlToColOffset is { } vmlToColOffset) + { + imgRenderWidth += colWidths[toColGroupIdx] * vmlToColOffset + columnPadding; + } imgRenderWidth = Math.Max(imgRenderWidth, 1f); // Height: sum actual row heights for the spanned rows @@ -2427,7 +2596,9 @@ float RenderPrintTitleRows() } // Adjust for sub-cell row offsets imgRenderHeight -= fromRowOffPt; - imgRenderHeight += img.ToRowOffEmu * drawingEmuToPt; + imgRenderHeight += img.VmlToRowOffset is { } vmlToRowOffset + ? ScaledRowHeightAt(img.AnchorRow + img.SpanRows) * vmlToRowOffset + : img.ToRowOffEmu * drawingEmuToPt; imgRenderHeight = Math.Max(imgRenderHeight, 1f); } @@ -4256,7 +4427,7 @@ private static float[] CalculateNaturalColumnWidths(ExcelSheet sheet, int maxCol // columns using the default/fallback width. // Do not cap explicit widths — LibreOffice honours the author's // column widths as-is, so the column-grouping boundary matches. - var floor = hasExplicitWidth ? 0f : minColWidth; + var floor = hasExplicitWidth || sheet.DefaultColumnWidth > 0f ? 0f : minColWidth; widths[i] = hasExplicitWidth ? Math.Max(excelPts, 0f) : Compat.Clamp(excelPts, floor, maxColWidth); diff --git a/src/MiniPdf/PdfPage.cs b/src/MiniPdf/PdfPage.cs index 84588b45..6b9b26b8 100644 --- a/src/MiniPdf/PdfPage.cs +++ b/src/MiniPdf/PdfPage.cs @@ -94,6 +94,9 @@ internal sealed class PdfPage private readonly List _overlayRects = []; private readonly List _overlayLines = []; private readonly List _overlayTexts = []; + private float? _worksheetContentTop; + private float? _worksheetContentBottom; + private bool _hasLegacyVmlImage; /// /// Page width in points. @@ -270,6 +273,82 @@ internal PdfPage AddOverlayText(string text, float x, float y, float fontSize = return this; } + internal void CenterContentVertically(float printableBottom, float printableTop) + { + var bounds = new List<(float Bottom, float Top)>(); + bounds.AddRange(_textBlocks.Where(block => !block.Hidden) + .Select(block => (block.Y - block.FontSize * 0.3f, block.Y + block.FontSize))); + bounds.AddRange(_imageBlocks.Select(block => (block.Y, block.Y + block.RenderHeight))); + bounds.AddRange(_rectBlocks.Select(block => (block.Y, block.Y + block.Height))); + bounds.AddRange(_ellipseBlocks.Select(block => (block.Y, block.Y + block.Height))); + bounds.AddRange(_polygonBlocks.Select(block => + (block.Points.Min(point => point.Y), block.Points.Max(point => point.Y)))); + bounds.AddRange(_lineBlocks.Select(block => + (Math.Min(block.Y1, block.Y2), Math.Max(block.Y1, block.Y2)))); + if (bounds.Count == 0) + return; + + var contentBottom = bounds.Min(bound => bound.Bottom); + var contentTop = bounds.Max(bound => bound.Top); + var printableHeight = printableTop - printableBottom; + var contentHeight = contentTop - contentBottom; + if (contentHeight >= printableHeight && !_hasLegacyVmlImage + && _worksheetContentTop is { } worksheetTop + && _worksheetContentBottom is { } worksheetBottom) + { + contentTop = worksheetTop; + contentBottom = worksheetBottom; + contentHeight = contentTop - contentBottom; + } + if (contentHeight >= printableHeight) + return; + + var offset = (printableBottom + printableTop - contentBottom - contentTop) / 2f; + if (Math.Abs(offset) < 0.01f) + return; + + for (var i = 0; i < _textBlocks.Count; i++) + _textBlocks[i] = _textBlocks[i].TranslateY(offset); + for (var i = 0; i < _imageBlocks.Count; i++) + _imageBlocks[i] = _imageBlocks[i] with { Y = _imageBlocks[i].Y + offset }; + for (var i = 0; i < _rectBlocks.Count; i++) + _rectBlocks[i] = _rectBlocks[i] with { Y = _rectBlocks[i].Y + offset }; + for (var i = 0; i < _ellipseBlocks.Count; i++) + _ellipseBlocks[i] = _ellipseBlocks[i] with { Y = _ellipseBlocks[i].Y + offset }; + for (var i = 0; i < _polygonBlocks.Count; i++) + _polygonBlocks[i] = _polygonBlocks[i] with + { + Points = _polygonBlocks[i].Points.Select(point => point with { Y = point.Y + offset }).ToList(), + Subpaths = _polygonBlocks[i].Subpaths?.Select(path => + path.Select(point => point with { Y = point.Y + offset }).ToList()).ToList() + }; + for (var i = 0; i < _lineBlocks.Count; i++) + _lineBlocks[i] = _lineBlocks[i] with + { + Y1 = _lineBlocks[i].Y1 + offset, + Y2 = _lineBlocks[i].Y2 + offset + }; + } + + internal void IncludeWorksheetVerticalRange(float top, float bottom) + { + _worksheetContentTop = _worksheetContentTop is { } existingTop + ? Math.Max(existingTop, top) + : top; + _worksheetContentBottom = _worksheetContentBottom is { } existingBottom + ? Math.Min(existingBottom, bottom) + : bottom; + } + + internal float GetWorksheetVerticalCenterOffset(float printableBottom, float printableTop) + { + if (_worksheetContentTop is not { } contentTop || _worksheetContentBottom is not { } contentBottom) + return 0f; + return Math.Max(0f, (printableTop - printableBottom - (contentTop - contentBottom)) / 2f); + } + + internal void MarkLegacyVmlImage() => _hasLegacyVmlImage = true; + /// /// Adds text that automatically wraps within the specified region. /// Text flows from top to bottom, left to right within the given bounds. diff --git a/src/MiniPdf/PdfTextBlock.cs b/src/MiniPdf/PdfTextBlock.cs index e40d5247..f7ba5aa4 100644 --- a/src/MiniPdf/PdfTextBlock.cs +++ b/src/MiniPdf/PdfTextBlock.cs @@ -115,4 +115,13 @@ internal PdfTextBlock(string text, float x, float y, float fontSize, PdfColor? c UnderlineWidth = underlineWidth; Hidden = hidden; } + + internal PdfTextBlock TranslateY(float offset) + { + var clipRect = ClipRect is { } clip + ? (clip.X, clip.Y + offset, clip.Width, clip.Height) + : ((float, float, float, float)?)null; + return new PdfTextBlock(Text, X, Y + offset, FontSize, Color, clipRect, MaxWidth, Bold, Italic, + Underline, CharSpacing, WordSpacing, PreferredFontName, UnderlineWidth, Strikethrough, Hidden); + } } diff --git a/src/MiniPdf/PdfWriter.cs b/src/MiniPdf/PdfWriter.cs index 98f5ffee..2f3291a0 100644 --- a/src/MiniPdf/PdfWriter.cs +++ b/src/MiniPdf/PdfWriter.cs @@ -2321,6 +2321,10 @@ private static string NormalizeFontName(string? name) if (name.Contains("宋体", StringComparison.Ordinal) || name.Contains("simsun", StringComparison.OrdinalIgnoreCase)) return "simsun"; + if (name.Contains("华文楷体", StringComparison.Ordinal) + || name.Contains("華文楷體", StringComparison.Ordinal) + || name.Contains("stkaiti", StringComparison.OrdinalIgnoreCase)) + return "stkaiti"; if (name.Contains("楷体", StringComparison.Ordinal) || name.Contains("楷體", StringComparison.Ordinal) || name.Contains("kaiti", StringComparison.OrdinalIgnoreCase) diff --git a/tests/Issue_Files/reference_xlsx/Issue202609031340.pdf b/tests/Issue_Files/reference_xlsx/Issue202609031340.pdf new file mode 100644 index 00000000..648ca2b4 Binary files /dev/null and b/tests/Issue_Files/reference_xlsx/Issue202609031340.pdf differ diff --git a/tests/Issue_Files/reports_xlsx/comparison_manifest.json b/tests/Issue_Files/reports_xlsx/comparison_manifest.json index 79764e72..931d8bec 100644 --- a/tests/Issue_Files/reports_xlsx/comparison_manifest.json +++ b/tests/Issue_Files/reports_xlsx/comparison_manifest.json @@ -1,114 +1,119 @@ -{ - "cases": [ - { - "name": "Academic Achievement Summary Table", - "case_id": "Academic Achievement Summary Table", - "format": "xlsx" - }, - { - "name": "AcademicAchievement_temp", - "case_id": "AcademicAchievement_temp", - "format": "xlsx" - }, - { - "name": "Business expense budget1", - "case_id": "Business expense budget1", - "format": "xlsx" - }, - { - "name": "Business expenses budget2", - "case_id": "Business expenses budget2", - "format": "xlsx" - }, - { - "name": "Business plan checklist with SWOT analysis1", - "case_id": "Business plan checklist with SWOT analysis1", - "format": "xlsx" - }, - { - "name": "Event budget1", - "case_id": "Event budget1", - "format": "xlsx" - }, - { - "name": "Expense report basic1", - "case_id": "Expense report basic1", - "format": "xlsx" - }, - { - "name": "Grocery list1", - "case_id": "Grocery list1", - "format": "xlsx" - }, - { - "name": "payroll-calculator_f", - "case_id": "payroll-calculator_f", - "format": "xlsx" - }, - { - "name": "PO_anonymized", - "case_id": "PO_anonymized", - "format": "xlsx" - }, - { - "name": "Simple invoice1", - "case_id": "Simple invoice1", - "format": "xlsx" - }, - { - "name": "Small business cash flow forecast1", - "case_id": "Small business cash flow forecast1", - "format": "xlsx" - }, - { - "name": "Wedding_timeline_planner1_copy", - "case_id": "Wedding_timeline_planner1_copy", - "format": "xlsx" - }, - { - "name": "Weekly schedule planner1", - "case_id": "Weekly schedule planner1", - "format": "xlsx" - }, - { - "name": "XlsxIssue75", - "case_id": "XlsxIssue75", - "format": "xlsx" - }, - { - "name": "XlsxIssue77_MergedCellAlignment", - "case_id": "XlsxIssue77_MergedCellAlignment", - "format": "xlsx" - }, - { - "name": "XlsxIssue77_Template1", - "case_id": "XlsxIssue77_Template1", - "format": "xlsx" - }, - { - "name": "XlsxIssue77_Template2_Workaround", - "case_id": "XlsxIssue77_Template2_Workaround", - "format": "xlsx" - }, - { - "name": "XlsxIssue81_LayoutOptions", - "case_id": "XlsxIssue81_LayoutOptions", - "format": "xlsx" - }, - { - "name": "XlsxIssue82_5mb", - "case_id": "XlsxIssue82_5mb", - "format": "xlsx" - }, - { - "name": "XlsxIssue82_SampleTestData5mb", - "case_id": "XlsxIssue82_SampleTestData5mb", - "format": "xlsx" - }, - { - "name": "XlsxIssue82_WideTable", - "case_id": "XlsxIssue82_WideTable", - "format": "xlsx" - } - ] +{ + "cases": [ + { + "name": "Academic Achievement Summary Table", + "case_id": "Academic Achievement Summary Table", + "format": "xlsx" + }, + { + "name": "AcademicAchievement_temp", + "case_id": "AcademicAchievement_temp", + "format": "xlsx" + }, + { + "name": "Business expense budget1", + "case_id": "Business expense budget1", + "format": "xlsx" + }, + { + "name": "Business expenses budget2", + "case_id": "Business expenses budget2", + "format": "xlsx" + }, + { + "name": "Business plan checklist with SWOT analysis1", + "case_id": "Business plan checklist with SWOT analysis1", + "format": "xlsx" + }, + { + "name": "Event budget1", + "case_id": "Event budget1", + "format": "xlsx" + }, + { + "name": "Expense report basic1", + "case_id": "Expense report basic1", + "format": "xlsx" + }, + { + "name": "Grocery list1", + "case_id": "Grocery list1", + "format": "xlsx" + }, + { + "name": "Issue202609031340", + "case_id": "Issue202609031340", + "format": "xlsx" + }, + { + "name": "payroll-calculator_f", + "case_id": "payroll-calculator_f", + "format": "xlsx" + }, + { + "name": "PO_anonymized", + "case_id": "PO_anonymized", + "format": "xlsx" + }, + { + "name": "Simple invoice1", + "case_id": "Simple invoice1", + "format": "xlsx" + }, + { + "name": "Small business cash flow forecast1", + "case_id": "Small business cash flow forecast1", + "format": "xlsx" + }, + { + "name": "Wedding_timeline_planner1_copy", + "case_id": "Wedding_timeline_planner1_copy", + "format": "xlsx" + }, + { + "name": "Weekly schedule planner1", + "case_id": "Weekly schedule planner1", + "format": "xlsx" + }, + { + "name": "XlsxIssue75", + "case_id": "XlsxIssue75", + "format": "xlsx" + }, + { + "name": "XlsxIssue77_MergedCellAlignment", + "case_id": "XlsxIssue77_MergedCellAlignment", + "format": "xlsx" + }, + { + "name": "XlsxIssue77_Template1", + "case_id": "XlsxIssue77_Template1", + "format": "xlsx" + }, + { + "name": "XlsxIssue77_Template2_Workaround", + "case_id": "XlsxIssue77_Template2_Workaround", + "format": "xlsx" + }, + { + "name": "XlsxIssue81_LayoutOptions", + "case_id": "XlsxIssue81_LayoutOptions", + "format": "xlsx" + }, + { + "name": "XlsxIssue82_5mb", + "case_id": "XlsxIssue82_5mb", + "format": "xlsx" + }, + { + "name": "XlsxIssue82_SampleTestData5mb", + "case_id": "XlsxIssue82_SampleTestData5mb", + "format": "xlsx" + }, + { + "name": "XlsxIssue82_WideTable", + "case_id": "XlsxIssue82_WideTable", + "format": "xlsx" + } + ] } diff --git a/tests/Issue_Files/reports_xlsx/comparison_report.json b/tests/Issue_Files/reports_xlsx/comparison_report.json index 6e6262e5..c6b415ea 100644 --- a/tests/Issue_Files/reports_xlsx/comparison_report.json +++ b/tests/Issue_Files/reports_xlsx/comparison_report.json @@ -849,5 +849,128 @@ } ], "overall_score": 0.9459 + }, + { + "name": "Issue202609031340", + "minipdf_exists": true, + "reference_exists": true, + "report_scope": "issue-xlsx", + "case_id": "Issue202609031340", + "format": "xlsx", + "minipdf_size": 470835, + "reference_size": 346206, + "pdf_valid": true, + "minipdf_pages": 4, + "reference_pages": 4, + "text_similarity": 0.8828, + "flat_text_similarity": 0.8808, + "word_text_similarity": 0.7896, + "text_diff": "--- minipdf/Issue202609031340.pdf\n+++ reference/Issue202609031340.pdf\n@@ -1,37 +1,34 @@\n 表单编号 XXX-XXX-051-01\n\n-文件编号 QA-SIPI-XXXX-01-1-1\n\n-XXXXXX有限公司\n\n+XXXXXX有限公司 文件编号QA-SIPI-XXXX-01-1-1\n\n 发行日期 2025-04-11\n\n 文件版本 03 修订日期 2025-11-25\n\n 产品穴号 详见注意事项 产品单重 20.45±1.05 g\n\n+注塑制程检验标准书\n\n ¢1.18*10MM(320度加强\n\n-注塑制程检验标准书\n\n 附件编号 / 销钉规格\n\n 型)\n\n 产品型号 XXXX 部件名称 盖塑胶件 抽样计划 ANSI/ASQ Z1.4Ⅱ 巡检频率 2H/次\n\n-MO25XXXX0100/MO25\n\n+MO25XXXX0100/MO\n\n 模具编号 部件材质 PETG 允收水准 CR: 0 MA:1.0 MI: 2.0\n\n-XXXX0110\n\n+25XXXX0110\n\n 检验条件 检验光源角度:45°-90°,检验距离30cm,光照900~1200lux\n\n 参考 图纸文件编号:XXXX系列 内部图纸 2025.4.10\n\n 项目 检验工具 巡检内容\n\n 图 部件图纸编号:HKXXXX-01-RB\n\n-目视、图纸 1、首件样品整模结构符合图纸设计、同PPS结构一致;\n\n-结\n\n-构\n\n-、PPS 2、制程巡检整模产品结构需同首件结构一致。\n\n-目视、限度 1、首件样品颜色需无限接近限度样中的标准样;\n\n-颜\n\n-色\n\n-样 2、制程巡检产品颜色需同首件颜色一致。\n\n-目视、限度 1、注塑成型外观缺陷大小不可超出限度样板;\n\n-外\n\n-观\n\n-样 2、其它外观缺陷无限度样的按常规通用标准判定。\n\n+结 目视、图 1、首件样品整模结构符合图纸设计、同PPS结构一致;\n\n+构 纸、PPS 2、制程巡检整模产品结构需同首件结构一致。\n\n+颜 目视、限 1、首件样品颜色需无限接近限度样中的标准样;\n\n+色 度样 2、制程巡检产品颜色需同首件颜色一致。\n\n+外 目视、限 1、注塑成型外观缺陷大小不可超出限度样板;\n\n+观 度样 2、其它外观缺陷无限度样的按常规通用标准判定。\n\n 包装 目视 参照注塑作业指导书(SOP)\n\n 仪器 项目 管控标准(mm) 测量位置 频率\n\n-卡尺 A 68.16 +0.05/-0.17 外形最长距离 1次/班\n\n-卡尺 B 62.03 +0.05/-0.20 外形最宽距离 1次/班\n\n+1次/\n\n+卡尺 A 68.16 +0.05/-0.17 外形最长距离\n\n+班\n\n+1次/\n\n+卡尺 B 62.03 +0.05/-0.20 外形最宽距离\n\n+班\n\n 卡尺 C 27.9 +0.03/-0.05 顶部装饰片槽长度 首件\n\n 卡尺 D 18.87 +0.00/-0.10 后钮侧面装饰片槽长度 首件\n\n 卡尺 E 6.70+0.00/-0.05 后钮侧面大卡槽宽度 首件\n\n@@ -40,22 +37,44 @@\n 寸 针规 G 1.20±0.02 针孔内径 6H\n\n 投影仪 H 58.69+0.02/-0.12 针孔中心至外形最宽距离 6H\n\n 卡尺 I 8.35+0.10/-0.05 口部平面至顶部高度 首件\n\n-后钮针孔中分线中心点至前扣弧线中\n\n+后钮针孔中分线中心点至前扣弧\n\n 投影仪 J 53.93+0.04/-0.03 6H\n\n-心点距离\n\n-卡尺 K 18.61±0.05 后钮针孔左右间距 1次/班\n\n-高度规 L*2 4.65+0.00/-0.10 后钮针孔中心至口部平面间距 1次/班\n\n+线中心点距离\n\n+1次/\n\n+卡尺 K 18.61±0.05 后钮针孔左右间距\n\n+班\n\n+1次/\n\n+高度规 L*2 4.65+0.00/-0.10 后钮针孔中心至口部平面间距\n\n+班\n\n+产品后钮装饰片槽缺\n\n+口竖边同卡尺贴紧\n\n+68.16 mm 62.03 mm\n\n+产品口部平面同\n\n+产品口部平面同\n\n+卡尺上平面平行\n\n+卡尺上平面平行\n\n+产品后钮装饰片槽缺\n\n+口竖边同卡尺贴紧\n\n+0.00 ~ 300mm卡尺\n\n 参考资料/项目 代码 取样数量 频率 代码 取样数量 频率 注意事项及品质履历\n\n-尺 1.MO25XXXX0100:1#~4#/MO25XXXX0110:5#~12#\n\n+1.MO25XXXX0100:1#~4#/MO25XXXX0110:5#~12#\n\n+尺\n\n 寸\n\n 组\n\n-组装制程检验标准书\n\n-1# 一模 1次/班 2# 一模 1次/班\n\n+组装制程检验标\n\n+1次/ 1次/\n\n+准书 1# 一模 2# 一模\n\n+班 班\n\n 合\n\n-XXXX-00组装成品 功\n\n-3# 一模 1次/班 5# 一模 1次/班\n\n+XXXX-\n\n+功\n\n+1次/ 1次/\n\n+3# 一模 5# 一模\n\n+00组装成品 班 班\n\n 能\n\n-6# 一模 1次/班\n\n+1次/\n\n+6# 一模\n\n+班\n\n 日期 版本 工程担当 品质担当 修 订 内 容\n\n 2025/4/11 00 AAAA AAA 初版发行\n\n 修\n\n@@ -63,23 +82,20 @@\n 修\n\n A:68.16 +0.05/-0.13改为68.16 +0.05/-0.17;B:62.03 +0.05/-0.10改为62.03 +0.05/-0.20;D:18.87\n\n 订\n\n++0.00/-0.05改为18.87\n\n 2025/5/12 01 BBBB BBB\n\n-记 +0.00/-0.05改为18.87 +0.00/-0.10;I:8.35+0.00/-0.16改为8.35+0.10/-0.05;J:53.93+0.00/-0.10改为53.\n\n-录\n\n-93+0.05/-0.10;L*2:4.65±0.05改成4.65+0.00/-0.10;更新检验频率\n\n+记\n\n++0.00/-0.10;I:8.35+0.00/-0.16改为8.35+0.10/-0.05;J:53.93+0.00/-0.10改为53.93+0.05/-0.10;\n\n+录 L*2:4.65±0.05改成4.65+0.00/-0.10;更新检验频率\n\n 2025/8/6 02 CCCC CCCC 新增模具编号:MO25XXXX0110\n\n 2025/11/25 03 DDDD DDDD J:53.93+0.05/-0.10改为53.93+0.04/-0.03\n\n+研 制\n\n 制 品\n\n-研 制\n\n+审 发 审 造 审 审 制\n\n 造 保\n\n-发 造\n\n-部 部 制\n\n-工 工\n\n-作\n\n+核 工 核 工 核 核 作\n\n+部 部\n\n 程 程\n\n-审 审\n\n-核 核\n\n-审 审\n\n ---PAGE---\n\n 文件编号 QA-SIPI-XXXX-01-1-1 表单编号 QA-FM-051-01\n\n XXXXXX有限公司\n\n@@ -90,8 +106,8 @@\n 产品品质履历(SIP附件)\n\n 版本 日期 问题描述 图片\n\n 00 7/30/2025 口部有麻点印NG,图1\n\n+图 1\n\n ---PAGE---\n\n-制\n\n-审 刘伟鹏\n\n-作\n\n-核\n+审 制\n\n+刘伟鹏\n\n+核 作", + "visual_scores": [ + 0.8944, + 0.9845, + 0.9351, + 0.9954 + ], + "visual_avg": 0.9524, + "diff_images": [ + { + "page": 1, + "minipdf_img": "Issue202609031340_p1_minipdf.png", + "reference_img": "Issue202609031340_p1_reference.png", + "heatmap_img": "Issue202609031340_p1_heatmap.png", + "heatmap_metrics": { + "size_px": [ + 1241, + 1754 + ], + "difference_bbox_px": [ + 43, + 29, + 1195, + 1754 + ], + "changed_pixels": 444245, + "changed_fraction": 0.20409, + "mean_abs_rgb": 27.6568, + "rmse_rgb": 71.4265, + "threshold": 12, + "gain": 5.0 + } + }, + { + "page": 2, + "minipdf_img": "Issue202609031340_p2_minipdf.png", + "reference_img": "Issue202609031340_p2_reference.png", + "heatmap_img": "Issue202609031340_p2_heatmap.png", + "heatmap_metrics": { + "size_px": [ + 1241, + 1754 + ], + "difference_bbox_px": [ + 43, + 725, + 1217, + 1014 + ], + "changed_pixels": 63529, + "changed_fraction": 0.029186, + "mean_abs_rgb": 4.3159, + "rmse_rgb": 28.1847, + "threshold": 12, + "gain": 5.0 + } + }, + { + "page": 3, + "minipdf_img": "Issue202609031340_p3_minipdf.png", + "reference_img": "Issue202609031340_p3_reference.png", + "heatmap_img": "Issue202609031340_p3_heatmap.png", + "heatmap_metrics": { + "size_px": [ + 1241, + 1754 + ], + "difference_bbox_px": [ + 42, + 40, + 1196, + 1754 + ], + "changed_pixels": 176239, + "changed_fraction": 0.080966, + "mean_abs_rgb": 10.4512, + "rmse_rgb": 43.9019, + "threshold": 12, + "gain": 5.0 + } + }, + { + "page": 4, + "minipdf_img": "Issue202609031340_p4_minipdf.png", + "reference_img": "Issue202609031340_p4_reference.png", + "heatmap_img": "Issue202609031340_p4_heatmap.png", + "heatmap_metrics": { + "size_px": [ + 1241, + 1754 + ], + "difference_bbox_px": [ + 42, + 743, + 1196, + 1009 + ], + "changed_pixels": 18149, + "changed_fraction": 0.008338, + "mean_abs_rgb": 1.166, + "rmse_rgb": 14.6026, + "threshold": 12, + "gain": 5.0 + } + } + ], + "overall_score": 0.9341 } ] \ No newline at end of file diff --git a/tests/Issue_Files/reports_xlsx/comparison_report.md b/tests/Issue_Files/reports_xlsx/comparison_report.md index 5ee2fc2f..4b56da7d 100644 --- a/tests/Issue_Files/reports_xlsx/comparison_report.md +++ b/tests/Issue_Files/reports_xlsx/comparison_report.md @@ -1,6 +1,6 @@ -# MiniPdf vs Reference PDF Comparison Report +# MiniPdf vs Microsoft 365 Reference PDF Comparison Report -Generated: 2026-08-09T01:57:44.974922 +Generated: 2026-09-04T20:39:22.365558 ## Summary @@ -28,24 +28,53 @@ Generated: 2026-08-09T01:57:44.974922 | 20 | 🔴 XlsxIssue82_5mb | ✅ | 0.1277 | 0.8157 | 722/766 | **0.4774** | | 21 | 🔴 XlsxIssue82_SampleTestData5mb | ✅ | 0.4255 | 0.9146 | 834/1668 | **0.636** | | 22 | 🟢 XlsxIssue82_WideTable | ✅ | 1.0 | 0.8648 | 13/13 | **0.9459** | +| 23 | 🟢 Issue202609031340 | ✅ | 0.8828 | 0.9524 | 4/4 | **0.9341** | -**Average Overall Score: 0.8359** +**Average Overall Score: 0.8401** + +## Difference Heatmaps + +Blue areas are below the configured difference threshold; red areas have stronger pixel differences. The reference rendering is retained as faint context. + + + + + + + + + + + + + + + + + + + + + + + +
CaseHeatmapMetrics
Issue202609031340
Page 1
Issue202609031340 page 1 difference heatmapchanged: 444245 px (20.41%)
bbox: [43, 29, 1195, 1754]
mean abs RGB: 27.6568
RMSE RGB: 71.4265
threshold: 12, gain: 5.0
Issue202609031340
Page 2
Issue202609031340 page 2 difference heatmapchanged: 63529 px (2.92%)
bbox: [43, 725, 1217, 1014]
mean abs RGB: 4.3159
RMSE RGB: 28.1847
threshold: 12, gain: 5.0
Issue202609031340
Page 3
Issue202609031340 page 3 difference heatmapchanged: 176239 px (8.10%)
bbox: [42, 40, 1196, 1754]
mean abs RGB: 10.4512
RMSE RGB: 43.9019
threshold: 12, gain: 5.0
Issue202609031340
Page 4
Issue202609031340 page 4 difference heatmapchanged: 18149 px (0.83%)
bbox: [42, 743, 1196, 1009]
mean abs RGB: 1.166
RMSE RGB: 14.6026
threshold: 12, gain: 5.0
## Visual Comparison - + - + - + @@ -53,11 +82,11 @@ Generated: 2026-08-09T01:57:44.974922 - + - + @@ -65,15 +94,15 @@ Generated: 2026-08-09T01:57:44.974922 - + - + - + @@ -81,19 +110,19 @@ Generated: 2026-08-09T01:57:44.974922 - + - + - + - + @@ -101,7 +130,7 @@ Generated: 2026-08-09T01:57:44.974922 - + @@ -109,15 +138,15 @@ Generated: 2026-08-09T01:57:44.974922 - + - + - + @@ -125,7 +154,7 @@ Generated: 2026-08-09T01:57:44.974922 - + @@ -133,7 +162,7 @@ Generated: 2026-08-09T01:57:44.974922 - + @@ -141,15 +170,15 @@ Generated: 2026-08-09T01:57:44.974922 - + - + - + @@ -157,15 +186,15 @@ Generated: 2026-08-09T01:57:44.974922 - + - + - + @@ -173,7 +202,7 @@ Generated: 2026-08-09T01:57:44.974922 - + @@ -181,15 +210,15 @@ Generated: 2026-08-09T01:57:44.974922 - + - + - + @@ -197,15 +226,15 @@ Generated: 2026-08-09T01:57:44.974922 - + - + - + @@ -213,7 +242,7 @@ Generated: 2026-08-09T01:57:44.974922 - + @@ -221,15 +250,15 @@ Generated: 2026-08-09T01:57:44.974922 - + - + - + @@ -237,11 +266,11 @@ Generated: 2026-08-09T01:57:44.974922 - + - + @@ -249,15 +278,15 @@ Generated: 2026-08-09T01:57:44.974922 - + - + - + @@ -265,15 +294,15 @@ Generated: 2026-08-09T01:57:44.974922 - + - + - + @@ -281,15 +310,15 @@ Generated: 2026-08-09T01:57:44.974922 - + - + - + @@ -297,15 +326,15 @@ Generated: 2026-08-09T01:57:44.974922 - + - + - + @@ -313,15 +342,15 @@ Generated: 2026-08-09T01:57:44.974922 - + - + - + @@ -329,15 +358,35 @@ Generated: 2026-08-09T01:57:44.974922 - + - + - + + + + + + + + + + + + + + + + + + + + +
MiniPdfLibreOffice (Reference)
MiniPdfMicrosoft 365 Reference
Academic Achievement Summary Table
format: xlsx | case: Academic Achievement Summary Table | scope: issue-xlsx
Academic Achievement Summary Table ⬤ 97.4%
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
AcademicAchievement_temp
format: xlsx | case: AcademicAchievement_temp | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
Business expense budget1
format: xlsx | case: Business expense budget1 | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
Business expenses budget2
format: xlsx | case: Business expenses budget2 | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
Business plan checklist with SWOT analysis1
format: xlsx | case: Business plan checklist with SWOT analysis1 | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
Event budget1
format: xlsx | case: Event budget1 | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
Expense report basic1
format: xlsx | case: Expense report basic1 | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
Grocery list1
format: xlsx | case: Grocery list1 | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
payroll-calculator_f
format: xlsx | case: payroll-calculator_f | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
PO_anonymized
format: xlsx | case: PO_anonymized | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
Simple invoice1
format: xlsx | case: Simple invoice1 | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
Small business cash flow forecast1
format: xlsx | case: Small business cash flow forecast1 | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
missingReferenceMicrosoft 365 Reference
Wedding_timeline_planner1_copy
format: xlsx | case: Wedding_timeline_planner1_copy | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
Weekly schedule planner1
format: xlsx | case: Weekly schedule planner1 | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
XlsxIssue75
format: xlsx | case: XlsxIssue75 | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
XlsxIssue77_MergedCellAlignment
format: xlsx | case: XlsxIssue77_MergedCellAlignment | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
XlsxIssue77_Template1
format: xlsx | case: XlsxIssue77_Template1 | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
XlsxIssue77_Template2_Workaround
format: xlsx | case: XlsxIssue77_Template2_Workaround | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
XlsxIssue81_LayoutOptions
format: xlsx | case: XlsxIssue81_LayoutOptions | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
XlsxIssue82_5mb
format: xlsx | case: XlsxIssue82_5mb | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
XlsxIssue82_SampleTestData5mb
format: xlsx | case: XlsxIssue82_SampleTestData5mb | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
XlsxIssue82_WideTable
format: xlsx | case: XlsxIssue82_WideTable | scope: issue-xlsx
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
MiniPdfReferenceMicrosoft 365 Reference
Issue202609031340
format: xlsx | case: Issue202609031340 | scope: issue-xlsx
Issue202609031340 ⬤ 93.4%
MiniPdfMicrosoft 365 Reference
MiniPdfMicrosoft 365 Reference
MiniPdfMicrosoft 365 Reference
MiniPdfMicrosoft 365 Reference
@@ -623,7 +672,6 @@ Generated: 2026-08-09T01:57:44.974922 ---PAGE--- Q2 ACTUAL VARIANCE - ``` @@ -682,7 +730,7 @@ Generated: 2026-08-09T01:57:44.974922 -Web site updates $ 200.00 $ 200.00 $ 200.00 $ 200.00 $ 200.00 $ 1,000.00 $ 200.00 $ 200.00 $ 200.00 $ 200.00 $ 200.00 $ 1,000.00 $ 4,000.00 --Collateral preparation $ 5,000.00 $ - $ - $ 5,000.00 $ - +-Collateral preparation $ 5,000.00 $ - $ - $ 5,000.00 $ - ... (13476 more characters) ``` @@ -726,7 +774,6 @@ Generated: 2026-08-09T01:57:44.974922 Kristin Orav 1/16/2023 how it will differentiate itself within the marketplace. - ``` @@ -990,7 +1037,6 @@ Text content: ✅ Identical Bacon wrapped Beef Farmer's Market LOCAL MARKET 10 lbs $7.99 $79.90 - ``` @@ -1335,7 +1381,6 @@ Text content: ✅ Identical A875 Peonies 35 $1.05 $36.75 K245 Tulips 25 $2.00 $50.00 - ``` @@ -1577,7 +1622,6 @@ Text content: ✅ Identical +Hire your wedding day transportation (carriage, limousine service, +☐ - ... (2605 more characters) ``` @@ -1615,7 +1659,6 @@ Text content: ✅ Identical ✖ Read ✔ Read ✔ Read Read Read Read Read ✔ Stretch ✔ Stretch ✖ Stretch Stretch Stretch Stretch Stretch - ``` @@ -2280,7 +2323,7 @@ Text content: ✅ Identical -44 User 44 user44@e New York Canada Sales IfbMmzIxe 2116040.7609570 --45 User 45 user45@eSydney +-45 User 45 user45@eSydney ... (16011 more characters) ``` @@ -2297,6 +2340,308 @@ Text content: ✅ Identical Text content: ✅ Identical +### Issue202609031340 + +- **Case Metadata:** format: xlsx | case: Issue202609031340 | scope: issue-xlsx +- **Text Similarity:** 0.8828 +- **Visual Average:** 0.9524 +- **Overall Score:** 0.9341 +- **Pages:** MiniPdf=4, Reference=4 +- **File Size:** MiniPdf=470835 bytes, Reference=346206 bytes + +
Text Diff + +```diff +--- minipdf/Issue202609031340.pdf ++++ reference/Issue202609031340.pdf +@@ -1,37 +1,34 @@ + 表单编号 XXX-XXX-051-01 + +-文件编号 QA-SIPI-XXXX-01-1-1 + +-XXXXXX有限公司 + ++XXXXXX有限公司 文件编号QA-SIPI-XXXX-01-1-1 + + 发行日期 2025-04-11 + + 文件版本 03 修订日期 2025-11-25 + + 产品穴号 详见注意事项 产品单重 20.45±1.05 g + ++注塑制程检验标准书 + + ¢1.18*10MM(320度加强 + +-注塑制程检验标准书 + + 附件编号 / 销钉规格 + + 型) + + 产品型号 XXXX 部件名称 盖塑胶件 抽样计划 ANSI/ASQ Z1.4Ⅱ 巡检频率 2H/次 + +-MO25XXXX0100/MO25 + ++MO25XXXX0100/MO + + 模具编号 部件材质 PETG 允收水准 CR: 0 MA:1.0 MI: 2.0 + +-XXXX0110 + ++25XXXX0110 + + 检验条件 检验光源角度:45°-90°,检验距离30cm,光照900~1200lux + + 参考 图纸文件编号:XXXX系列 内部图纸 2025.4.10 + + 项目 检验工具 巡检内容 + + 图 部件图纸编号:HKXXXX-01-RB + +-目视、图纸 1、首件样品整模结构符合图纸设计、同PPS结构一致; + +-结 + +-构 + +-、PPS 2、制程巡检整模产品结构需同首件结构一致。 + +-目视、限度 1、首件样品颜色需无限接近限度样中的标准样; + +-颜 + +-色 + +-样 2、制程巡检产品颜色需同首件颜色一致。 + +-目视、限度 1、注塑成型外观缺陷大小不可超出限度样板; + +-外 + +-观 + +-样 2、其它外观缺陷无限度样的按常规通用标准判定。 + ++结 目视、图 1、首件样品整模结构符合图纸设计、同PPS结构一致; + ++构 纸、PPS 2、制程巡检整模产品结构需同首件结构一致。 + ++颜 目视、限 1、首件样品颜色需无限接近限度样中的标准样; + ++色 度样 2、制程巡检产品颜色需同首件颜色一致。 + ++外 目视、限 1、注塑成型外观缺陷大小不可超出限度样板; + ++观 度样 2、其它外观缺陷无限度样的按常规通用标准判定。 + + 包装 目视 参照注塑作业指导书(SOP) + + 仪器 项目 管控标准(mm) 测量位置 频率 + +-卡尺 A 68.16 +0.05/-0.17 外形最长距离 1次/班 + +-卡尺 B 62.03 +0.05/-0.20 外形最宽距离 1次/班 + ++1次/ + ++卡尺 A 68.16 +0.05/-0.17 外形最长距离 + ++班 + ++1次/ + ++卡尺 B 62.03 +0.05/-0.20 外形最宽距离 + ++班 + + 卡尺 C 27.9 +0.03/-0.05 顶部装饰片槽长度 首件 + + 卡尺 D 18.87 +0.00/-0.10 后钮侧面装饰片槽长度 首件 + + 卡尺 E 6.70+0.00/-0.05 后钮侧面大卡槽宽度 首件 + +@@ -40,22 +37,44 @@ + 寸 针规 G 1.20±0.02 针孔内径 6H + + 投影仪 H 58.69+0.02/-0.12 针孔中心至外形最宽距离 6H + + 卡尺 I 8.35+0.10/-0.05 口部平面至顶部高度 首件 + +-后钮针孔中分线中心点至前扣弧线中 + ++后钮针孔中分线中心点至前扣弧 + + 投影仪 J 53.93+0.04/-0.03 6H + +-心点距离 + +-卡尺 K 18.61±0.05 后钮针孔左右间距 1次/班 + +-高度规 L*2 4.65+0.00/-0.10 后钮针孔中心至口部平面间距 1次/班 + ++线中心点距离 + ++1次/ + ++卡尺 K 18.61±0.05 后钮针孔左右间距 + ++班 + ++1次/ + ++高度规 L*2 4.65+0.00/-0.10 后钮针孔中心至口部平面间距 + ++班 + ++产品后钮装饰片槽缺 + ++口竖边同卡尺贴紧 + ++68.16 mm 62.03 mm + ++产品口部平面同 + ++产品口部平面同 + ++卡尺上平面平行 + ++卡尺上平面平行 + ++产品后钮装饰片槽缺 + ++口竖边同卡尺贴紧 + ++0.00 ~ 300mm卡尺 + + 参考资料/项目 代码 取样数量 频率 代码 取样数量 频率 注意事项及品质履历 + +-尺 1.MO25XXXX0100:1#~4#/MO25XXXX0110:5#~12# + ++1.MO25XXXX0100:1#~4#/MO25XXXX0110:5#~12# + ++尺 + + 寸 + + 组 + +-组装制程检验标准书 + +-1# 一模 1次/班 2# 一模 1次/班 + ++组装制程检验标 + ++1次/ 1次/ + ++准书 1# 一模 2# 一模 + ++班 班 + + 合 + +-XXXX-00组装成品 功 + +-3# 一模 1次/班 5# 一模 1次/班 + ++XXXX- + ++功 + ++1次/ 1次/ + ++3# 一模 5# 一模 + ++00组装成品 班 班 + + 能 + +-6# 一模 1次/班 + ++1次/ + ++6# 一模 + ++班 + + 日期 版本 工程担当 品质担当 修 订 内 容 + + 2025/4/11 00 AAAA AAA 初版发行 + + 修 + +@@ -63,23 +82,20 @@ + 修 + + A:68.16 +0.05/-0.13改为68.16 +0.05/-0.17;B:62.03 +0.05/-0.10改为62.03 +0.05/-0.20;D:18.87 + + 订 + +++0.00/-0.05改为18.87 + + 2025/5/12 01 BBBB BBB + +-记 +0.00/-0.05改为18.87 +0.00/-0.10;I:8.35+0.00/-0.16改为8.35+0.10/-0.05;J:53.93+0.00/-0.10改为53. + +-录 + +-93+0.05/-0.10;L*2:4.65±0.05改成4.65+0.00/-0.10;更新检验频率 + ++记 + +++0.00/-0.10;I:8.35+0.00/-0.16改为8.35+0.10/-0.05;J:53.93+0.00/-0.10改为53.93+0.05/-0.10; + ++录 L*2:4.65±0.05改成4.65+0.00/-0.10;更新检验频率 + + 2025/8/6 02 CCCC CCCC 新增模具编号:MO25XXXX0110 + + 2025/11/25 03 DDDD DDDD J:53.93+0.05/-0.10改为53.93+0.04/-0.03 + ++研 制 + + 制 品 + +-研 制 + ++审 发 审 造 审 审 制 + + 造 保 + +-发 造 + +-部 部 制 + +-工 工 + +-作 + ++核 工 核 工 核 核 作 + ++部 部 + + 程 程 + +-审 审 + +-核 核 + +-审 审 + + ---PAGE--- + + 文件编号 QA-SIPI-XXXX-01-1-1 表单编号 QA-FM-051-01 + + XXXXXX有限公司 + +@@ -90,8 +106,8 @@ + 产品品质履历(SIP附件) + + 版本 日期 问题 +... (88 more characters) + +``` +
+ ## Improvement Suggestions ### ⚠ Low-Score Test Cases (below 0.8) diff --git a/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p1_heatmap.png b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p1_heatmap.png new file mode 100644 index 00000000..3aa277ee Binary files /dev/null and b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p1_heatmap.png differ diff --git a/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p1_minipdf.png b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p1_minipdf.png new file mode 100644 index 00000000..bc5be905 Binary files /dev/null and b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p1_minipdf.png differ diff --git a/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p1_reference.png b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p1_reference.png new file mode 100644 index 00000000..cd07b9fa Binary files /dev/null and b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p1_reference.png differ diff --git a/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p2_heatmap.png b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p2_heatmap.png new file mode 100644 index 00000000..5a2ec9c6 Binary files /dev/null and b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p2_heatmap.png differ diff --git a/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p2_minipdf.png b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p2_minipdf.png new file mode 100644 index 00000000..f3253277 Binary files /dev/null and b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p2_minipdf.png differ diff --git a/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p2_reference.png b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p2_reference.png new file mode 100644 index 00000000..1060b344 Binary files /dev/null and b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p2_reference.png differ diff --git a/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p3_heatmap.png b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p3_heatmap.png new file mode 100644 index 00000000..7d4ead46 Binary files /dev/null and b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p3_heatmap.png differ diff --git a/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p3_minipdf.png b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p3_minipdf.png new file mode 100644 index 00000000..3790e0c6 Binary files /dev/null and b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p3_minipdf.png differ diff --git a/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p3_reference.png b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p3_reference.png new file mode 100644 index 00000000..996d7731 Binary files /dev/null and b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p3_reference.png differ diff --git a/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p4_heatmap.png b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p4_heatmap.png new file mode 100644 index 00000000..ec99e226 Binary files /dev/null and b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p4_heatmap.png differ diff --git a/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p4_minipdf.png b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p4_minipdf.png new file mode 100644 index 00000000..28469c83 Binary files /dev/null and b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p4_minipdf.png differ diff --git a/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p4_reference.png b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p4_reference.png new file mode 100644 index 00000000..a656ea30 Binary files /dev/null and b/tests/Issue_Files/reports_xlsx/images/Issue202609031340_p4_reference.png differ diff --git a/tests/Issue_Files/xlsx/Issue202609031340.xlsx b/tests/Issue_Files/xlsx/Issue202609031340.xlsx new file mode 100644 index 00000000..caa55d1a Binary files /dev/null and b/tests/Issue_Files/xlsx/Issue202609031340.xlsx differ diff --git a/tests/MiniPdf.Benchmark/compare_pdfs.py b/tests/MiniPdf.Benchmark/compare_pdfs.py index 5f4cc6a1..b61e4fe0 100644 --- a/tests/MiniPdf.Benchmark/compare_pdfs.py +++ b/tests/MiniPdf.Benchmark/compare_pdfs.py @@ -133,7 +133,7 @@ def normalize_manifest_case(entry: dict) -> dict: def load_manifest_cases(manifest_path: str) -> list[dict]: """Load benchmark cases from a manifest JSON file.""" - with open(manifest_path, encoding="utf-8") as f: + with open(manifest_path, encoding="utf-8-sig") as f: data = json.load(f) if isinstance(data, list): @@ -1045,7 +1045,7 @@ def compare_single( return result -def generate_report(results: list[dict], report_dir: str, labels: dict | None = None): +def generate_report(results: list[dict], report_dir: str, labels: Optional[dict] = None): """Generate a markdown + JSON comparison report.""" labels = labels or {} candidate_label = labels.get("candidate", "MiniPdf") diff --git a/tests/MiniPdf.Tests/XlsxIssueFileTests.cs b/tests/MiniPdf.Tests/XlsxIssueFileTests.cs index 04e980c2..4040daee 100644 --- a/tests/MiniPdf.Tests/XlsxIssueFileTests.cs +++ b/tests/MiniPdf.Tests/XlsxIssueFileTests.cs @@ -5,6 +5,53 @@ namespace MiniSoftware.Tests; public class XlsxIssueFileTests { + [Fact] + public void Issue202609031340_PreservesLayoutAndOlePreviews() + { + var issuePath = FindIssueXlsx("Issue202609031340.xlsx"); + + using var stream = File.OpenRead(issuePath); + var sheets = ExcelReader.ReadSheets(stream); + Assert.All(sheets, sheet => Assert.True(sheet.VerticalCentered)); + Assert.Equal(255, sheets[0].Rows[45][0].TextRotation); + if (Compat.IsWindows()) + { + Assert.Equal(2, sheets[0].Images.Count); + Assert.All(sheets[0].Images, image => + { + Assert.Equal("png", image.Extension); + Assert.NotNull(image.AbsoluteLeftPt); + Assert.NotNull(image.AbsoluteTopPt); + Assert.NotNull(image.VmlFromColOffset); + Assert.NotNull(image.VmlFromRowOffset); + Assert.NotNull(image.VmlToColOffset); + Assert.NotNull(image.VmlToRowOffset); + }); + } + + var doc = ExcelToPdfConverter.Convert(issuePath); + + Assert.Equal(4, doc.Pages.Count); + Assert.All(new[] { doc.Pages[1], doc.Pages[3] }, page => + { + Assert.NotEmpty(page.LineBlocks); + Assert.True(page.LineBlocks.Max(line => Math.Max(line.Y1, line.Y2)) < page.Height * 0.7f); + Assert.True(page.LineBlocks.Min(line => Math.Min(line.Y1, line.Y2)) > page.Height * 0.3f); + }); + Assert.All("修订记录", character => + Assert.Contains(doc.Pages[1].TextBlocks, block => block.Text == character.ToString())); + if (Compat.IsWindows()) + { + Assert.Equal(2, doc.Pages[0].ImageBlocks.Count); + var imageTops = doc.Pages[0].ImageBlocks + .Select(image => doc.Pages[0].Height - image.Y - image.RenderHeight) + .OrderBy(top => top) + .ToArray(); + Assert.InRange(imageTops[0], 215f, 218f); + Assert.InRange(imageTops[1], 528f, 530f); + } + } + [Fact] public void AcademicAchievement_ManualPageBreak_UsesIntegerScaleForFixedRows() {