Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/MiniPdf/DocxReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@
var xpath = dataBinding.Attribute(W + "xpath")?.Value;
var prefixMappings = dataBinding.Attribute(W + "prefixMappings")?.Value ?? "";
if (string.IsNullOrEmpty(storeItemID) || string.IsNullOrEmpty(xpath)) return null;
if (!ctx.XmlStores.TryGetValue(storeItemID, out var doc) || doc.Root == null) return null;

Check warning on line 132 in src/MiniPdf/DocxReader.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'key' in 'bool Dictionary<string, XDocument>.TryGetValue(string key, out XDocument value)'.

var nsMgr = new System.Xml.XmlNamespaceManager(new System.Xml.NameTable());
foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches(
Expand Down Expand Up @@ -828,7 +828,7 @@
if (string.IsNullOrWhiteSpace(style))
return values;

foreach (var declaration in style.Split(';', StringSplitOptions.RemoveEmptyEntries))

Check warning on line 831 in src/MiniPdf/DocxReader.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 's' in 'string[] NetFxPolyfills.Split(string s, char separator, StringSplitOptions options)'.
{
var separator = declaration.IndexOf(':');
if (separator <= 0)
Expand Down Expand Up @@ -1767,7 +1767,10 @@
var color = !string.IsNullOrEmpty(colorHex) && colorHex != "auto"
? PdfColor.FromHex(colorHex)
: new PdfColor(0, 0, 0);
return new DocxBorderEdge(Math.Max(0.5f, width), color);
var space = float.TryParse(el.Attribute(W + "space")?.Value, out var parsedSpace)
? parsedSpace
: 0f;
Comment on lines +1770 to +1772
return new DocxBorderEdge(Math.Max(0.5f, width), color, space);
}

/// <summary>Like <see cref="ReadBorderEdge"/> but returns
Expand Down Expand Up @@ -5083,7 +5086,8 @@
/// explicit OOXML "nil"/"none" border that suppresses inheritance.</summary>
internal sealed record DocxBorderEdge(
float Width, // in points; 0 means explicit "nil" (suppress inherited border)
PdfColor Color
PdfColor Color,
float Space = 0
)
{
public static readonly DocxBorderEdge Nil = new(0f, new PdfColor(0, 0, 0));
Expand Down
82 changes: 57 additions & 25 deletions src/MiniPdf/DocxToPdfConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1074,11 +1074,15 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph,
|| (isVeryFirstParagraph && paragraph.SpacingBeforeExplicit)))
{
var extraBefore = options.CollapseParagraphSpacing
&& !(paragraph.Runs.Count == 0 && paragraph.Images.Count == 0 && paragraph.Borders != null)
? spacingBefore - state.LastSpacingAfter
: spacingBefore;
if (extraBefore > 0)
{
state.AdvanceY(extraBefore);
if (isVeryFirstParagraph)
state.CurrentY -= extraBefore;
else
state.AdvanceY(extraBefore);
}
}

Expand Down Expand Up @@ -1135,7 +1139,8 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph,
if (baselineToTopOffset < 0) baselineToTopOffset = 0;
state.LastParagraphStartY = state.CurrentY + baselineToTopOffset;

var totalEmptyAdvance = lineHeight;
var emptyBorderWidth = paragraph.Borders?.Bottom?.Width ?? 0f;
var totalEmptyAdvance = Math.Max(0f, lineHeight - emptyBorderWidth);
var spacingAfterEmpty = paragraph.SpacingAfter >= 0 ? paragraph.SpacingAfter : 0f;
totalEmptyAdvance += spacingAfterEmpty;

Expand All @@ -1153,6 +1158,7 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph,
: fontSize * GetTopOfPageAscentRatio(paraFontName, ResolveLineSpacingMul(paragraph, options));
state.AdvanceY(emptyAscentOffset);
}
RenderParagraphBorders(state, paragraph, state.CurrentY, state.CurrentY, isEmptyParagraph: true);
state.AdvanceY(totalEmptyAdvance);
// If the empty paragraph pushed past the bottom margin, accumulate
// the overflow as pending vertical space for the next page so that
Expand Down Expand Up @@ -1538,7 +1544,10 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph,
deferredImagesTotalHeight += imgH + 1f;
continue;
}
RenderImage(state, image, paragraph.Alignment);
var inlineImageOffset = paragraph.Runs.Count == 0 && !image.IsAnchor
? fontSize * GetTopOfPageAscentRatio(paraFontName, ResolveLineSpacingMul(paragraph, options))
: 0f;
RenderImage(state, image, paragraph.Alignment, inlineImageOffset);
}

// Proactive page break: if text + deferred wrapTopAndBottom images
Expand Down Expand Up @@ -1901,17 +1910,19 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph,
if (paragraph.Alignment == "both" && renderMaxWidth == null)
renderMaxWidth = lineW;

var renderY = state.CurrentY + GetHeadingBaselineOffset(paragraph, runFontName, runBold);

