Skip to content

Commit 10f00a3

Browse files
authored
Merge pull request #2502 from EPPlusSoftware/bug/i2498
#2498 - Fix GPOS kerning: unwrap extension-wrapped PairPos, sort expanded pair sets, and scale TJ adjustments by units per em
2 parents 01c9d62 + 2c1dc47 commit 10f00a3

6 files changed

Lines changed: 674 additions & 55 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/*************************************************************************************************
2+
Required Notice: Copyright (C) EPPlus Software AB.
3+
This software is licensed under PolyForm Noncommercial License 1.0.0
4+
and may only be used for noncommercial purposes
5+
https://polyformproject.org/licenses/noncommercial/1.0.0/
6+
7+
A commercial license to use this software can be purchased at https://epplussoftware.com
8+
*************************************************************************************************
9+
Date Author Change
10+
*************************************************************************************************
11+
09/07/2026 EPPlus Software AB WP1 kerning repro sheet
12+
*************************************************************************************************/
13+
using EPPlus.Export.Pdf.Tests;
14+
using OfficeOpenXml;
15+
using OfficeOpenXml.Style;
16+
using System;
17+
using System.IO;
18+
using System.Text;
19+
20+
namespace EPPlusTest.PDF
21+
{
22+
/// <summary>
23+
/// Reproduces the reference sheet the WP1 kerning work was measured against.
24+
///
25+
/// This is a VISUAL repro, not an automated regression test. It produces a PDF that has to be
26+
/// opened and measured by hand, because the kerning adjustments live inside a Flate compressed
27+
/// content stream and there is no decompression helper in the test project yet.
28+
///
29+
/// What to measure in the output, at Calibri 48 pt and 3.3299 px/pt:
30+
///
31+
/// A2 "AVATAR Wa To" The A to A pen step is the reference measurement. It was 184 px
32+
/// before WP1 and should now be close to 170 px, which is 2358 font
33+
/// units down to 2184. The pairs that carry kerning here are A+V, V+A,
34+
/// A+T, T+A, W+a and T+o.
35+
///
36+
/// A4 "AVATAR Wa To" Same string with kerning suppressed by putting every character in its
37+
/// A5 own cell, as a side by side reference for the pen steps. Not affected
38+
/// by WP1.
39+
///
40+
/// A7 "A" + U+0301 The accent cells are expected to be UNCHANGED by WP1. XOffset and
41+
/// A8 "a" + U+0301 YOffset are still not read by the renderer, so the accent over the
42+
/// capital A should still sit about 7 px too far right and 23 px too low
43+
/// and collide with the apex, while the accent over the lower case a
44+
/// still looks correct by coincidence. Both moving is a sign that WP1
45+
/// touched something it should not have. That is WP2.
46+
///
47+
/// The decomposed sequences in A7 and A8 are the ones that exercise mark to base positioning.
48+
/// The precomposed characters in B7 and B8 resolve to a single glyph in cmap and never reach
49+
/// the mark positioning code, so they are included only as a visual baseline.
50+
/// </summary>
51+
[TestClass]
52+
public class KerningReproTests : PdfTestBase
53+
{
54+
/// <summary>
55+
/// Calibri is a system font, so this test is only meaningful on a machine that has it.
56+
/// It is the font the original measurements were taken with, and its kern lookups are
57+
/// extension wrapped, which is the failure mode WP1 fixes.
58+
/// </summary>
59+
private const string ReferenceFontName = "Calibri";
60+
61+
private const float ReferenceFontSize = 48f;
62+
63+
private const string KernedText = "AVATAR Wa To";
64+
65+
[TestMethod]
66+
public void Avatar_Calibri48_KerningReproSheet()
67+
{
68+
using (var package = OpenPackage("WP1KerningRepro.xlsx", true))
69+
{
70+
var sheet = package.Workbook.Worksheets.Add("Kerning");
71+
72+
BuildReproSheet(sheet);
73+
74+
// Written to disk for visual inspection and for measuring against the reference
75+
// image. SaveAsPdf on the worksheet exports that single sheet.
76+
SaveAsPdf(sheet, "WP1KerningRepro");
77+
78+
// Also export to a stream so the test fails loudly if the export itself breaks,
79+
// rather than silently writing an unreadable file.
80+
using (var stream = new MemoryStream())
81+
{
82+
sheet.SaveAsPdf(stream);
83+
AssertLooksLikePdf(stream.ToArray());
84+
}
85+
86+
SaveWorkbook("WP1KerningRepro.xlsx", package);
87+
}
88+
}
89+
90+
private static void BuildReproSheet(ExcelWorksheet sheet)
91+
{
92+
sheet.Cells["A1"].Value = "Kerned pen steps";
93+
StyleHeader(sheet.Cells["A1"]);
94+
95+
sheet.Cells["A2"].Value = KernedText;
96+
StyleReference(sheet.Cells["A2"]);
97+
98+
sheet.Cells["A3"].Value = "Unkerned reference, one character per cell";
99+
StyleHeader(sheet.Cells["A3"]);
100+
101+
// One character per cell removes every pair boundary, so these advances are the raw
102+
// hmtx widths regardless of whether kerning works.
103+
for (int i = 0; i < KernedText.Length; i++)
104+
{
105+
var cell = sheet.Cells[4, i + 1];
106+
cell.Value = KernedText[i].ToString();
107+
StyleReference(cell);
108+
}
109+
110+
sheet.Cells["A6"].Value = "Mark to base, expected unchanged by WP1";
111+
StyleHeader(sheet.Cells["A6"]);
112+
113+
// Decomposed: base glyph plus combining acute accent, U+0301.
114+
sheet.Cells["A7"].Value = "A\u0301";
115+
sheet.Cells["A8"].Value = "a\u0301";
116+
117+
// Precomposed, single glyph in cmap. Visual baseline only.
118+
sheet.Cells["B7"].Value = "\u00C1";
119+
sheet.Cells["B8"].Value = "\u00E1";
120+
121+
StyleReference(sheet.Cells["A7:B8"]);
122+
123+
// Wide enough that autofit or clipping never influences the measurement. Kerning that
124+
// starts working narrows the measured text, so a fixed width keeps the pen steps the
125+
// only thing that changes between runs.
126+
for (int column = 1; column <= KernedText.Length; column++)
127+
{
128+
sheet.Column(column).Width = 20;
129+
}
130+
131+
for (int row = 1; row <= 8; row++)
132+
{
133+
sheet.Row(row).CustomHeight = true;
134+
sheet.Row(row).Height = 70;
135+
}
136+
}
137+
138+
private static void StyleReference(ExcelRange range)
139+
{
140+
range.Style.Font.Name = ReferenceFontName;
141+
range.Style.Font.Size = ReferenceFontSize;
142+
range.Style.HorizontalAlignment = ExcelHorizontalAlignment.Left;
143+
range.Style.VerticalAlignment = ExcelVerticalAlignment.Bottom;
144+
145+
// Left aligned and unindented, so the first glyph starts at the cell edge and the
146+
// A to A distance is a pure pen step with no left margin effects.
147+
range.Style.Indent = 0;
148+
range.Style.WrapText = false;
149+
}
150+
151+
private static void StyleHeader(ExcelRange range)
152+
{
153+
range.Style.Font.Name = ReferenceFontName;
154+
range.Style.Font.Size = 11f;
155+
range.Style.Font.Bold = true;
156+
}
157+
158+
private static void AssertLooksLikePdf(byte[] bytes)
159+
{
160+
Assert.IsTrue(bytes.Length > 0, "PDF output is empty.");
161+
162+
string head = Encoding.ASCII.GetString(bytes, 0, Math.Min(8, bytes.Length));
163+
Assert.IsTrue(head.StartsWith("%PDF-"), $"Missing PDF header. Got: '{head}'");
164+
165+
int tailLength = Math.Min(8, bytes.Length);
166+
string tail = Encoding.ASCII.GetString(bytes, bytes.Length - tailLength, tailLength);
167+
Assert.IsTrue(tail.Contains("%%EOF"), "Missing %%EOF trailer marker.");
168+
}
169+
}
170+
}

