Skip to content

Commit 51e2655

Browse files
committed
Fix XLSX Indic script rendering (#87)
1 parent cb4926d commit 51e2655

8 files changed

Lines changed: 154 additions & 46 deletions

File tree

src/MiniPdf/ExcelToPdfConverter.cs

Lines changed: 124 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
using System.Drawing;
2+
using System.Drawing.Drawing2D;
3+
using System.Drawing.Imaging;
4+
using System.Drawing.Text;
15
using System.Globalization;
26

37
namespace MiniSoftware;
@@ -1525,7 +1529,7 @@ float RenderPrintTitleRows()
15251529
var tw = (float)MeasureHelveticaWidth(titleCellLines[i][lineIdx], cellFs, bold: titleIsBold);
15261530
textX = titleContentX + (titleContentWidth - tw) / 2f;
15271531
}
1528-
currentPage!.AddText(titleCellLines[i][lineIdx], textX, cellY, cellFs, cell?.Color,
1532+
AddExcelText(currentPage!, titleCellLines[i][lineIdx], textX, cellY, cellFs, cell?.Color,
15291533
maxWidth: lineMaxWidth,
15301534
bold: titleIsBold,
15311535
underline: cell?.Underline ?? false,
@@ -1945,7 +1949,7 @@ float RenderPrintTitleRows()
19451949
var tw = (float)MeasureHelveticaWidth(lines[lineIdx], options.FontSize, bold: mpIsBold);
19461950
textX = mpContentX + (mpContentWidth - tw) / 2f;
19471951
}
1948-
currentPage!.AddText(lines[lineIdx], textX, cellY, options.FontSize, color,
1952+
AddExcelText(currentPage!, lines[lineIdx], textX, cellY, options.FontSize, color,
19491953
bold: mpIsBold,
19501954
underline: cell?.Underline ?? false,
19511955
strikethrough: cell?.Strikethrough ?? false,
@@ -2176,7 +2180,7 @@ float RenderPrintTitleRows()
21762180
// overlap with the normal text.
21772181
var boldWidth = (float)MeasureFontWidth(boldPart, cellFontSize, bold: true, cell?.FontName);
21782182

2179-
currentPage!.AddText(boldPart, textX, cellY, cellFontSize, color,
2183+
AddExcelText(currentPage!, boldPart, textX, cellY, cellFontSize, color,
21802184
maxWidth: boldWidth,
21812185
bold: true,
21822186
underline: cell?.Underline ?? false,
@@ -2187,7 +2191,7 @@ float RenderPrintTitleRows()
21872191
{
21882192
var normalX = textX + boldWidth + spaceGap;
21892193
var normalMax = lineMaxWidth > 0 ? lineMaxWidth - boldWidth - spaceGap : 0f;
2190-
currentPage!.AddText(normalPart, normalX, cellY, cellFontSize, color,
2194+
AddExcelText(currentPage!, normalPart, normalX, cellY, cellFontSize, color,
21912195
maxWidth: normalMax > 0 ? normalMax : 0f,
21922196
bold: false,
21932197
underline: cell?.Underline ?? false,
@@ -2198,7 +2202,7 @@ float RenderPrintTitleRows()
21982202
}
21992203
else
22002204
{
2201-
currentPage!.AddText(lines[lineIdx], textX, cellY, cellFontSize, color,
2205+
AddExcelText(currentPage!, lines[lineIdx], textX, cellY, cellFontSize, color,
22022206
maxWidth: lineMaxWidth,
22032207
bold: isBold,
22042208
underline: cell?.Underline ?? false,
@@ -3693,6 +3697,121 @@ private static string[] WrapCellText(string text, float widthPts, float fontSize
36933697
return lines.ToArray();
36943698
}
36953699

3700+
private static void AddExcelText(PdfPage page, string text, float x, float y, float fontSize, PdfColor? color = null, (float, float, float, float)? clipRect = null, float? maxWidth = null, bool bold = false, bool italic = false, bool underline = false, float charSpacing = 0, float wordSpacing = 0, string? preferredFontName = null, float? underlineWidth = null, bool strikethrough = false)
3701+
{
3702+
if (!ContainsIndicText(text) || !TryRenderComplexScriptText(text, fontSize, color ?? PdfColor.Black, bold, italic, preferredFontName, maxWidth, out var png, out var widthPt, out var heightPt, out var baselineFromBottomPt))
3703+
{
3704+
page.AddText(text, x, y, fontSize, color, clipRect, maxWidth, bold, italic, underline, charSpacing, wordSpacing, preferredFontName, underlineWidth, strikethrough);
3705+
return;
3706+
}
3707+
3708+
var imageY = y - baselineFromBottomPt;
3709+
page.AddImage(png, "png", x, imageY, widthPt, heightPt);
3710+
page.AddText(text, x, y, fontSize, color, clipRect, maxWidth, bold, italic, underline: false, charSpacing, wordSpacing, preferredFontName, underlineWidth: null, strikethrough: false, hidden: true);
3711+
}
3712+
3713+
private static bool ContainsIndicText(string text)
3714+
{
3715+
foreach (var ch in text)
3716+
{
3717+
if (ch is >= '\u0900' and <= '\u0D7F')
3718+
return true;
3719+
}
3720+
return false;
3721+
}
3722+
3723+
private static bool TryRenderComplexScriptText(string text, float fontSize, PdfColor pdfColor, bool bold, bool italic, string? preferredFontName, float? maxWidth, out byte[] png, out float widthPt, out float heightPt, out float baselineFromBottomPt)
3724+
{
3725+
png = [];
3726+
widthPt = 0;
3727+
heightPt = 0;
3728+
baselineFromBottomPt = 0;
3729+
3730+
try
3731+
{
3732+
const float dpiScale = 3f;
3733+
var fontStyle = (bold ? FontStyle.Bold : FontStyle.Regular) | (italic ? FontStyle.Italic : FontStyle.Regular);
3734+
using var font = CreateComplexScriptFont(preferredFontName, fontSize, fontStyle);
3735+
using var probe = new Bitmap(1, 1, PixelFormat.Format32bppArgb);
3736+
probe.SetResolution(72f * dpiScale, 72f * dpiScale);
3737+
using var probeGraphics = Graphics.FromImage(probe);
3738+
probeGraphics.TextRenderingHint = TextRenderingHint.AntiAliasGridFit;
3739+
var format = StringFormat.GenericTypographic;
3740+
format.FormatFlags |= StringFormatFlags.MeasureTrailingSpaces | StringFormatFlags.NoClip;
3741+
var measured = probeGraphics.MeasureString(text, font, int.MaxValue, format);
3742+
3743+
var naturalWidthPt = Math.Max(1f, measured.Width / dpiScale);
3744+
widthPt = Math.Max(1f, Math.Min(maxWidth ?? naturalWidthPt, naturalWidthPt));
3745+
3746+
var family = font.FontFamily;
3747+
var em = family.GetEmHeight(font.Style);
3748+
var ascent = family.GetCellAscent(font.Style);
3749+
var lineSpacing = family.GetLineSpacing(font.Style);
3750+
var baselineFromTopPt = fontSize * ascent / em;
3751+
heightPt = Math.Max(fontSize * 1.35f, fontSize * lineSpacing / em);
3752+
baselineFromBottomPt = Math.Max(0.1f, heightPt - baselineFromTopPt);
3753+
3754+
var widthPx = Math.Max(1, (int)Math.Ceiling(widthPt * dpiScale));
3755+
var heightPx = Math.Max(1, (int)Math.Ceiling(heightPt * dpiScale));
3756+
using var bmp = new Bitmap(widthPx, heightPx, PixelFormat.Format32bppArgb);
3757+
bmp.SetResolution(72f * dpiScale, 72f * dpiScale);
3758+
using (var graphics = Graphics.FromImage(bmp))
3759+
{
3760+
graphics.Clear(System.Drawing.Color.Transparent);
3761+
graphics.SmoothingMode = SmoothingMode.AntiAlias;
3762+
graphics.TextRenderingHint = TextRenderingHint.AntiAliasGridFit;
3763+
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
3764+
using var brush = new SolidBrush(ToDrawingColor(pdfColor));
3765+
graphics.SetClip(new RectangleF(0, 0, widthPx, heightPx));
3766+
graphics.DrawString(text, font, brush, 0, 0, format);
3767+
}
3768+
3769+
using var ms = new MemoryStream();
3770+
bmp.Save(ms, ImageFormat.Png);
3771+
png = ms.ToArray();
3772+
return png.Length > 0;
3773+
}
3774+
catch
3775+
{
3776+
return false;
3777+
}
3778+
}
3779+
3780+
private static Font CreateComplexScriptFont(string? preferredFontName, float fontSize, FontStyle style)
3781+
{
3782+
foreach (var name in ComplexScriptFontCandidates(preferredFontName))
3783+
{
3784+
try
3785+
{
3786+
if (FontFamily.Families.Any(f => string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase)))
3787+
return new Font(name, fontSize, style, GraphicsUnit.Point);
3788+
}
3789+
catch
3790+
{
3791+
}
3792+
}
3793+
3794+
return new Font(FontFamily.GenericSansSerif, fontSize, style, GraphicsUnit.Point);
3795+
}
3796+
3797+
private static IEnumerable<string> ComplexScriptFontCandidates(string? preferredFontName)
3798+
{
3799+
if (!string.IsNullOrWhiteSpace(preferredFontName))
3800+
yield return preferredFontName!;
3801+
yield return "Nirmala UI";
3802+
yield return "Mangal";
3803+
yield return "Latha";
3804+
yield return "Vrinda";
3805+
yield return "Gautami";
3806+
yield return "Shruti";
3807+
}
3808+
3809+
private static System.Drawing.Color ToDrawingColor(PdfColor color)
3810+
{
3811+
static int Component(float value) => Math.Max(0, Math.Min(255, (int)Math.Round(value * 255f)));
3812+
return System.Drawing.Color.FromArgb(255, Component(color.R), Component(color.G), Component(color.B));
3813+
}
3814+
36963815
/// <summary>
36973816
/// Checks if a sheet name is a generic default like Sheet1, Sheet2, etc.
36983817
/// </summary>

src/MiniPdf/PdfPage.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,10 +160,11 @@ internal PdfPage(float width, float height)
160160
/// <param name="preferredFontName">Optional preferred Unicode font family hint.</param>
161161
/// <param name="underlineWidth">Optional explicit underline width override in points.</param>
162162
/// <param name="strikethrough">Whether to draw a line through the text.</param>
163+
/// <param name="hidden">Whether to keep text extractable without visibly painting it.</param>
163164
/// <returns>The current page for chaining.</returns>
164-
public PdfPage AddText(string text, float x, float y, float fontSize = 12, PdfColor? color = null, (float, float, float, float)? clipRect = null, float? maxWidth = null, bool bold = false, bool italic = false, bool underline = false, float charSpacing = 0, float wordSpacing = 0, string? preferredFontName = null, float? underlineWidth = null, bool strikethrough = false)
165+
public PdfPage AddText(string text, float x, float y, float fontSize = 12, PdfColor? color = null, (float, float, float, float)? clipRect = null, float? maxWidth = null, bool bold = false, bool italic = false, bool underline = false, float charSpacing = 0, float wordSpacing = 0, string? preferredFontName = null, float? underlineWidth = null, bool strikethrough = false, bool hidden = false)
165166
{
166-
_textBlocks.Add(new PdfTextBlock(text, x, y, fontSize, color, clipRect, maxWidth, bold, italic, underline, charSpacing, wordSpacing, preferredFontName, underlineWidth, strikethrough));
167+
_textBlocks.Add(new PdfTextBlock(text, x, y, fontSize, color, clipRect, maxWidth, bold, italic, underline, charSpacing, wordSpacing, preferredFontName, underlineWidth, strikethrough, hidden));
167168
return this;
168169
}
169170

src/MiniPdf/PdfTextBlock.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ internal sealed class PdfTextBlock
6767
/// </summary>
6868
public bool Strikethrough { get; }
6969

70+
/// <summary>
71+
/// Whether to keep this text for extraction without painting visible glyphs.
72+
/// </summary>
73+
public bool Hidden { get; }
74+
7075
/// <summary>
7176
/// Character spacing in points (PDF Tc operator). 0 means default.
7277
/// </summary>
@@ -91,7 +96,7 @@ internal sealed class PdfTextBlock
9196
/// </summary>
9297
public float? UnderlineWidth { get; }
9398

94-
internal PdfTextBlock(string text, float x, float y, float fontSize, PdfColor? color = null, (float, float, float, float)? clipRect = null, float? maxWidth = null, bool bold = false, bool italic = false, bool underline = false, float charSpacing = 0, float wordSpacing = 0, string? preferredFontName = null, float? underlineWidth = null, bool strikethrough = false)
99+
internal PdfTextBlock(string text, float x, float y, float fontSize, PdfColor? color = null, (float, float, float, float)? clipRect = null, float? maxWidth = null, bool bold = false, bool italic = false, bool underline = false, float charSpacing = 0, float wordSpacing = 0, string? preferredFontName = null, float? underlineWidth = null, bool strikethrough = false, bool hidden = false)
95100
{
96101
Text = text;
97102
X = x;
@@ -108,5 +113,6 @@ internal PdfTextBlock(string text, float x, float y, float fontSize, PdfColor? c
108113
WordSpacing = wordSpacing;
109114
PreferredFontName = preferredFontName;
110115
UnderlineWidth = underlineWidth;
116+
Hidden = hidden;
111117
}
112118
}

src/MiniPdf/PdfWriter.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,6 +1044,8 @@ private static string BuildContentStream(PdfPage page, bool hasUnicodeFont, Dict
10441044
sb.Append("BT\n");
10451045
sb.Append(colorCmd);
10461046
sb.Append($"/{fontName} {fontSize} Tf\n");
1047+
if (block.Hidden)
1048+
sb.Append("3 Tr\n");
10471049
// For justified text, the caller's wordSpacing was derived from a
10481050
// wrap-time width estimate. Recompute it using the actual measured
10491051
// natural width so the rendered line fills (not exceeds) MaxWidth.
@@ -1097,6 +1099,8 @@ private static string BuildContentStream(PdfPage page, bool hasUnicodeFont, Dict
10971099
}
10981100
sb.Append($"{x} {y} Td\n");
10991101
sb.Append($"({escapedText}) Tj\n");
1102+
if (block.Hidden)
1103+
sb.Append("0 Tr\n");
11001104
sb.Append("ET\n");
11011105
}
11021106
else
@@ -1123,6 +1127,8 @@ private static string BuildContentStream(PdfPage page, bool hasUnicodeFont, Dict
11231127
sb.Append("\n");
11241128
sb.Append("2 Tr\n"); // rendering mode: fill + stroke
11251129
}
1130+
if (block.Hidden)
1131+
sb.Append("3 Tr\n");
11261132
// Determine the block's preferred font slot for font-aware
11271133
// width computation and Tz scaling.
11281134
var blockPrefSlot = -1;
@@ -1318,7 +1324,7 @@ private static string BuildContentStream(PdfPage page, bool hasUnicodeFont, Dict
13181324
}
13191325
}
13201326

1321-
if (block.Bold)
1327+
if (block.Bold || block.Hidden)
13221328
sb.Append("0 Tr\n"); // reset rendering mode
13231329
sb.Append("ET\n");
13241330
}
@@ -1328,7 +1334,7 @@ private static string BuildContentStream(PdfPage page, bool hasUnicodeFont, Dict
13281334
sb.Append("Q\n");
13291335

13301336
// Render underline as a line below the text
1331-
if (block.Underline)
1337+
if (!block.Hidden && block.Underline)
13321338
{
13331339
var textWidth = block.UnderlineWidth ?? MeasureTextWidth(block.Text, block.FontSize, block.CharSpacing, bold: block.Bold);
13341340
if (!block.UnderlineWidth.HasValue && block.MaxWidth.HasValue && textWidth > block.MaxWidth.Value)
@@ -1346,7 +1352,7 @@ private static string BuildContentStream(PdfPage page, bool hasUnicodeFont, Dict
13461352
sb.Append($"{x1} {y1} m {x2} {y1} l S\n");
13471353
}
13481354

1349-
if (block.Strikethrough)
1355+
if (!block.Hidden && block.Strikethrough)
13501356
{
13511357
var textWidth = MeasureTextWidth(block.Text, block.FontSize, block.CharSpacing, bold: block.Bold);
13521358
if (block.MaxWidth.HasValue && textWidth > block.MaxWidth.Value)

tests/MiniPdf.Benchmark/reports/comparison_report.json

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10058,7 +10058,7 @@
1005810058
"name": "classic164_indic_scripts",
1005910059
"minipdf_exists": true,
1006010060
"reference_exists": true,
10061-
"minipdf_size": 71613,
10061+
"minipdf_size": 77409,
1006210062
"reference_size": 53352,
1006310063
"pdf_valid": true,
1006410064
"minipdf_pages": 1,
@@ -10068,36 +10068,17 @@
1006810068
"word_text_similarity": 1.0,
1006910069
"text_diff": "(identical)",
1007010070
"visual_scores": [
10071-
0.9903
10071+
0.9947
1007210072
],
10073-
"visual_avg": 0.9903,
10073+
"visual_avg": 0.9947,
1007410074
"diff_images": [
1007510075
{
1007610076
"page": 1,
1007710077
"minipdf_img": "classic164_indic_scripts_p1_minipdf.png",
10078-
"reference_img": "classic164_indic_scripts_p1_reference.png",
10079-
"heatmap_img": "classic164_indic_scripts_p1_heatmap.png",
10080-
"heatmap_metrics": {
10081-
"size_px": [
10082-
1241,
10083-
1650
10084-
],
10085-
"difference_bbox_px": [
10086-
113,
10087-
147,
10088-
427,
10089-
335
10090-
],
10091-
"changed_pixels": 10718,
10092-
"changed_fraction": 0.005234,
10093-
"mean_abs_rgb": 0.826,
10094-
"rmse_rgb": 12.8411,
10095-
"threshold": 12,
10096-
"gain": 5.0
10097-
}
10078+
"reference_img": "classic164_indic_scripts_p1_reference.png"
1009810079
}
1009910080
],
10100-
"overall_score": 0.9961
10081+
"overall_score": 0.9979
1010110082
},
1010210083
{
1010310084
"name": "classic165_southeast_asian",

tests/MiniPdf.Benchmark/reports/comparison_report.md

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# MiniPdf vs Reference PDF Comparison Report
22

3-
Generated: 2026-07-19T19:49:12.102818
3+
Generated: 2026-07-23T09:55:09.574001
44

55
## Summary
66

@@ -169,7 +169,7 @@ Generated: 2026-07-19T19:49:12.102818
169169
| 161 | 🟢 classic161_box_drawing || 0.9976 | 0.9867 | 1/1 | **0.9937** |
170170
| 162 | 🟢 classic162_cjk_emoji_styled || 1.0 | 0.9872 | 1/1 | **0.9949** |
171171
| 163 | 🟢 classic163_cyrillic_alphabets || 0.9519 | 0.9849 | 1/1 | **0.9747** |
172-
| 164 | 🟢 classic164_indic_scripts || 1.0 | 0.9903 | 1/1 | **0.9961** |
172+
| 164 | 🟢 classic164_indic_scripts || 1.0 | 0.9947 | 1/1 | **0.9979** |
173173
| 165 | 🟢 classic165_southeast_asian || 0.9672 | 0.8185 | 1/1 | **0.9143** |
174174
| 166 | 🟢 classic166_emoji_progress || 1.0 | 0.9761 | 1/1 | **0.9904** |
175175
| 167 | 🟢 classic167_musical_symbols || 1.0 | 0.9843 | 1/1 | **0.9937** |
@@ -1561,11 +1561,6 @@ Blue areas are below the configured difference threshold; red areas have stronge
15611561
<td><img src="images/classic163_cyrillic_alphabets_p1_heatmap.png" width="760" alt="classic163_cyrillic_alphabets page 1 difference heatmap"></td>
15621562
<td>changed: 33058 px (1.61%)<br>bbox: [114, 147, 908, 336]<br>mean abs RGB: 2.3605<br>RMSE RGB: 21.0512<br>threshold: 12, gain: 5.0</td>
15631563
</tr>
1564-
<tr>
1565-
<td><b>classic164_indic_scripts</b><br>Page 1</td>
1566-
<td><img src="images/classic164_indic_scripts_p1_heatmap.png" width="760" alt="classic164_indic_scripts page 1 difference heatmap"></td>
1567-
<td>changed: 10718 px (0.52%)<br>bbox: [113, 147, 427, 335]<br>mean abs RGB: 0.826<br>RMSE RGB: 12.8411<br>threshold: 12, gain: 5.0</td>
1568-
</tr>
15691564
<tr>
15701565
<td><b>classic165_southeast_asian</b><br>Page 1</td>
15711566
<td><img src="images/classic165_southeast_asian_p1_heatmap.png" width="760" alt="classic165_southeast_asian page 1 difference heatmap"></td>
@@ -3520,7 +3515,7 @@ Blue areas are below the configured difference threshold; red areas have stronge
35203515
</tr>
35213516
<tr>
35223517
<td><b>classic164_indic_scripts</b></td>
3523-
<td colspan="1">classic164_indic_scripts <span style="color:#3fb950">⬤</span> 99.6%</td>
3518+
<td colspan="1">classic164_indic_scripts <span style="color:#3fb950">⬤</span> 99.8%</td>
35243519
</tr>
35253520
<tr>
35263521
<td><img src="images/classic164_indic_scripts_p1_minipdf.png" width="340" alt="MiniPdf"></td>
@@ -9230,10 +9225,10 @@ Text content: ✅ Identical
92309225
### classic164_indic_scripts
92319226

92329227
- **Text Similarity:** 1.0
9233-
- **Visual Average:** 0.9903
9234-
- **Overall Score:** 0.9961
9228+
- **Visual Average:** 0.9947
9229+
- **Overall Score:** 0.9979
92359230
- **Pages:** MiniPdf=1, Reference=1
9236-
- **File Size:** MiniPdf=71613 bytes, Reference=53352 bytes
9231+
- **File Size:** MiniPdf=77409 bytes, Reference=53352 bytes
92379232

92389233
Text content: ✅ Identical
92399234

Binary file not shown.
10.5 KB
Loading

0 commit comments

Comments
 (0)