if (runShading != null)
{
var shadingWidth = textWidth + wordSpacing * line.Count(c => c == ' ');
if (renderMaxWidth.HasValue)
shadingWidth = Math.Min(shadingWidth, renderMaxWidth.Value);
var padX = Math.Max(0.7f, runFontSize * 0.08f);
state.CurrentPage!.AddRectangle(renderX - padX, state.CurrentY - runFontSize * 0.24f,
state.CurrentPage!.AddRectangle(renderX - padX, renderY - runFontSize * 0.24f,
shadingWidth + padX * 2, runFontSize * 1.18f, runShading);
}

state.CurrentPage!.AddText(line, renderX, state.CurrentY, runFontSize, runColor, maxWidth: renderMaxWidth, bold: runBold, italic: runItalic, underline: runUnderline, charSpacing: runCharSpacing, wordSpacing: wordSpacing, preferredFontName: runFontName);
state.CurrentPage!.AddText(line, renderX, renderY, runFontSize, runColor, maxWidth: renderMaxWidth, bold: runBold, italic: runItalic, underline: runUnderline, charSpacing: runCharSpacing, wordSpacing: wordSpacing, preferredFontName: runFontName);
state.AdvanceY(lineHeight);
}
s_overrideWidths = null;
Expand All @@ -1924,24 +1935,7 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph,
// 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.)
// Render paragraph borders
if (paragraph.Borders != null && state.CurrentPage != null)
{
var bdr = paragraph.Borders;
var paraLeft = options.MarginLeft + paragraph.IndentLeft;
var paraRight = options.MarginLeft + state.UsableWidth - paragraph.IndentRight;
var paraTop = paragraphStartY;
var paraBottom = state.CurrentY;

if (bdr.Top != null)
state.CurrentPage.AddLine(paraLeft, paraTop, paraRight, paraTop, bdr.Top.Color, bdr.Top.Width);
if (bdr.Bottom != null)
state.CurrentPage.AddLine(paraLeft, paraBottom, paraRight, paraBottom, bdr.Bottom.Color, bdr.Bottom.Width);
if (bdr.Left != null)
state.CurrentPage.AddLine(paraLeft, paraTop, paraLeft, paraBottom, bdr.Left.Color, bdr.Left.Width);
if (bdr.Right != null)
state.CurrentPage.AddLine(paraRight, paraTop, paraRight, paraBottom, bdr.Right.Color, bdr.Right.Width);
}
RenderParagraphBorders(state, paragraph, paragraphStartY, state.CurrentY);

// Render text box border (outline rectangle around text box content)
if (paragraph.TextBoxBorder is { } tb && state.CurrentPage != null)
Expand Down Expand Up @@ -2003,6 +1997,32 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph,

}

private static void RenderParagraphBorders(RenderState state, DocxParagraph paragraph, float paragraphTop, float paragraphBottom,
bool isEmptyParagraph = false)
{
if (paragraph.Borders == null || state.CurrentPage == null)
return;

var borders = paragraph.Borders;
var paragraphLeft = state.Options.MarginLeft + paragraph.IndentLeft;
var paragraphRight = state.Options.MarginLeft + state.UsableWidth - paragraph.IndentRight;
var topSpace = isEmptyParagraph ? 0f : borders.Top?.Space ?? 0f;
var bottomSpace = isEmptyParagraph ? 0f : borders.Bottom?.Space ?? 0f;

if (borders.Top != null)
state.CurrentPage.AddLine(paragraphLeft - borders.Top.Space, paragraphTop + topSpace,
paragraphRight + borders.Top.Space, paragraphTop + topSpace, borders.Top.Color, borders.Top.Width);
if (borders.Bottom != null)
state.CurrentPage.AddLine(paragraphLeft - borders.Bottom.Space, paragraphBottom - bottomSpace,
paragraphRight + borders.Bottom.Space, paragraphBottom - bottomSpace, borders.Bottom.Color, borders.Bottom.Width);
if (borders.Left != null)
state.CurrentPage.AddLine(paragraphLeft - borders.Left.Space, paragraphTop + borders.Left.Space,
paragraphLeft - borders.Left.Space, paragraphBottom - borders.Left.Space, borders.Left.Color, borders.Left.Width);
Comment on lines +2018 to +2020
if (borders.Right != null)
state.CurrentPage.AddLine(paragraphRight + borders.Right.Space, paragraphTop + borders.Right.Space,
paragraphRight + borders.Right.Space, paragraphBottom - borders.Right.Space, borders.Right.Color, borders.Right.Width);
Comment on lines +2021 to +2023
}

