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+ }
0 commit comments