‎src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This software is licensed under PolyForm Noncommercial License 1.0.0
99
Date Author Change
1010
*************************************************************************************************
1111
10/07/2025 EPPlus Software AB EPPlus.Fonts.OpenType 1.0
12+
09/07/2026 EPPlus Software AB Scale TJ kerning adjustments with units per em
1213
*************************************************************************************************/
1314
using EPPlus.Graphics;
1415
using EPPlus.Graphics.Geometry;
@@ -249,7 +250,16 @@ public void AddText(PdfCellContentLayout cell, Vector2 position, double textRota
249250
int kerning = glyph.XAdvance - glyph.BaseAdvance;
250251
if (kerning != 0)
251252
{
252-
double adjustment = -(kerning * 1000.0 / 1000);
253+
// Glyph metrics are in font units, TJ numbers are in 1/1000 em, so
254+
// the adjustment has to be scaled by 1000 / unitsPerEm. The em square
255+
// is resolved per glyph because a fallback font can use a different
256+
// one than the primary font. Same scaling as /W in PdfCIDFont.
257+
ushort unitsPerEm = GetUnitsPerEm(
258+
shapedText.ShapedText.FontUnitsPerEm,
259+
glyph.FontId,
260+
fontResource.fontData.HeadTable.UnitsPerEm);
261+
262+
double adjustment = -(kerning * 1000.0 / unitsPerEm);
253263
sb.Append($" {adjustment.ToPdfStringF0()}");
254264
}
255265
if (j < shapedText.ShapedText.Glyphs.Length - 1)
@@ -266,6 +276,32 @@ public void AddText(PdfCellContentLayout cell, Vector2 position, double textRota
266276
}
267277
}
268278

279+
/// <summary>
280+
/// Resolves the em square to use when converting font units to text space units for a
281+
/// single glyph. Fallback fonts are allowed to have a different units per em than the
282+
/// primary font, which is why the value is indexed by the font id of the glyph.
283+
/// </summary>
284+
/// <param name="fontUnitsPerEm">Units per em indexed by font id, from the shaping result.</param>
285+
/// <param name="fontId">The font id of the glyph being written.</param>
286+
/// <param name="defaultUnitsPerEm">Units per em of the primary font, used as a fallback.</param>
287+
private static ushort GetUnitsPerEm(ushort[] fontUnitsPerEm, byte fontId, ushort defaultUnitsPerEm)
288+
{
289+
if (fontUnitsPerEm != null && fontUnitsPerEm.Length > 0)
290+
{
291+
if (fontId < fontUnitsPerEm.Length && fontUnitsPerEm[fontId] > 0)
292+
{
293+
return fontUnitsPerEm[fontId];
294+
}
295+
296+
if (fontUnitsPerEm[0] > 0)
297+
{
298+
return fontUnitsPerEm[0];
299+
}
300+
}
301+
302+
return defaultUnitsPerEm > 0 ? defaultUnitsPerEm : (ushort)1000;
303+
}
304+
269305
public void AddCellContentLayout(PdfCellContentLayout cell, PdfDictionaries dictionaries, PdfPageSettings pageSettings)
270306
{
271307
commands.Add($"% Content Start: {cell.Name}");

0 commit comments

Comments
 (0)