/// <summary>
/// Renders floating text boxes (wrapNone) at their absolute page positions.
/// These text boxes do not affect the normal document flow.
Expand Down Expand Up @@ -3304,7 +3324,7 @@ 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")
private static void RenderImage(RenderState state, DocxImage image, string alignment = "left", float inlineVerticalOffset = 0)
{
const float emuPerPoint = 914400f / 72f;

Expand Down Expand Up @@ -3392,7 +3412,10 @@ private static void RenderImage(RenderState state, DocxImage image, string align
x = state.Options.MarginLeft + (state.UsableWidth - width) / 2;
else if (alignment == "right")
x = state.Options.MarginLeft + state.UsableWidth - width;
var y = state.CurrentY - height;
var y = state.CurrentY - height + inlineVerticalOffset;
var maximumY = state.Options.PageHeight - state.Options.MarginTop - height;
if (y > maximumY)
y = maximumY;

state.CurrentPage!.AddImage(image.Data, format, x, y, width, height);
state.AdvanceY(height + 1f); // 1pt gap after image
Expand Down Expand Up @@ -4775,6 +4798,15 @@ private static bool IsLargeCjkTitle(DocxParagraph paragraph)
return false;
}

private static float GetHeadingBaselineOffset(DocxParagraph paragraph, string? fontName, bool bold)
{
if (!bold || string.IsNullOrEmpty(fontName)
|| !fontName.Contains("Calibri", StringComparison.OrdinalIgnoreCase))
return 0f;

return paragraph.StyleId is "Heading1" or "Heading2" ? 1.6f : 0f;
}

private static bool ContainsCjk(string text)
{
foreach (var ch in text)
Expand Down
15 changes: 12 additions & 3 deletions src/MiniPdf/PdfWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,7 @@ internal void Write(PdfDocument document)
// Exclude common Latin fonts (Calibri, Arial, etc.) that are adequately
// substituted by the built-in Helvetica — keeping those in the WinAnsi
// (direct ASCII) encoding path so text content remains inspectable.
if (!blockNeedsUnicode && !string.IsNullOrWhiteSpace(block.PreferredFontName)
&& !_latinFontSubstitutes.Contains(block.PreferredFontName!)
if (!blockNeedsUnicode && ShouldEmbedPreferredFont(block)
&& FindSystemFontByPreferredName(block.PreferredFontName!) != null)
blockNeedsUnicode = true;
if (blockNeedsUnicode)
Expand Down Expand Up @@ -132,7 +131,7 @@ internal void Write(PdfDocument document)
foreach (var block in page.TextBlocks)
{
if (string.IsNullOrWhiteSpace(block.PreferredFontName)) continue;
if (_latinFontSubstitutes.Contains(block.PreferredFontName!)
if (!ShouldEmbedPreferredFont(block)
&& !cpsByPreferredFont.ContainsKey(block.PreferredFontName!)) continue;
bool allWinAnsi = true;
foreach (var ch in block.Text)
Expand Down Expand Up @@ -2478,6 +2477,15 @@ private static List<string> FindSystemFontCandidates()
"Segoe UI", "Segoe UI Light", "Segoe UI Semibold",
};

private static bool ShouldEmbedPreferredFont(PdfTextBlock block)
{
if (string.IsNullOrWhiteSpace(block.PreferredFontName))
return false;

return !_latinFontSubstitutes.Contains(block.PreferredFontName)
|| (block.Bold && string.Equals(block.PreferredFontName, "Calibri", StringComparison.OrdinalIgnoreCase));
}

/// <summary>
/// Lazily-built cache: normalized font name → system font file path.
/// Built on first access by scanning the system fonts directory and reading
Expand Down Expand Up @@ -2981,6 +2989,7 @@ private static (string family, string fullName) ReadFontNames(byte[] ttf, int ba
{
["Times New Roman"] = ["times.ttf"],
["Calibri"] = ["calibri.ttf"],
["Calibri Bold"] = ["calibrib.ttf"],
["Cambria"] = ["cambria.ttc"],
["Courier New"] = ["cour.ttf"],
["Verdana"] = ["verdana.ttf"],
Expand Down
16 changes: 8 additions & 8 deletions tests/MiniPdf.Benchmark/reports_docx/comparison_report.json
Original file line number Diff line number Diff line change
Expand Up @@ -987,20 +987,20 @@
"name": "docx_classic34_employee_directory_with_photo",
"minipdf_exists": true,
"reference_exists": true,
"minipdf_size": 422597,
"minipdf_size": 975599,
"reference_size": 99625,
"pdf_valid": true,
"minipdf_pages": 1,
"minipdf_pages": 2,
"reference_pages": 2,
"text_similarity": 1.0,
"flat_text_similarity": 1.0,
"word_text_similarity": 1.0,
"text_diff": "--- minipdf/docx_classic34_employee_directory_with_photo.pdf\n+++ reference/docx_classic34_employee_directory_with_photo.pdf\n@@ -10,4 +10,5 @@\n Carol Williams\n\n UX Designer\n\n Email: carol@company.com\n\n+---PAGE---\n\n Department: Design",
"text_diff": "(identical)",
"visual_scores": [
0.9758,
0.0
0.995,
0.9987
],
"visual_avg": 0.4879,
"visual_avg": 0.9969,
"diff_images": [
{
"page": 1,
Expand All @@ -1009,11 +1009,11 @@
},
{
"page": 2,
"minipdf_img": null,
"minipdf_img": "docx_classic34_employee_directory_with_photo_p2_minipdf.png",
"reference_img": "docx_classic34_employee_directory_with_photo_p2_reference.png"
}
],
"overall_score": 0.6952
"overall_score": 0.9988
},
{
"name": "docx_classic34_paragraph_borders",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading