diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1ad2d24..a0810021 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,38 @@ jobs: name: test-results path: '**/test-results.trx' + docx-font-tests: + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Verify required DOCX fonts + shell: pwsh + run: | + $fontDirectory = Join-Path $env:WINDIR 'Fonts' + foreach ($font in @('cambria.ttc', 'cambriab.ttf', 'cambriaz.ttf', 'seguisym.ttf', 'seguiemj.ttf')) { + if (-not (Test-Path -LiteralPath (Join-Path $fontDirectory $font))) { + throw "Required DOCX test font is unavailable: $font" + } + } + + - name: Test DOCX rendering with system fonts + run: dotnet test tests/MiniPdf.Tests/MiniPdf.Tests.csproj --configuration Release --filter "FullyQualifiedName~Docx|FullyQualifiedName~SystemFontTheoryAttributeTests" --verbosity normal --logger "trx;LogFileName=docx-font-results.trx" + + - name: Upload DOCX test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: docx-font-test-results + path: '**/docx-font-results.trx' + ai-security-scan: if: github.event_name == 'pull_request' runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83817850..8b47535c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,12 @@ dotnet build --configuration Release dotnet test tests/MiniPdf.Tests --configuration Release ``` +DOCX tests that measure installed Cambria or Segoe fonts report an explicit skip +when their required fonts are unavailable. CI runs the portable suite on Ubuntu +and a separate DOCX suite on Windows, where it verifies the required fonts +before testing. Install these fonts through a licensed system/Office installation +to run the font-dependent checks locally; do not add font binaries to the repository. + ### Rust Install the stable Rust toolchain, then run: diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index ead4524a..0b78268e 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -149,6 +149,24 @@ Word compatibility modes up to 14 align implicit left indents with first-cell content; modern implicit indents continue to align the table border. Inline images in table cells honor the paragraph's left, center, or right alignment in both body and header/footer tables. +Cambria table cells use the same preferred-font advances for wrapping and +row-height calculation, including header/footer tables. Auto-spaced body +paragraphs do not carry the next text-line ascent into a following table. +Large first-paragraph runs advance subsequent content from their rendered +baseline rather than the smaller inherited paragraph-mark baseline. +Cell line height distinguishes explicit paragraph-mark sizing from inherited +defaults. Collapsed table borders contribute half their stroke widths to natural +row heights and text insets; center/bottom alignment uses the remaining content +area. Cambria/Calibri paragraph flow tracks font ascents across text, empty marks, +and inline-image hosts. Auto-spaced image-only paragraphs add proportional font +leading without scaling image height. Hard section starts collapse paragraph +space-before against the closing section's space-after. +Image-only paragraphs reclaim the next text ascent before body tables, while +border-only empty marks reset text-ascent tracking. Courier transitions use +Courier font metrics. Symbol cells account for the Segoe fallback's ascent and +line height when the PDF writer selects that font. +Mixed-format Cambria paragraphs measure each run's bold/italic font advances and +spaces consistently for wrapping, run positioning, alignment, and run shading. ## XLSX Rendering Gaps diff --git a/src/MiniPdf/DocxReader.cs b/src/MiniPdf/DocxReader.cs index 980bf8d9..f095ab21 100644 --- a/src/MiniPdf/DocxReader.cs +++ b/src/MiniPdf/DocxReader.cs @@ -912,6 +912,7 @@ private static float ParseVmlPointLength(Dictionary style, strin float spacingAfter = -1; bool spacingAfterExplicit = false; bool spacingBeforeExplicit = false; + float paragraphMarkFontSize = 0; float lineSpacing = 0; bool lineSpacingAbsolute = false; bool lineSpacingExact = false; @@ -1183,7 +1184,10 @@ private static float ParseVmlPointLength(Dictionary style, strin { var sz = rPr.Element(W + "sz")?.Attribute(W + "val")?.Value; if (float.TryParse(sz, out var s)) + { fontSize = s / 2f; // half-points to points + paragraphMarkFontSize = fontSize; + } // Paragraph-level character spacing var spacingEl2 = rPr.Element(W + "spacing"); if (spacingEl2 != null && int.TryParse(spacingEl2.Attribute(W + "val")?.Value, out var pcs)) @@ -1509,7 +1513,8 @@ private static float ParseVmlPointLength(Dictionary style, strin ListSuff: listSuff, ListTabStop: listTabStop, OutlineLevel: ReadOutlineLevel(pPr), - StyleIndentFirstLine: styleIndentFirstLineForListLabel); + StyleIndentFirstLine: styleIndentFirstLineForListLabel, + ParagraphMarkFontSize: paragraphMarkFontSize); } private static void EmitPageFieldPlaceholder(string instruction, List runs, bool bold, bool italic, float fontSize, PdfColor? color) @@ -5286,7 +5291,8 @@ internal sealed record DocxParagraph( // not position the automatic-numbering suffix. float? ListTabStop = null, int OutlineLevel = -1, - float StyleIndentFirstLine = 0 + float StyleIndentFirstLine = 0, + float ParagraphMarkFontSize = 0 ) : DocxElement; /// Represents a single border edge. Width=0 is a sentinel for an diff --git a/src/MiniPdf/DocxToPdfConverter.cs b/src/MiniPdf/DocxToPdfConverter.cs index d12b9ee7..5994684c 100644 --- a/src/MiniPdf/DocxToPdfConverter.cs +++ b/src/MiniPdf/DocxToPdfConverter.cs @@ -8,6 +8,11 @@ internal static class DocxToPdfConverter { // Single-spaced line height ≈ fontSize × this factor (ascent + descent for typical fonts) private const float FontMetricsFactor = 1.17f; + // OS/2 Windows ascent plus descent, divided by unitsPerEm. + private const float FontMetricsFactorCambria = (1946f + 455f) / 2048f; + private const float FontMetricsFactorCalibri = (1950f + 550f) / 2048f; + private const float FontMetricsFactorCourier = (1705f + 615f) / 2048f; + private const float FontMetricsFactorSegoeSymbol = (2210f + 514f) / 2048f; // Times New Roman / Georgia metric factor: these serif fonts have smaller ascent/descent // ratios than Calibri, so the standard factor over-estimates line height. // TNR: (usWinAscent+usWinDescent)/unitsPerEm ≈ 1.107, but Word's "auto" line @@ -38,6 +43,11 @@ internal static class DocxToPdfConverter private const float FontMetricsFactorFranklinGothic = 1.136f; // Helvetica ascent ratio: visual top of text is baseline + fontSize × AscentRatio private const float AscentRatio = 1.075f; + // Cambria OS/2 usWinAscent / unitsPerEm. + private const float CambriaAscentRatio = 1946f / 2048f; + private const float CalibriAscentRatio = 1950f / 2048f; + private const float CourierAscentRatio = 1705f / 2048f; + private const float SegoeSymbolAscentRatio = 2210f / 2048f; // Calibri-to-Helvetica width ratio: most DOCX documents use Calibri (default since Word 2007). // Used only as a fallback; per-character Calibri widths are used when available. private const float CalibriWidthScale = 0.87f; @@ -327,6 +337,7 @@ internal static PdfDocument Convert(Stream docxStream, ConversionOptions? option RenderConnectorLines(state, paragraph.ConnectorLines, state.LastParagraphStartY); if (paragraph.SectionBreak != null) { + var sectionSpacingAfter = state.LastSpacingAfter; sectionIndex++; if (sectionIndex < sectionLayouts.Count) { @@ -352,6 +363,7 @@ internal static PdfDocument Convert(Stream docxStream, ConversionOptions? option // first content downward on the new page. state.PendingVerticalSpace = 0; state.ForceNewPage(); + state.SectionStartSpacingAfter = sectionSpacingAfter; if (nextLayout.ColumnCount > 1) state.EnterMultiColumnSection(nextLayout.ColumnCount, nextLayout.ColumnSpacing, nextLayout.ColumnWidths, nextLayout.ColumnGaps); } @@ -362,6 +374,7 @@ internal static PdfDocument Convert(Stream docxStream, ConversionOptions? option state.ExitMultiColumnSection(); state.PendingVerticalSpace = 0; state.ForceNewPage(); + state.SectionStartSpacingAfter = sectionSpacingAfter; } // Record new section's start page state.SectionStartPages.Add(pdfDoc.Pages.Count - 1); @@ -715,6 +728,8 @@ public bool AvoidWrapObstacles(float ascent, float descent) /// 14pt paragraph at line=360 (1.5x) erroneously gains ~5-6pt of leading. /// public float LastFontSize { get; set; } + public float? LastBaselineAscent { get; set; } + public float? SectionStartSpacingAfter { get; set; } public bool LastParagraphWasEmpty { get; set; } /// @@ -854,6 +869,8 @@ public bool AdvanceToNextColumn() CurrentY = ColumnTopY; IsTopOfPage = true; LastLineHeight = 0; + LastBaselineAscent = null; + SectionStartSpacingAfter = null; LastSpacingAfter = 0; LastGridAscentExcess = 0; return true; @@ -887,6 +904,8 @@ public void EnsurePage() } IsTopOfPage = true; LastLineHeight = 0; + LastBaselineAscent = null; + SectionStartSpacingAfter = null; LastSpacingAfter = 0; // Reset to first column on new page @@ -929,6 +948,8 @@ public void ForceNewPage() } IsTopOfPage = true; LastLineHeight = 0; + LastBaselineAscent = null; + SectionStartSpacingAfter = null; LastSpacingAfter = 0; LastGridAscentExcess = 0; @@ -1002,6 +1023,8 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, if (paragraph.HasPageBreakBefore) state.ForceNewPage(); + var sectionSpacingAfter = state.SectionStartSpacingAfter; + state.SectionStartSpacingAfter = null; var options = state.Options; var fontSize = paragraph.FontSize > 0 ? paragraph.FontSize : options.FontSize; // Font metrics factor: single-spaced line height ≈ fontSize × FontMetricsFactor @@ -1027,10 +1050,10 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, // spaces) DO contribute — Word renders space glyphs at the run's font // size, so they keep their full vertical metrics. var effectiveFs = fontSize; + var baselineFontSize = 0f; foreach (var run in paragraph.Runs) { var runFs = run.FontSize > 0 ? run.FontSize : fontSize; - if (runFs <= effectiveFs) continue; var rt = run.Text; bool tabOnly = !string.IsNullOrEmpty(rt); if (tabOnly) @@ -1041,9 +1064,12 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, } } if (string.IsNullOrEmpty(rt) || tabOnly) continue; - effectiveFs = runFs; + effectiveFs = Math.Max(effectiveFs, runFs); + baselineFontSize = Math.Max(baselineFontSize, runFs); } + if (baselineFontSize == 0) baselineFontSize = fontSize; var lineHeight = ApplyLineSpacingRule(paragraph, effectiveFs * metricsFactor * lineSpacingMul); + var topOfPageAscentRatio = GetTopOfPageAscentRatio(paraFontName, ResolveLineSpacingMul(paragraph, options)); var isLargeCjkTitle = IsLargeCjkTitle(paragraph); var isCenteredCjkGridHeading = state.IsTopOfPage @@ -1133,15 +1159,16 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, if (isLargeCjkTitle && isVeryFirstParagraph) spacingBefore += 30f; if (spacingBefore > 0 && (!state.IsTopOfPage || paragraph.ForceSpacingBefore - || (isVeryFirstParagraph && paragraph.SpacingBeforeExplicit))) + || (isVeryFirstParagraph && paragraph.SpacingBeforeExplicit) + || (state.IsTopOfPage && sectionSpacingAfter.HasValue))) { var extraBefore = options.CollapseParagraphSpacing && !(paragraph.Runs.Count == 0 && paragraph.Images.Count == 0 && paragraph.Borders != null) - ? spacingBefore - state.LastSpacingAfter + ? spacingBefore - (sectionSpacingAfter ?? state.LastSpacingAfter) : spacingBefore; if (extraBefore > 0) { - if (isVeryFirstParagraph) + if (state.IsTopOfPage) state.CurrentY -= extraBefore; else state.AdvanceY(extraBefore); @@ -1184,6 +1211,17 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, Math.Abs(b.YPt) <= lineHeight && b.HeightPt <= lineHeight * 1.5f); + // Empty paragraph marks participate in font-ascent changes just like visible text. + var latinBaselineAscent = GetLatinBaselineAscent(paraFontName, baselineFontSize); + if (latinBaselineAscent.HasValue) + latinBaselineAscent += GetHeadingBaselineOffset(paragraph, paraFontName, paragraph.Bold); + var usedLatinBaselineAdjustment = !state.IsTopOfPage && options.GridLinePitch <= 0 + && !paragraph.LineSpacingAbsolute + && latinBaselineAscent.HasValue && state.LastBaselineAscent.HasValue; + if (usedLatinBaselineAdjustment + && latinBaselineAscent is { } currentAscent && state.LastBaselineAscent is { } previousAscent) + state.AdvanceY(currentAscent - previousAscent); + // Handle empty paragraphs before EnsurePage — they don't produce visible content // and should not force a new page (avoids spurious trailing pages). if (paragraph.Runs.Count == 0 && paragraph.Images.Count == 0 && paragraph.Shading == null @@ -1226,6 +1264,8 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, state.AvoidWrapObstacles(emptyLineAscent, Math.Max(0f, totalEmptyAdvance - emptyLineAscent)); RenderParagraphBorders(state, paragraph, state.CurrentY, state.CurrentY, isEmptyParagraph: true); state.AdvanceY(totalEmptyAdvance); + if (followedByTable && options.GridLinePitch <= 0 && !paragraph.LineSpacingAbsolute) + state.CurrentY += latinBaselineAscent ?? emptyLineAscent; // If the empty paragraph pushed past the bottom margin, accumulate // the overflow as pending vertical space for the next page so that // spacing from empty paragraphs is preserved across page breaks. @@ -1243,6 +1283,7 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, } state.LastLineHeight = lineHeight; state.LastFontSize = fontSize; + state.LastBaselineAscent = paragraph.Borders == null ? latinBaselineAscent : null; state.LastSpacingAfter = spacingAfterEmpty; state.LastParagraphWasEmpty = true; if (options.GridLinePitch > 0 && paragraph.SnapToGrid && !(paragraph.LineSpacingAbsolute && paragraph.LineSpacingExact)) @@ -1359,7 +1400,7 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, { var ascentOffset = options.GridLinePitch > 0 && paragraph.SnapToGrid ? currentGridAscent - : fontSize * GetTopOfPageAscentRatio(paraFontName, ResolveLineSpacingMul(paragraph, options)); + : fontSize * topOfPageAscentRatio; state.AdvanceY(ascentOffset); } @@ -1402,7 +1443,7 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, var runFs = run.FontSize > 0 ? run.FontSize : fontSize; if (runFs > currEffFsForGate) currEffFsForGate = runFs; } - if (!wasTopOfPage && !paragraph.LineSpacingAbsolute && state.LastLineHeight > 0 + if (!usedLatinBaselineAdjustment && !wasTopOfPage && !paragraph.LineSpacingAbsolute && state.LastLineHeight > 0 && lineHeight > state.LastLineHeight && !state.LastParagraphWasEmpty && currEffFsForGate > lastEffFsForGate + 0.01f) { @@ -1419,7 +1460,7 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, // shrink. Verified against "Template for MSc Thesis.docx" page 2 where // a 14pt → 12pt list-paragraph transition needs a ~-1.9pt correction. // Gated symmetrically with the grow case above to limit scope. - else if (!wasTopOfPage && !paragraph.LineSpacingAbsolute && state.LastLineHeight > 0 + else if (!usedLatinBaselineAdjustment && !wasTopOfPage && !paragraph.LineSpacingAbsolute && state.LastLineHeight > 0 && lineHeight < state.LastLineHeight && !state.LastParagraphWasEmpty && currEffFsForGate < lastEffFsForGate - 0.01f) { @@ -1431,7 +1472,7 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, // stay attached to the line that actually starts the paragraph. var firstLineAscent = currentGridAscent > 0 ? currentGridAscent - : fontSize * GetTopOfPageAscentRatio(paraFontName, ResolveLineSpacingMul(paragraph, options)); + : fontSize * topOfPageAscentRatio; if (paragraph.Borders?.Top is { } topBorder) state.AdvanceY(topBorder.Space + topBorder.Width * (topBorder.Style == "double" ? 3f : 1f)); var yBeforeObstacles = state.CurrentY; @@ -1628,7 +1669,12 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, var inlineImageOffset = paragraph.Runs.Count == 0 && !image.IsAnchor ? fontSize * GetTopOfPageAscentRatio(paraFontName, ResolveLineSpacingMul(paragraph, options)) : 0f; - RenderImage(state, image, paragraph.Alignment, inlineImageOffset); + // Proportional spacing adds font leading without scaling the image height. + var imageGap = paragraph.Runs.Count == 0 && !image.IsAnchor + && options.GridLinePitch <= 0 && !paragraph.LineSpacingAbsolute && latinBaselineAscent.HasValue + ? Math.Max(0f, lineHeight - effectiveFs * metricsFactor) + : 1f; + RenderImage(state, image, paragraph.Alignment, inlineImageOffset, imageGap); } // Proactive page break: if text + deferred wrapTopAndBottom images @@ -1749,6 +1795,10 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, state.AdvanceY(spacingAfterEmpty); state.LastSpacingAfter = spacingAfterEmpty; state.LastParagraphWasEmpty = true; + state.LastBaselineAscent = latinBaselineAscent; + if (followedByTable && options.GridLinePitch <= 0 && !paragraph.LineSpacingAbsolute + && paragraph.Images.Any(image => !image.IsAnchor)) + state.CurrentY += baselineFontSize * topOfPageAscentRatio; // Handle page break after (even for empty paragraphs) if (paragraph.HasPageBreakAfter) @@ -1838,7 +1888,7 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, } var paragraphBaselineOffset = wasTopOfPage && currentGridAscent == 0 && effectiveFs > fontSize - ? (effectiveFs - fontSize) * GetTopOfPageAscentRatio(paraFontName, ResolveLineSpacingMul(paragraph, options)) + ? (effectiveFs - fontSize) * topOfPageAscentRatio : 0f; if (hasVaryingFormat) { @@ -2033,11 +2083,15 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, s_serifRunInCalibri = false; } + state.CurrentY -= paragraphBaselineOffset; + // (Previously: hard cap to lineHeight for exact line spacing. Removed — // wrapping is now governed by the right-edge check during run rendering, // and the overflow-tolerance heuristic prevents spurious multi-line wraps // for short equation/overlay paragraphs.) - RenderParagraphBorders(state, paragraph, paragraphStartY + firstLineAscent, state.CurrentY + firstLineAscent); + var borderAscent = currentGridAscent > 0 ? firstLineAscent + : effectiveFs * topOfPageAscentRatio; + RenderParagraphBorders(state, paragraph, paragraphStartY + borderAscent, state.CurrentY + borderAscent); if (paragraph.Borders?.Bottom is { } bottomBorder) state.AdvanceY(bottomBorder.Space + bottomBorder.Width * (bottomBorder.Style == "double" ? 3f : 1f)); @@ -2069,6 +2123,7 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, state.LastLineHeight = lineHeight; state.LastFontSize = currEffFsForGate; + state.LastBaselineAscent = latinBaselineAscent; state.LastParagraphWasEmpty = false; // When grid snapping is active, the AdvanceY(lineHeight) after the last @@ -2086,6 +2141,10 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, state.LastGridFirstLineAscent = 0; } + // Auto-spaced text advances to the next baseline, but a following table starts at its top. + if (followedByTable && currentGridAscent == 0 && !paragraph.LineSpacingAbsolute) + state.CurrentY += baselineFontSize * topOfPageAscentRatio; + // Handle page break after if (paragraph.HasPageBreakAfter) state.ForceNewPage(); @@ -2736,11 +2795,12 @@ void AdvanceToContinuationLine(float fontSize, string? fontName) : null; void DrawRunShading(string text, float x, float baselineY, float fontSize, bool bold, - float charSpacing, bool useCalibri, PdfColor? shading, float? maxWidth = null) + float charSpacing, bool useCalibri, PdfColor? shading, float? maxWidth = null, + string? fontName = null, bool italic = false) { if (shading == null || string.IsNullOrEmpty(text) || state.CurrentPage == null) return; - var width = maxWidth ?? EstimateWrapTextWidth(text, fontSize, bold, charSpacing, useCalibri); + var width = maxWidth ?? EstimateWrapTextWidth(text, fontSize, bold, charSpacing, useCalibri, fontName, italic); if (width <= 0) return; var padX = Math.Max(0.7f, fontSize * 0.08f); var rectHeight = fontSize * 1.18f; @@ -2756,7 +2816,7 @@ void BufferOrEmit(string text, float bx, float by, float bfs, PdfColor? bcolor, lineEntries.Add((text, bx, by, bfs, bcolor, bbold, bitalic, bunderline, bcs, bfontName, bmaxW, bulW, bshading)); else { - DrawRunShading(text, bx, by, bfs, bbold, bcs, state.Options.UseCalibriWidths, bshading, bmaxW); + DrawRunShading(text, bx, by, bfs, bbold, bcs, state.Options.UseCalibriWidths, bshading, bmaxW, bfontName, bitalic); // Pass a tiny wordSpacing so PdfWriter fills any gap between the chunk's // natural rendered width and the allocated maxWidth via per-space stretching. // Without this, run boundaries (e.g. bold→regular at "...copies)" + ":") show @@ -2795,7 +2855,7 @@ static float WrapEntryWidth((string Text, float X, float Y, float FontSize, PdfC if (preferActual && PdfWriter.TryMeasurePreferredFontWidth( e.FontName, e.Text, e.FontSize, e.Bold, e.Italic, e.CharSpacing, out var preferredWidth)) return preferredWidth; - var w = EstimateWrapTextWidth(e.Text, e.FontSize, e.Bold, e.CharSpacing, useCalibri); + var w = EstimateWrapTextWidth(e.Text, e.FontSize, e.Bold, e.CharSpacing, useCalibri, e.FontName, e.Italic); if (useCalibri && e.FontName != null && !e.FontName.Contains("Calibri", StringComparison.OrdinalIgnoreCase)) { @@ -2859,7 +2919,7 @@ static float WrapEntryWidth((string Text, float X, float Y, float FontSize, PdfC ? estW : estW + justifyWordSpacing * entrySpaces; DrawRunShading(e.Text, entryX, e.Y, e.FontSize, e.Bold, e.CharSpacing, - useCalibriJustify, e.Shading, entryTarget); + useCalibriJustify, e.Shading, entryTarget, e.FontName, e.Italic); state.CurrentPage!.AddText(e.Text, entryX, e.Y, e.FontSize, e.Color, bold: e.Bold, italic: e.Italic, underline: e.Underline, charSpacing: e.CharSpacing, wordSpacing: justifyWordSpacing, @@ -2874,7 +2934,7 @@ static float WrapEntryWidth((string Text, float X, float Y, float FontSize, PdfC foreach (var e in lineEntries) { if (e.X < minX) minX = e.X; - var w = EstimateWrapTextWidth(e.Text, e.FontSize, e.Bold, e.CharSpacing, false); + var w = EstimateWrapTextWidth(e.Text, e.FontSize, e.Bold, e.CharSpacing, false, e.FontName, e.Italic); var endX = e.X + w; if (endX > maxEndX) maxEndX = endX; } @@ -2885,7 +2945,7 @@ static float WrapEntryWidth((string Text, float X, float Y, float FontSize, PdfC foreach (var e in lineEntries) { DrawRunShading(e.Text, e.X + shift, e.Y, e.FontSize, e.Bold, e.CharSpacing, - false, e.Shading, e.MaxWidth); + false, e.Shading, e.MaxWidth, e.FontName, e.Italic); state.CurrentPage!.AddText(e.Text, e.X + shift, e.Y, e.FontSize, e.Color, bold: e.Bold, italic: e.Italic, underline: e.Underline, charSpacing: e.CharSpacing, wordSpacing: 0, @@ -3047,9 +3107,13 @@ static float WrapEntryWidth((string Text, float X, float Y, float FontSize, PdfC // When segment contains CJK characters, the PDF renderer uses CJK fonts // for the entire text block, so ASCII spaces use CJK half-width (500/1000). var segmentHasCjk = hasCjkContext && segment.Any(c => c >= '\u2E80' && !char.IsHighSurrogate(c) && !char.IsLowSurrogate(c)); + var textSpaceWidth = runFs * GetWrapCharWidth(' ', useCalibri) / 1000f; + var usePreferredRunWidths = ShouldUsePreferredFontWidthsForWrap(run.FontName, useCalibri) + && PdfWriter.TryMeasurePreferredFontWidth(run.FontName, " ", runFs, + run.Bold, run.Italic, 0f, out textSpaceWidth); var effectiveSpaceWidth = (isWhitespaceUnderline || segmentHasCjk) ? runFs * 500f / 1000f - : runFs * GetWrapCharWidth(' ', useCalibri) / 1000f; + : usePreferredRunWidths ? textSpaceWidth : runFs * GetWrapCharWidth(' ', useCalibri) / 1000f; // Preserve leading whitespace at paragraph start (e.g. a `` // in its own run expanded by ExpandTabs to spaces). The inter-word @@ -3060,7 +3124,9 @@ static float WrapEntryWidth((string Text, float X, float Y, float FontSize, PdfC { int leadCount = 0; while (leadCount < segment.Length && segment[leadCount] == ' ') leadCount++; - var leadSpaceW = runFs * GetWrapCharWidth(' ', useCalibri) / 1000f; + var leadSpaceW = usePreferredRunWidths + ? textSpaceWidth + run.CharSpacing + : runFs * GetWrapCharWidth(' ', useCalibri) / 1000f; currentX += leadCount * leadSpaceW; segment = segment.Substring(leadCount); if (string.IsNullOrEmpty(segment)) continue; @@ -3095,7 +3161,7 @@ static float WrapEntryWidth((string Text, float X, float Y, float FontSize, PdfC // 8% Latin reduction instead of the 22% CJK-context reduction. // Pre-compute correction factor to match CJK font Latin glyph widths. var cjkLatinCorrection = 1f; - if (!useCalibri && hasCjkContext) + if (!useCalibri && hasCjkContext && !usePreferredRunWidths) { var nonCjkKeep = run.Bold ? 0.95f : (s_serifFont ? 0.90f : 0.92f); cjkLatinCorrection = 0.78f / nonCjkKeep; @@ -3120,7 +3186,8 @@ static float WrapEntryWidth((string Text, float X, float Y, float FontSize, PdfC { effectiveFactor = 1f; break; } } } - var wordWidth = EstimateWrapTextWidth(word, runFs, run.Bold, run.CharSpacing, useCalibri) * effectiveFactor; + var wordWidth = EstimateWrapTextWidth(word, runFs, run.Bold, run.CharSpacing, + useCalibri, run.FontName, run.Italic) * effectiveFactor; // Apply CJK Latin correction for pure-Latin words in CJK paragraph if (cjkLatinCorrection < 1f && word.Length > 0) { @@ -3133,6 +3200,7 @@ static float WrapEntryWidth((string Text, float X, float Y, float FontSize, PdfC } var spaceWidth = wi > 0 ? effectiveSpaceWidth + run.CharSpacing + + (usePreferredRunWidths && word.Length > 0 && pendingText.Length > 0 ? run.CharSpacing : 0f) : 0; // Check if word fits on current line. For exact line spacing, @@ -3260,7 +3328,8 @@ static float WrapEntryWidth((string Text, float X, float Y, float FontSize, PdfC FlushLineEntries(); pendingText = pendingText[breakAt..]; AdvanceToContinuationLine(runFs, run.FontName); - currentX = baseX + EstimateWrapTextWidth(pendingText, runFs, run.Bold, run.CharSpacing, useCalibri) * nonCalibriWidthFactor; + currentX = baseX + EstimateWrapTextWidth(pendingText, runFs, run.Bold, run.CharSpacing, + useCalibri, run.FontName, run.Italic) * nonCalibriWidthFactor; pendingX = baseX; isFirstLine = false; } @@ -3300,7 +3369,8 @@ static float WrapEntryWidth((string Text, float X, float Y, float FontSize, PdfC var trimmedWords = pendingText.TrimEnd(' '); if (trimmedWords.Length > 0) { - var natWordsWidth = EstimateWrapTextWidth(trimmedWords, runFs, run.Bold, run.CharSpacing, useCalibri); + var natWordsWidth = EstimateWrapTextWidth(trimmedWords, runFs, run.Bold, run.CharSpacing, + useCalibri, run.FontName, run.Italic); var inflationExcess = natWordsWidth * (nonCalibriWidthFactor - 1f); if (inflationExcess > 0) currentX -= inflationExcess; @@ -3526,7 +3596,8 @@ private static void RenderShapeGeometry(PdfPage page, float x, float y, float wi // ── Image rendering ───────────────────────────────────────────────── - private static void RenderImage(RenderState state, DocxImage image, string alignment = "left", float inlineVerticalOffset = 0) + private static void RenderImage(RenderState state, DocxImage image, string alignment = "left", + float inlineVerticalOffset = 0, float gapAfter = 1f) { const float emuPerPoint = 914400f / 72f; @@ -3620,7 +3691,7 @@ private static void RenderImage(RenderState state, DocxImage image, string align y = maximumY; state.CurrentPage!.AddImage(image.Data, format, x, y, width, height); - state.AdvanceY(height + 1f); // 1pt gap after image + state.AdvanceY(height + gapAfter); } // ── Header/footer element rendering ───────────────────────────────── @@ -3711,9 +3782,11 @@ private static float EstimateElementsHeight(List elements, Conversi var colWidths = CalculateTableColumnWidths(table, effectiveTableWidth); var cellPaddingH = (table.CellMarginLeft + table.CellMarginRight) / 2; var cellPaddingV = Math.Max(0f, Math.Max(table.CellMarginTop, table.CellMarginBottom)); - foreach (var row in table.Rows) + for (var rowIndex = 0; rowIndex < table.Rows.Count; rowIndex++) { - var rowH = CalculateRowHeight(row, colWidths, cellPaddingH, cellPaddingV, options, table.StyleLineSpacing, table.StyleSpacingAfter); + var row = table.Rows[rowIndex]; + var rowH = CalculateRowHeight(row, colWidths, cellPaddingH, cellPaddingV, options, table.StyleLineSpacing, table.StyleSpacingAfter) + + GetRowBorderHeight(table, rowIndex); if (row.Height > 0) { if (row.HeightExact) @@ -3930,9 +4003,11 @@ private static float RenderHeaderFooterTable(PdfPage page, ConversionOptions opt }; var y = startY; - foreach (var row in table.Rows) + for (var rowIndex = 0; rowIndex < table.Rows.Count; rowIndex++) { - var rowHeight = CalculateRowHeight(row, colWidths, cellPaddingH, cellPaddingV, options, table.StyleLineSpacing, table.StyleSpacingAfter); + var row = table.Rows[rowIndex]; + var rowHeight = CalculateRowHeight(row, colWidths, cellPaddingH, cellPaddingV, options, table.StyleLineSpacing, table.StyleSpacingAfter) + + GetRowBorderHeight(table, rowIndex); if (row.Height > 0) { if (row.HeightExact) @@ -3972,12 +4047,14 @@ private static float RenderHeaderFooterTable(PdfPage page, ConversionOptions opt if (cell.VerticalAlignment != "top") { var contentHeight = CalculateCellContentHeight(cell, cellWidth, effPaddingH, effPaddingV, options, table.StyleLineSpacing, table.StyleSpacingAfter); - var space = rowHeight - contentHeight; + var space = rowHeight - contentHeight + - GetCellTopBorderInset(table, cell, rowIndex) + - GetCellBottomBorderInset(table, cell, rowIndex); if (space > 0) vAlignOffset = cell.VerticalAlignment == "bottom" ? space : space / 2; } - var textY = y - effPaddingV - vAlignOffset; + var textY = y - effPaddingV - vAlignOffset - GetCellTopBorderInset(table, cell, rowIndex); float lastParaSpacingAfter = 0; foreach (var para in cell.Paragraphs) { @@ -4016,9 +4093,10 @@ private static float RenderHeaderFooterTable(PdfPage page, ConversionOptions opt var fontSize = para.FontSize > 0 ? para.FontSize : options.FontSize; var firstRun = para.Runs.FirstOrDefault(r => !string.IsNullOrEmpty(r.Text)); var runFontSize = firstRun?.FontSize > 0 ? firstRun.FontSize : fontSize; + var metricFontName = GetCellMetricFontName(firstRun); var lineHeight = ApplyLineSpacingRule( para, - runFontSize * GetFontMetricsFactor(firstRun?.FontName) + Math.Max(runFontSize, para.ParagraphMarkFontSize) * GetFontMetricsFactor(metricFontName) * GetLineSpacingMultiple(para, table.StyleLineSpacing > 0 ? table.StyleLineSpacing : options.LineSpacing)); var textWidth = cellWidth - effPaddingLeft - effPaddingRight; // Use Calibri widths only when the run font is Calibri-like; @@ -4026,14 +4104,18 @@ private static float RenderHeaderFooterTable(PdfPage page, ConversionOptions opt var cellUseCalibri = options.UseCalibriWidths && !IsWideSansSerifFont(firstRun?.FontName); s_overrideWidths = GetFontOverrideWidths(firstRun?.FontName); + s_preferredWrapFontName = ShouldUsePreferredFontWidthsForWrap(firstRun?.FontName, cellUseCalibri) + ? firstRun?.FontName : null; s_wideSansSerifFont = IsWideSansSerifFont(firstRun?.FontName) && s_overrideWidths == null; s_serifRunInCalibri = cellUseCalibri && IsSerifFont(firstRun?.FontName); var lines = WordWrap(text, textWidth, textWidth, runFontSize, null, firstRun?.Bold ?? false, firstRun?.CharSpacing ?? 0f, cellUseCalibri); + var firstLineAscentRatio = GetCellFirstLineAscentRatio(metricFontName); foreach (var line in lines) { - textY -= runFontSize; + textY -= runFontSize * firstLineAscentRatio; + firstLineAscentRatio = 1f; var lineTextWidth = EstimateWrapTextWidth(line, runFontSize, firstRun?.Bold ?? false, firstRun?.CharSpacing ?? 0f, cellUseCalibri); var lineX = para.Alignment switch @@ -4054,6 +4136,7 @@ private static float RenderHeaderFooterTable(PdfPage page, ConversionOptions opt textY -= lineHeight - runFontSize; } s_overrideWidths = null; + s_preferredWrapFontName = null; s_wideSansSerifFont = false; s_serifRunInCalibri = false; @@ -4133,7 +4216,8 @@ private static void RenderTable(RenderState state, DocxTable table) for (var ri = 0; ri < table.Rows.Count; ri++) { var r = table.Rows[ri]; - var ch = CalculateRowHeight(r, colWidths, cellPaddingH, cellPaddingV, options, table.StyleLineSpacing, table.StyleSpacingAfter); + var ch = CalculateRowHeight(r, colWidths, cellPaddingH, cellPaddingV, options, table.StyleLineSpacing, table.StyleSpacingAfter) + + GetRowBorderHeight(table, ri); var rh = ch; if (r.Height > 0) { @@ -4288,12 +4372,15 @@ void DrawOneTableRow(DocxTableRow rowArg, int rowIdxArg, float rowHeightArg, boo if (cell.VerticalAlignment != "top") { var contentHeight = CalculateCellContentHeight(cell, cellDrawWidth, (effCellLeft + effCellRight) / 2, effCellPaddingV, options, table.StyleLineSpacing, table.StyleSpacingAfter); - var space = cellRenderHeight - contentHeight; + var space = cellRenderHeight - contentHeight + - GetCellTopBorderInset(table, cell, rowIdxArg) + - GetCellBottomBorderInset(table, cell, rowIdxArg); if (space > 0) vAlignOffset = cell.VerticalAlignment == "bottom" ? space : space / 2; } - var textY2 = rowDrawTop - effCellPaddingV - vAlignOffset; + var textY2 = rowDrawTop - effCellPaddingV - vAlignOffset + - GetCellTopBorderInset(table, cell, rowIdxArg); var cellParaList = cell.Paragraphs; float lastCellSpacingAfter = 0; for (var cellParaIdx = 0; cellParaIdx < cellParaList.Count; cellParaIdx++) @@ -4369,13 +4456,13 @@ void DrawOneTableRow(DocxTableRow rowArg, int rowIdxArg, float rowHeightArg, boo var cellRunUnderline = dominantRun?.Underline ?? false; var cellRunCharSpacing = dominantRun?.CharSpacing ?? 0f; var cellRunFontName = dominantRun?.FontName; + var cellMetricFontName = GetCellMetricFontName(dominantRun); var cellRunShading = dominantRun?.Shading; - var cellLineMetricFs = effectiveFontSize; - if (para.FontSize > effectiveFontSize) cellLineMetricFs = para.FontSize; + var cellLineMetricFs = Math.Max(effectiveFontSize, para.ParagraphMarkFontSize); var cellMetricsFactor = !options.SnapToGridInTableCells && cellRunFontName != null && IsTallCjkFont(cellRunFontName) ? LegacyWordTableCjkMetricsFactor - : GetFontMetricsFactor(cellRunFontName); + : GetFontMetricsFactor(cellMetricFontName); var cellLineSpacingMul = GetLineSpacingMultiple( para, table.StyleLineSpacing > 0 ? table.StyleLineSpacing : options.LineSpacing); var lineHeight = ApplyLineSpacingRule(para, cellLineMetricFs * cellMetricsFactor * cellLineSpacingMul); @@ -4391,9 +4478,13 @@ void DrawOneTableRow(DocxTableRow rowArg, int rowIdxArg, float rowHeightArg, boo && ContainsCjk(text) ? effectiveFontSize * 0.4f : 0f; - var wrapWidth = textWidth + 0.5f + legacyNarrowCjkTolerance; var cellUseCalibri = options.UseCalibriWidths && !IsWideSansSerifFont(cellRunFontName) && !IsTaiwanKaiFont(cellRunFontName); s_overrideWidths = GetFontOverrideWidths(cellRunFontName); + s_preferredWrapFontName = ShouldUsePreferredFontWidthsForWrap(cellRunFontName, cellUseCalibri) + ? cellRunFontName : null; + // Measured font advances need the same content width as row-height calculation. + var wrapWidth = textWidth + legacyNarrowCjkTolerance + + (s_preferredWrapFontName == null ? 0.5f : 0f); s_wideSansSerifFont = IsWideSansSerifFont(cellRunFontName) && s_overrideWidths == null; s_taiwanKaiFont = IsTaiwanKaiFont(cellRunFontName); s_serifRunInCalibri = cellUseCalibri && IsSerifFont(cellRunFontName); @@ -4402,7 +4493,7 @@ void DrawOneTableRow(DocxTableRow rowArg, int rowIdxArg, float rowHeightArg, boo // For the first line of the first cell paragraph, place the baseline // using the font's actual usWinAscent ratio rather than the nominal // font size. This matches LibreOffice's cell baseline placement. - var firstLineAscRatio = isFirstCellPara ? GetCellFirstLineAscentRatio(cellRunFontName) : 1.0f; + var firstLineAscRatio = isFirstCellPara ? GetCellFirstLineAscentRatio(cellMetricFontName) : 1.0f; foreach (var line in lines) { textY2 -= effectiveFontSize * firstLineAscRatio; @@ -4460,6 +4551,7 @@ void DrawOneTableRow(DocxTableRow rowArg, int rowIdxArg, float rowHeightArg, boo textY2 -= lineHeight - effectiveFontSize; } s_overrideWidths = null; + s_preferredWrapFontName = null; s_wideSansSerifFont = false; s_taiwanKaiFont = false; s_serifRunInCalibri = false; @@ -4516,19 +4608,13 @@ void DrawOneTableRow(DocxTableRow rowArg, int rowIdxArg, float rowHeightArg, boo } } - static DocxBorderEdge? ResolveEdge(DocxBorderEdge? cellEdge, DocxBorderEdge? tableEdge) - { - if (cellEdge == null) return tableEdge; - if (cellEdge.Width <= 0f) return null; - return cellEdge; - } DocxBorderEdge? topBorder2, bottomBorder2, leftBorder2, rightBorder2; if (borders2 != null) { - topBorder2 = suppressTop2 ? null : ResolveEdge(borders2.Top, table.HasBorders ? (isFirstArg ? table.BorderTop : table.BorderInsideH) : null); - bottomBorder2 = suppressBottom2 ? null : ResolveEdge(borders2.Bottom, table.HasBorders ? (isLastArg ? table.BorderBottom : table.BorderInsideH) : null); - leftBorder2 = ResolveEdge(borders2.Left, table.HasBorders ? (isFirstCell2 ? table.BorderLeft : table.BorderInsideV) : null); - rightBorder2 = ResolveEdge(borders2.Right, table.HasBorders ? (isLastCell2 ? table.BorderRight : table.BorderInsideV) : null); + topBorder2 = suppressTop2 ? null : ResolveTableBorderEdge(borders2.Top, table.HasBorders ? (isFirstArg ? table.BorderTop : table.BorderInsideH) : null); + bottomBorder2 = suppressBottom2 ? null : ResolveTableBorderEdge(borders2.Bottom, table.HasBorders ? (isLastArg ? table.BorderBottom : table.BorderInsideH) : null); + leftBorder2 = ResolveTableBorderEdge(borders2.Left, table.HasBorders ? (isFirstCell2 ? table.BorderLeft : table.BorderInsideV) : null); + rightBorder2 = ResolveTableBorderEdge(borders2.Right, table.HasBorders ? (isLastCell2 ? table.BorderRight : table.BorderInsideV) : null); } else if (table.HasBorders) { @@ -4676,6 +4762,8 @@ void DrawOneTableRow(DocxTableRow rowArg, int rowIdxArg, float rowHeightArg, boo // Reset spacing context after table so the next paragraph's SpacingBefore // is not collapsed with the pre-table paragraph's SpacingAfter. state.LastSpacingAfter = 0; + state.LastBaselineAscent = GetLatinBaselineAscent(s_defaultFontName, options.FontSize); + state.LastLineHeight = 0; } private static void AddFormCheckboxOverlay(PdfPage page, float x, @@ -4791,17 +4879,17 @@ private static float CalculateCellContentHeight(DocxTableCell cell, float cellWi var fontSize = para.FontSize > 0 ? para.FontSize : options.FontSize; var dominantRun = para.Runs.FirstOrDefault(r => !string.IsNullOrEmpty(r.Text)); + var metricFontName = GetCellMetricFontName(dominantRun); var runFontSize = dominantRun?.FontSize > 0 ? dominantRun.FontSize : fontSize; var runCharSpacing = dominantRun?.CharSpacing ?? 0f; var runBold = dominantRun?.Bold ?? false; // Word's line height includes the paragraph mark font size from pPr/rPr/sz — // when the mark is larger than the run text, the line grows accordingly. - var lineMetricFs = runFontSize; - if (para.FontSize > runFontSize) lineMetricFs = para.FontSize; + var lineMetricFs = Math.Max(runFontSize, para.ParagraphMarkFontSize); var cellMetricsFactor = !options.SnapToGridInTableCells && dominantRun?.FontName != null && IsTallCjkFont(dominantRun.FontName) ? LegacyWordTableCjkMetricsFactor - : GetFontMetricsFactor(dominantRun?.FontName); + : GetFontMetricsFactor(metricFontName); var cellLineSpacingMul = GetLineSpacingMultiple( para, styleLineSpacing > 0 ? styleLineSpacing : options.LineSpacing); var lineHeight = ApplyLineSpacingRule(para, lineMetricFs * cellMetricsFactor * cellLineSpacingMul); @@ -4843,6 +4931,8 @@ float ResolveSpAfter(DocxParagraph p) && !IsWideSansSerifFont(dominantRun?.FontName) && !IsTaiwanKaiFont(dominantRun?.FontName); s_overrideWidths = GetFontOverrideWidths(dominantRun?.FontName); + s_preferredWrapFontName = ShouldUsePreferredFontWidthsForWrap(dominantRun?.FontName, cellUseCalibri) + ? dominantRun?.FontName : null; s_wideSansSerifFont = IsWideSansSerifFont(dominantRun?.FontName) && s_overrideWidths == null; s_taiwanKaiFont = IsTaiwanKaiFont(dominantRun?.FontName); s_serifRunInCalibri = cellUseCalibri && !s_serifFont && IsSerifFont(dominantRun?.FontName); @@ -4855,6 +4945,7 @@ float ResolveSpAfter(DocxParagraph p) textWidth + legacyNarrowCjkTolerance, runFontSize, null, runBold, runCharSpacing, cellUseCalibri); s_overrideWidths = null; + s_preferredWrapFontName = null; s_wideSansSerifFont = false; s_taiwanKaiFont = false; s_serifRunInCalibri = false; @@ -4864,8 +4955,8 @@ float ResolveSpAfter(DocxParagraph p) // extra (ascRatio - 1) * fontSize must be included in the height budget. if (isFirstPara) { - var ascRatio = GetCellFirstLineAscentRatio(dominantRun?.FontName); - if (ascRatio > 1.0f) + var ascRatio = GetCellFirstLineAscentRatio(metricFontName); + if (ascRatio > 1.0f && !IsSegoeSymbolFont(metricFontName)) cellHeight += (ascRatio - 1.0f) * runFontSize; } cellHeight += lines.Count * lineHeight; @@ -4877,6 +4968,41 @@ float ResolveSpAfter(DocxParagraph p) return cellHeight; } + private static DocxBorderEdge? ResolveTableBorderEdge(DocxBorderEdge? cellEdge, DocxBorderEdge? tableEdge) + => cellEdge == null ? tableEdge : cellEdge.Width > 0 ? cellEdge : null; + + private static float GetCellTopBorderInset(DocxTable table, DocxTableCell cell, int rowIndex) + { + if (table.CellSpacing > 0 || cell.IsVMergeContinue) + return 0; + var tableEdge = table.HasBorders ? (rowIndex == 0 ? table.BorderTop : table.BorderInsideH) : null; + return (ResolveTableBorderEdge(cell.Borders?.Top, tableEdge)?.Width ?? 0) / 2f; + } + + private static float GetRowBorderHeight(DocxTable table, int rowIndex) + { + if (table.CellSpacing > 0) + return 0; + var height = 0f; + foreach (var cell in table.Rows[rowIndex].Cells) + { + if (cell.IsVMergeContinue) + continue; + height = Math.Max(height, GetCellTopBorderInset(table, cell, rowIndex) + + GetCellBottomBorderInset(table, cell, rowIndex)); + } + return height; + } + + private static float GetCellBottomBorderInset(DocxTable table, DocxTableCell cell, int rowIndex) + { + if (table.CellSpacing > 0 || cell.IsVMergeContinue || cell.IsVMergeRestart) + return 0; + var tableEdge = table.HasBorders + ? (rowIndex == table.Rows.Count - 1 ? table.BorderBottom : table.BorderInsideH) : null; + return (ResolveTableBorderEdge(cell.Borders?.Bottom, tableEdge)?.Width ?? 0) / 2f; + } + private static float CalculateRowHeight(DocxTableRow row, float[] colWidths, float cellPaddingH, float cellPaddingV, ConversionOptions options, float styleLineSpacing = -1, float styleSpacingAfter = -1) { // Use table style line spacing for minimum row height @@ -5403,6 +5529,15 @@ private static float GetLineSpacingMultiple(DocxParagraph para, float fallback) /// private static float GetFontMetricsFactor(string? fontName) { + var latinName = fontName ?? s_defaultFontName; + if (latinName?.Contains("Cambria", StringComparison.OrdinalIgnoreCase) == true) + return FontMetricsFactorCambria; + if (latinName?.Contains("Calibri", StringComparison.OrdinalIgnoreCase) == true) + return FontMetricsFactorCalibri; + if (latinName?.Contains("Courier", StringComparison.OrdinalIgnoreCase) == true) + return FontMetricsFactorCourier; + if (IsSegoeSymbolFont(latinName)) + return FontMetricsFactorSegoeSymbol; // Fall back to the document-level default font name only when it would change // the answer (Avenir/AvenirNext via theme minorHAnsi) and the paragraph/run // does not specify one explicitly. Themed templates often leave the body @@ -5436,6 +5571,13 @@ private static float GetFontMetricsFactor(string? fontName) private static float GetCellFirstLineAscentRatio(string? fontName) { + var latinName = fontName ?? s_defaultFontName; + if (latinName?.Contains("Cambria", StringComparison.OrdinalIgnoreCase) == true) + return CambriaAscentRatio; + if (latinName?.Contains("Calibri", StringComparison.OrdinalIgnoreCase) == true) + return CalibriAscentRatio; + if (IsSegoeSymbolFont(latinName)) + return SegoeSymbolAscentRatio; // Returns the ratio of usWinAscent/unitsPerEm for fonts used as the first // paragraph of a table cell. LibreOffice places the first cell baseline at // rowTop - cellPadding - spacingBefore - fontSize * ascentRatio, using the @@ -5459,6 +5601,21 @@ private static float GetCellFirstLineAscentRatio(string? fontName) return 1.0f; } + private static bool IsSegoeSymbolFont(string? fontName) + => fontName?.Contains("Segoe UI Symbol", StringComparison.OrdinalIgnoreCase) == true + || fontName?.Contains("Segoe UI Emoji", StringComparison.OrdinalIgnoreCase) == true; + + private static string? GetCellMetricFontName(DocxRun? run) + { + var fontName = run?.FontName ?? s_defaultFontName; + if (run != null && PdfWriter.EnumerateCodePoints(run.Text).Any(codePoint => + PdfWriter.IsEmojiRange(codePoint) || codePoint is >= 0x2700 and <= 0x27BF) + && !PdfWriter.TryMeasurePreferredFontWidth(run.FontName, run.Text, 1f, false, false, 0f, out _) + && PdfWriter.TryMeasurePreferredFontWidth("Segoe UI Emoji", run.Text, 1f, false, false, 0f, out _)) + return "Segoe UI Emoji"; + return fontName; + } + private static bool IsTaiwanKaiFont(string? fontName) { @@ -5489,6 +5646,8 @@ private static float GetTopOfPageAscentRatio(string? fontName, float lineSpacing { var name = fontName; if (string.IsNullOrEmpty(name)) name = s_defaultFontName; + if (name?.Contains("Cambria", StringComparison.OrdinalIgnoreCase) == true) + return CambriaAscentRatio; if (!string.IsNullOrEmpty(name) && (name!.Contains("Times", StringComparison.OrdinalIgnoreCase) || name.Contains("Georgia", StringComparison.OrdinalIgnoreCase))) @@ -5569,6 +5728,18 @@ private static bool IsSerifFont(string? fontName) || fontName.Contains("Garamond", StringComparison.OrdinalIgnoreCase); } + private static float? GetLatinBaselineAscent(string? fontName, float fontSize) + { + var name = fontName ?? s_defaultFontName; + if (name?.Contains("Cambria", StringComparison.OrdinalIgnoreCase) == true) + return fontSize * CambriaAscentRatio; + if (name?.Contains("Calibri", StringComparison.OrdinalIgnoreCase) == true) + return fontSize * CalibriAscentRatio; + if (name?.Contains("Courier", StringComparison.OrdinalIgnoreCase) == true) + return fontSize * CourierAscentRatio; + return null; + } + private static bool ShouldUsePreferredFontWidthsForWrap(string? fontName, bool useCalibriWidths) { return !useCalibriWidths @@ -5579,10 +5750,14 @@ private static bool ShouldUsePreferredFontWidthsForWrap(string? fontName, bool u /// When using Helvetica fallback, applies a kerning/hinting reduction since /// Helvetica is wider than most actual document fonts (Times New Roman, PMingLiU, etc.). /// - private static float EstimateWrapTextWidth(string text, float fontSize, bool bold = false, float charSpacing = 0, bool useCalibriWidths = true) + private static float EstimateWrapTextWidth(string text, float fontSize, bool bold = false, + float charSpacing = 0, bool useCalibriWidths = true, string? fontName = null, bool italic = false) { - if (!string.IsNullOrWhiteSpace(s_preferredWrapFontName) - && PdfWriter.TryMeasurePreferredFontWidth(s_preferredWrapFontName, text, fontSize, bold, italic: false, charSpacing, out var preferredWidth)) + var preferredWrapFontName = fontName == null + ? s_preferredWrapFontName + : ShouldUsePreferredFontWidthsForWrap(fontName, useCalibriWidths) ? fontName : null; + if (!string.IsNullOrWhiteSpace(preferredWrapFontName) + && PdfWriter.TryMeasurePreferredFontWidth(preferredWrapFontName, text, fontSize, bold, italic, charSpacing, out var preferredWidth)) return preferredWidth; if (s_overrideWidths != null && !useCalibriWidths) return EstimateOverrideTextWidth(text, fontSize, bold, charSpacing); diff --git a/src/MiniPdf/PdfWriter.cs b/src/MiniPdf/PdfWriter.cs index 1c3976bd..7a479654 100644 --- a/src/MiniPdf/PdfWriter.cs +++ b/src/MiniPdf/PdfWriter.cs @@ -1628,7 +1628,7 @@ private static int CjkPunctCompressKerning(int curCp, int nextCp) /// Returns true if a code point is in a common emoji range, used to prefer /// the dedicated emoji font over CJK fonts that have placeholder glyphs. /// - private static bool IsEmojiRange(int cp) + internal static bool IsEmojiRange(int cp) { return cp >= 0x1F000 // Supplemental Symbols, Emoticons, etc. || (cp >= 0x2600 && cp <= 0x26FF) // Miscellaneous Symbols (☀ ★ ☎) @@ -3082,7 +3082,7 @@ private static void AddPreferredFontCandidates(List candidatePaths, IEnu /// /// Enumerates Unicode code points from a .NET string, properly handling surrogate pairs. /// - private static IEnumerable EnumerateCodePoints(string text) + internal static IEnumerable EnumerateCodePoints(string text) { for (var i = 0; i < text.Length; i++) { diff --git a/tests/MiniPdf.Tests/DocxToPdfConverterTests.cs b/tests/MiniPdf.Tests/DocxToPdfConverterTests.cs index 4e66b942..67b638e5 100644 --- a/tests/MiniPdf.Tests/DocxToPdfConverterTests.cs +++ b/tests/MiniPdf.Tests/DocxToPdfConverterTests.cs @@ -6,6 +6,95 @@ namespace MiniSoftware.Tests; public class DocxToPdfConverterTests { + [SystemFontTheory("Cambria", "Cambria Bold", "Cambria Bold Italic")] + [InlineData("left", false, 0f)] + [InlineData("center", false, 0f)] + [InlineData("right", false, 0f)] + [InlineData("both", false, 0f)] + [InlineData("left", true, 0f)] + [InlineData("center", true, 0f)] + [InlineData("right", true, 0f)] + [InlineData("both", true, 0f)] + [InlineData("left", false, 0.05f)] + [InlineData("left", false, -0.05f)] + [InlineData("center", false, 0.05f)] + [InlineData("center", false, -0.05f)] + public void Convert_CambriaMixedRuns_UsesPreferredWidthsForWrappingAndRunPositions( + string alignment, bool italicPrefix, float charSpacing) + { + const string prefixText = "Note: "; + const string firstLineTail = "Volume discounts are applied automatically. Contact sales for enterprise agreements"; + const string continuation = "over 100 seats."; + const float lineWidth = 432f; + Assert.True(PdfWriter.TryMeasurePreferredFontWidth("Cambria", prefixText, 11f, + true, italicPrefix, 0f, out var prefixWidth)); + Assert.True(PdfWriter.TryMeasurePreferredFontWidth("Cambria", firstLineTail, 11f, + false, false, charSpacing, out var tailWidth)); + Assert.True(PdfWriter.TryMeasurePreferredFontWidth("Cambria", firstLineTail + " over", 11f, + false, false, charSpacing, out var overflowingTailWidth)); + Assert.True(prefixWidth + tailWidth <= lineWidth); + Assert.True(prefixWidth + overflowingTailWidth > lineWidth); + + using var stream = CreateDocxFromBodyXml( + $""" + + + + {(italicPrefix ? "" : "")} + {prefixText} + + + + {firstLineTail} {continuation} + + + """); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true)) + AddEntry(archive, "word/styles.xml", + """ + + + + + + + """); + stream.Position = 0; + var page = Assert.Single(DocxToPdfConverter.Convert(stream, + new DocxToPdfConverter.ConversionOptions + { + MarginLeft = 90f, + MarginRight = 90f, + UseCalibriWidths = false, + }).Pages); + var lines = page.TextBlocks.GroupBy(block => block.Y).OrderByDescending(line => line.Key).ToList(); + Assert.Equal(2, lines.Count); + Assert.Equal(prefixText + firstLineTail, + string.Concat(lines[0].OrderBy(block => block.X).Select(block => block.Text))); + Assert.Equal(continuation, Assert.Single(lines[1]).Text); + + var prefix = lines[0].Single(block => block.Text == prefixText); + var tail = lines[0].Single(block => block.Text == firstLineTail); + var remainingWidth = lineWidth - prefixWidth - tailWidth; + var expectedX = 90f + (alignment switch + { + "center" => remainingWidth / 2f, + "right" => remainingWidth, + _ => 0f, + }); + Assert.InRange(prefix.X, expectedX - 0.05f, expectedX + 0.05f); + var expectedAdvance = alignment == "both" + ? prefixWidth + prefix.WordSpacing + : prefixWidth; + Assert.InRange(tail.X - prefix.X, expectedAdvance - 0.05f, expectedAdvance + 0.05f); + Assert.InRange(lines[0].Key - lines[1].Key, 14.78f, 14.88f); + var shading = page.RectBlocks.Single(rect => + Math.Abs(rect.X - (tail.X - 0.88f)) < 0.05f + && Math.Abs(rect.Y - (tail.Y - 2.64f)) < 0.05f); + var expectedShadingWidth = (tail.MaxWidth ?? tailWidth) + 1.76f; + Assert.InRange(shading.Width, expectedShadingWidth - 0.05f, expectedShadingWidth + 0.05f); + } + [Fact] public void Convert_SimpleDocx_ProducesValidPdf() { @@ -292,6 +381,337 @@ public void Convert_WithTable_RendersCellText() Assert.Contains("Bob", content); } + [SystemFontTheory("Cambria")] + [InlineData("MiniPdf Professional", "body")] + [InlineData("Integration Consulting", "body")] + [InlineData("Custom Development", "body")] + [InlineData("MiniPdf Professional", "hdr")] + [InlineData("MiniPdf Professional", "ftr")] + public void Convert_CambriaTable_UsesPreferredWidthsForWrappingAndRowHeight(string text, string location) + { + Assert.True(PdfWriter.TryMeasurePreferredFontWidth("Cambria", text, 11, + bold: false, italic: false, charSpacing: 0, out var width)); + Assert.True(width > 97.2f, $"Expected text to exceed the cell's content width, got {width}pt."); + using var stream = CreateDocxWithCambriaTable(text, location); + using var shortStream = CreateDocxWithCambriaTable("Short", location); + var options = new DocxToPdfConverter.ConversionOptions { UseCalibriWidths = false }; + var document = DocxToPdfConverter.Convert(stream, options); + var shortDocument = DocxToPdfConverter.Convert(shortStream, + new DocxToPdfConverter.ConversionOptions { UseCalibriWidths = false }); + var page = Assert.Single(document.Pages); + var words = text.Split(' '); + var first = Assert.Single(page.TextBlocks, block => block.Text == words[0]); + var second = Assert.Single(page.TextBlocks, block => block.Text == words[1]); + var marker = Assert.Single(page.TextBlocks, block => block.Text == "Marker"); + var shortMarker = Assert.Single(shortDocument.Pages[0].TextBlocks, block => block.Text == "Marker"); + + Assert.InRange(first.Y - second.Y, 12.885f, 12.906f); + Assert.True(marker.Y < second.Y); + if (location == "ftr") + { + Assert.InRange(shortMarker.Y - marker.Y, -0.01f, 0.01f); + var shortFirst = Assert.Single(shortDocument.Pages[0].TextBlocks, block => block.Text == "Short"); + Assert.InRange(first.Y - shortFirst.Y, 12.885f, 12.906f); + } + else + { + Assert.InRange(shortMarker.Y - marker.Y, 12.885f, 12.906f); + } + Assert.StartsWith("%PDF-1.4", Encoding.ASCII.GetString(document.ToArray())); + } + + [Theory] + [InlineData(false, 12.045f)] + [InlineData(true, 13.75f)] + public void Convert_ParagraphBeforeTable_OnlyAutoSpacingDropsNextLineAscent(bool exact, float expectedDistance) + { + using var stream = CreateDocxFromBodyXml( + $""" + + + Heading + + + + + Cell + + """); + var document = DocxToPdfConverter.Convert(stream); + var heading = Assert.Single(document.Pages[0].TextBlocks, block => block.Text == "Heading"); + var cell = Assert.Single(document.Pages[0].TextBlocks, block => block.Text == "Cell"); + + Assert.InRange(heading.Y - cell.Y, expectedDistance - 0.01f, expectedDistance + 0.01f); + } + + [Theory] + [InlineData(false, 12.4517f)] + [InlineData(true, 14.7964f)] + public void Convert_TableLineHeight_OnlyExplicitParagraphMarkEnlargesSmallRun(bool explicitMark, float expectedDistance) + { + using var stream = CreateDocxWithCambriaTable("Small", "body"); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true)) + { + var entry = archive.GetEntry("word/document.xml")!; + XDocument xml; + using (var reader = new StreamReader(entry.Open())) + xml = XDocument.Load(reader); + XNamespace w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + var paragraph = xml.Descendants(w + "tc").First().Element(w + "p")!; + paragraph.Element(w + "r")!.Element(w + "rPr")!.Element(w + "sz")!.SetAttributeValue(w + "val", "18"); + if (explicitMark) + paragraph.AddFirst(new XElement(w + "pPr", new XElement(w + "rPr", + new XElement(w + "sz", new XAttribute(w + "val", "22"))))); + entry.Delete(); + AddEntry(archive, "word/document.xml", xml.ToString()); + } + stream.Position = 0; + var document = DocxToPdfConverter.Convert(stream); + var small = Assert.Single(document.Pages[0].TextBlocks, block => block.Text == "Small"); + var marker = Assert.Single(document.Pages[0].TextBlocks, block => block.Text == "Marker"); + + Assert.InRange(small.Y - marker.Y, expectedDistance - 0.01f, expectedDistance + 0.01f); + } + + [Theory] + [InlineData("body")] + [InlineData("hdr")] + [InlineData("ftr")] + public void Convert_TableBorders_ReserveStrokeHeightInEveryTableSurface(string location) + { + using var stream = CreateDocxWithCambriaTable("Short", location); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true)) + { + var part = location == "body" ? "word/document.xml" + : location == "hdr" ? "word/header1.xml" : "word/footer1.xml"; + var entry = archive.GetEntry(part)!; + XDocument xml; + using (var reader = new StreamReader(entry.Open())) + xml = XDocument.Load(reader); + XNamespace w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + xml.Descendants(w + "tblPr").Single().Add(new XElement(w + "tblBorders", + new[] { "top", "bottom", "insideH" }.Select(edge => + new XElement(w + edge, new XAttribute(w + "val", "single"), new XAttribute(w + "sz", "8"))))); + entry.Delete(); + AddEntry(archive, part, xml.ToString()); + } + stream.Position = 0; + var document = DocxToPdfConverter.Convert(stream); + var first = Assert.Single(document.Pages[0].TextBlocks, block => block.Text == "Short"); + var marker = Assert.Single(document.Pages[0].TextBlocks, block => block.Text == "Marker"); + + Assert.InRange(first.Y - marker.Y, 13.885f, 13.906f); + } + + [Theory] + [InlineData(false, 14.8955f)] + [InlineData(true, 27.7915f)] + public void Convert_LargeTitleBeforeTable_EmptyMarkUsesItsOwnAscent(bool insertEmpty, float expectedDistance) + { + using var stream = CreateDocxWithCambriaTable("Short", "body"); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true)) + { + var entry = archive.GetEntry("word/document.xml")!; + XDocument xml; + using (var reader = new StreamReader(entry.Open())) + xml = XDocument.Load(reader); + XNamespace w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + var body = xml.Root!.Element(w + "body")!; + if (insertEmpty) + body.AddFirst(new XElement(w + "p")); + body.AddFirst(XElement.Parse( + """ + + Title + + """)); + entry.Delete(); + AddEntry(archive, "word/document.xml", xml.ToString()); + } + stream.Position = 0; + var document = DocxToPdfConverter.Convert(stream); + var title = Assert.Single(document.Pages[0].TextBlocks, block => block.Text == "Title"); + var cell = Assert.Single(document.Pages[0].TextBlocks, block => block.Text == "Short"); + + Assert.InRange(title.Y - cell.Y, expectedDistance - 0.01f, expectedDistance + 0.01f); + } + + [SystemFontTheory("Cambria", "Segoe UI Symbol", "Segoe UI Emoji")] + [InlineData("body", false)] + [InlineData("hdr", false)] + [InlineData("ftr", false)] + [InlineData("body", true)] + [InlineData("hdr", true)] + [InlineData("ftr", true)] + public void Convert_SymbolTable_UsesSymbolAscentAndLineHeight(string location, bool useFallbackFont) + { + using var stream = CreateDocxWithCambriaTable("\u2713", location); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true)) + { + var part = location == "body" ? "word/document.xml" + : location == "hdr" ? "word/header1.xml" : "word/footer1.xml"; + var entry = archive.GetEntry(part)!; + XDocument xml; + using (var source = entry.Open()) + xml = XDocument.Load(source); + XNamespace w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + var run = xml.Descendants(w + "r").First(); + run.Element(w + "rPr")!.Element(w + "rFonts")!.SetAttributeValue(w + "ascii", "Segoe UI Symbol"); + run.Element(w + "rPr")!.Element(w + "rFonts")!.SetAttributeValue(w + "hAnsi", "Segoe UI Symbol"); + if (useFallbackFont) + run.Element(w + "rPr")!.Element(w + "rFonts")!.Remove(); + entry.Delete(); + AddEntry(archive, part, xml.ToString()); + } + stream.Position = 0; + var page = DocxToPdfConverter.Convert(stream).Pages[0]; + var symbol = page.TextBlocks.Single(block => block.Text == "\u2713"); + var marker = page.TextBlocks.Single(block => block.Text == "Marker"); + var expectedDistance = 11f * ((2210f + 514f + 1946f - 2210f) / 2048f); + + Assert.InRange(symbol.Y - marker.Y, expectedDistance - 0.01f, expectedDistance + 0.01f); + } + + [Fact] + public void Convert_BorderOnlyEmptyParagraph_DoesNotCarryTextAscentIntoFollowingHeading() + { + float HeadingY(int fontSize) + { + using var stream = CreateDocxWithCambriaTable("Short", "body"); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true)) + { + var entry = archive.GetEntry("word/document.xml")!; + XDocument xml; + using (var source = entry.Open()) + xml = XDocument.Load(source); + XNamespace w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + xml.Root!.Element(w + "body")!.AddFirst(XElement.Parse( + $""" + + Before + + """), + XElement.Parse( + """ + + + + """), + XElement.Parse( + $""" + + + Heading + + """)); + entry.Delete(); + AddEntry(archive, "word/document.xml", xml.ToString()); + } + stream.Position = 0; + return DocxToPdfConverter.Convert(stream).Pages[0].TextBlocks.Single(block => block.Text == "Heading").Y; + } + + Assert.InRange(HeadingY(13) - HeadingY(11), -0.01f, 0.01f); + } + + [Fact] + public void Convert_CourierParagraph_UsesItsOwnAscentAndLineHeight() + { + using var stream = CreateDocxFromBodyXml( + """ + Before + + + FirstSecond + + """); + var page = Assert.Single(DocxToPdfConverter.Convert(stream).Pages); + var before = page.TextBlocks.Single(block => block.Text == "Before"); + var first = page.TextBlocks.Single(block => block.Text == "First"); + var second = page.TextBlocks.Single(block => block.Text == "Second"); + var expectedGap = 11f * ((1946f + 455f) / 2048f) * 1.15f + + 9f * (1705f / 2048f) - 11f * (1946f / 2048f); + var expectedLineHeight = 9f * ((1705f + 615f) / 2048f) * 1.15f; + + Assert.InRange(before.Y - first.Y, expectedGap - 0.01f, expectedGap + 0.01f); + Assert.InRange(first.Y - second.Y, expectedLineHeight - 0.01f, expectedLineHeight + 0.01f); + } + + [Theory] + [InlineData("body", "top", -2f)] + [InlineData("body", "center", 1f)] + [InlineData("body", "bottom", 4f)] + [InlineData("hdr", "top", -2f)] + [InlineData("hdr", "center", 1f)] + [InlineData("hdr", "bottom", 4f)] + [InlineData("ftr", "top", -2f)] + [InlineData("ftr", "center", 1f)] + [InlineData("ftr", "bottom", 4f)] + public void Convert_TableVerticalAlignment_ExcludesBorderInsets(string location, string alignment, float expectedOffset) + { + float ConvertCell(bool addBorders) + { + using var stream = CreateDocxWithCambriaTable("Short", location); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true)) + { + var part = location == "body" ? "word/document.xml" + : location == "hdr" ? "word/header1.xml" : "word/footer1.xml"; + var entry = archive.GetEntry(part)!; + XDocument xml; + using (var source = entry.Open()) + xml = XDocument.Load(source); + XNamespace w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + var table = xml.Descendants(w + "tbl").Single(); + var row = table.Elements(w + "tr").First(); + foreach (var tableRow in table.Elements(w + "tr")) + tableRow.AddFirst(new XElement(w + "trPr", new XElement(w + "trHeight", + new XAttribute(w + "val", 800), new XAttribute(w + "hRule", "exact")))); + var cell = row.Element(w + "tc")!; + cell.AddFirst(new XElement(w + "tcPr", new XElement(w + "vAlign", + new XAttribute(w + "val", alignment)))); + if (addBorders) + table.Element(w + "tblPr")!.Add(new XElement(w + "tblBorders", + new XElement(w + "top", new XAttribute(w + "val", "single"), new XAttribute(w + "sz", 32)), + new XElement(w + "insideH", new XAttribute(w + "val", "single"), new XAttribute(w + "sz", 64)))); + entry.Delete(); + AddEntry(archive, part, xml.ToString()); + } + stream.Position = 0; + return DocxToPdfConverter.Convert(stream).Pages[0].TextBlocks.Single(block => block.Text == "Short").Y; + } + var originalY = ConvertCell(false); + var borderedY = ConvertCell(true); + + Assert.InRange(borderedY - originalY, expectedOffset - 0.01f, expectedOffset + 0.01f); + } + + [Fact] + public void Convert_LargeCambriaFirstRun_AdvancesFollowingParagraphFromRenderedBaseline() + { + PdfDocument ConvertTitle(string markProperties) + { + using var stream = CreateDocxFromBodyXml( + $""" + + {markProperties} + Title + + After + """); + return DocxToPdfConverter.Convert(stream); + } + var inheritedMark = ConvertTitle(""); + var explicitMark = ConvertTitle(""""""); + var title = Assert.Single(inheritedMark.Pages[0].TextBlocks, block => block.Text == "Title"); + var explicitTitle = Assert.Single(explicitMark.Pages[0].TextBlocks, block => block.Text == "Title"); + var after = Assert.Single(inheritedMark.Pages[0].TextBlocks, block => block.Text == "After"); + var explicitAfter = Assert.Single(explicitMark.Pages[0].TextBlocks, block => block.Text == "After"); + + Assert.InRange(title.Y, 700.99f, 701.01f); + Assert.InRange(title.Y - explicitTitle.Y, -0.01f, 0.01f); + Assert.InRange(after.Y - explicitAfter.Y, -0.01f, 0.01f); + } + [Fact] public void Convert_WithBoldText_ProducesPdf() { @@ -507,6 +927,92 @@ public void Convert_DocxWithRootRelativeImageRelationship_RendersImage() Assert.Contains(doc.Pages, page => page.ImageBlocks.Count > 0); } + [Theory] + [InlineData(11f, 1f, false)] + [InlineData(20f, 1f, false)] + [InlineData(11f, 1.15f, false)] + [InlineData(20f, 1.15f, false)] + [InlineData(11f, 1.5f, false)] + [InlineData(20f, 1.5f, false)] + [InlineData(11f, 1.15f, true)] + [InlineData(20f, 1.5f, true)] + public void Convert_ImageOnlyParagraph_UsesItsOwnAscentForFollowingText( + float precedingFontSize, float lineSpacing, bool followedByTable) + { + using var stream = CreateDocxWithPngImage(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true)) + { + var entry = archive.GetEntry("word/document.xml")!; + XDocument document; + using (var source = entry.Open()) + document = XDocument.Load(source); + XNamespace w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + var paragraphs = document.Descendants(w + "p").ToList(); + foreach (var paragraph in paragraphs) + { + paragraph.AddFirst(new XElement(w + "pPr", + new XElement(w + "rPr", + new XElement(w + "rFonts", new XAttribute(w + "ascii", "Cambria")), + new XElement(w + "sz", new XAttribute(w + "val", 22))), + new XElement(w + "spacing", new XAttribute(w + "line", lineSpacing * 240), + new XAttribute(w + "lineRule", "auto"), new XAttribute(w + "after", 200)))); + } + paragraphs[0].Element(w + "r")!.AddFirst(new XElement(w + "rPr", + new XElement(w + "rFonts", new XAttribute(w + "ascii", "Cambria")), + new XElement(w + "sz", new XAttribute(w + "val", precedingFontSize * 2)))); + if (followedByTable) + paragraphs[2].ReplaceWith(XElement.Parse( + """ + + + + After image + + """)); + entry.Delete(); + AddEntry(archive, "word/document.xml", document.ToString()); + } + stream.Position = 0; + var page = Assert.Single(DocxToPdfConverter.Convert(stream).Pages); + var image = Assert.Single(page.ImageBlocks); + var followingText = page.TextBlocks.Single(block => block.Text == "After image"); + + var leading = 11f * ((1946f + 455f) / 2048f) * (lineSpacing - 1f); + var expectedGap = 11f * (1946f / 2048f) + leading + 10f; + Assert.InRange(image.Y - followingText.Y, expectedGap - 0.1f, expectedGap + 0.1f); + } + + [Theory] + [InlineData(0f, 24f)] + [InlineData(10f, 14f)] + [InlineData(30f, 0f)] + public void Convert_HardSectionBreak_CollapsesSpacingBeforeAcrossSections(float spacingAfter, float expectedBefore) + { + using var stream = CreateDocxFromBodyXml( + $""" + + + + + + + + + First section + + + + Next section + + """); + var document = DocxToPdfConverter.Convert(stream); + Assert.Equal(2, document.Pages.Count); + var followingText = Assert.Single(document.Pages[1].TextBlocks); + var expectedY = 792f - 72f - expectedBefore - 11f * (1946f / 2048f); + + Assert.InRange(followingText.Y, expectedY - 0.1f, expectedY + 0.1f); + } + [Fact] public void Convert_InlineImageThatDoesNotFit_MovesToNextPageWithoutClipping() { @@ -1356,6 +1862,64 @@ private static MemoryStream CreateDocxWithTable(params string[][] rows) return ms; } + private static MemoryStream CreateDocxWithCambriaTable(string text, string location) + { + var table = $""" + + + + + + + + + + + + {EscapeXml(text)} + + + + Marker + + + """; + var part = location == "hdr" ? "header" : "footer"; + var stream = CreateDocxFromBodyXml( + location == "body" ? table : $""" + Body + + + + + """); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true)) + { + AddEntry(archive, "word/styles.xml", + """ + + + + + + + """); + if (location != "body") + { + AddEntry(archive, "word/_rels/document.xml.rels", + $""" + + + + """); + AddEntry(archive, $"word/{part}1.xml", + $"""{table}"""); + } + } + stream.Position = 0; + return stream; + } + private static MemoryStream CreateDocxWithBold(string boldText) { var ms = new MemoryStream(); diff --git a/tests/MiniPdf.Tests/SystemFontTheoryAttribute.cs b/tests/MiniPdf.Tests/SystemFontTheoryAttribute.cs new file mode 100644 index 00000000..b7dcbce6 --- /dev/null +++ b/tests/MiniPdf.Tests/SystemFontTheoryAttribute.cs @@ -0,0 +1,29 @@ +namespace MiniSoftware.Tests; + +public sealed class SystemFontTheoryAttribute : TheoryAttribute +{ + public SystemFontTheoryAttribute(params string[] fontNames) + { + foreach (var fontName in fontNames) + { + if (!PdfWriter.TryMeasurePreferredFontWidth(fontName, "A", 11f, + bold: false, italic: false, charSpacing: 0f, out _)) + { + Skip = $"Required system font is unavailable: {fontName}"; + return; + } + } + } +} + +public class SystemFontTheoryAttributeTests +{ + [Fact] + public void MissingFont_IsReportedAsSkipped() + { + const string missingFont = "MiniPdf Missing Test Font"; + var attribute = new SystemFontTheoryAttribute(missingFont); + + Assert.Equal($"Required system font is unavailable: {missingFont}", attribute.Skip); + } +}