Summary
MarkToBaseProvider resolves GPOS mark lookups and writes the result to
ShapedGlyph.XOffset/YOffset, but no renderer reads those fields. A grep across
Export.Pdf, PdfExport, DrawingRenderer and Graphics returns only OS/2 hits. Mark-to-base
is therefore dead code in the PDF path, and combining marks are drawn at the pen position with no
offset at all.
The visible symptom: a combining acute over a lowercase base looks correct, while the same accent
over a capital sits too far right and too low and collides with the apex.
Reproduction
KerningReproTests.Avatar_Calibri48_KerningReproSheet produces WP1KerningRepro.pdf. Cells A7
and A8 hold A + U+0301 and a + U+0301 in Calibri 48 pt. Cells B7 and B8 hold the precomposed
Á and á as a visual baseline; those resolve to a single glyph in cmap and never reach the mark
positioning code, so they render correctly and make the defect easy to see side by side.
The content stream for A7 is:
A followed by acutecomb, with no Td, no Tm and no adjustment. The mark is drawn at the pen
after the base's full advance.
Why lowercase looks right
acutecomb has LSB = −760 fu, so it draws far to the left of its own origin. Placed on the pen
after a lowercase letter it lands nearly correctly by coincidence. Measured from the anchors in
the embedded Calibri subset (mark lookup type 4, mark class 0, mark anchor (−501, 1091)):
| Base |
Base anchor |
Pen-relative XOffset |
YOffset |
A |
(593, 1382) |
−91 fu (−2.13 pt) |
+291 fu (+6.82 pt) |
a |
(491, 1135) |
+11 fu (+0.26 pt) |
+44 fu (+1.03 pt) |
For a the correct offset is essentially zero, which is why drawing on the pen is almost right.
For A it is 91 fu too far right and 291 fu too low.
Changes
| # |
File |
Change |
| 1 |
EPPlus.Fonts.OpenType/TextShaping/Positioning/MarkToBaseProvider.cs (line 114–115) |
Make the offset pen-relative, following the HarfBuzz convention: subtract baseGlyph.XAdvance, not BaseAdvance. Kerning has already been applied by the time mark positioning runs — ApplyPositioning orders SinglePos, then kerning, then mark. |
| 2 |
Same file |
Use += instead of = so that XPlacement from SinglePos (TextShaper line 530–532) is not overwritten. |
| 3 |
EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs |
Read XOffset/YOffset. This requires the mark to break out of the TJ array and be positioned with its own Td/Tm, since TJ can only adjust horizontally. |
Acceptance criteria
- Pen-relative
XOffset for A + U+0301 in Calibri is −91 fu and YOffset is +291 fu.
- Pen-relative
XOffset for a + U+0301 is +11 fu and YOffset is +44 fu.
- The rendered
YOffset difference between Á and á is 247 fu. It is 0 today — both accents
occupy an identical row range in the rasterised output.
- The rendered accent over
Á does not collide with the apex.
á does not regress. Its correct offset is near zero, so a sign error would show up here as a
small shift rather than an obvious break.
Notes
Depends on WP1, which is now merged. MarkToBaseProvider sets XAdvance = 0 but leaves
BaseAdvance untouched, which produces an unscaled TJ adjustment. That is only correct once the
units-per-em scaling from WP1 is in place. Harmless as long as marks have an hmtx advance of 0,
but not true in general.
Breaking change. XOffset/YOffset become pen-relative rather than relative to the base
glyph's origin. There are zero consumers today, so the change is free now and breaking after
release. Both fields are public in EPPlus.Interfaces.
Not in scope: mkmk (GPOS type 6) and MarkToLigature (type 5) are not loaded at all, so
stacked diacritics remain unsupported. Tracked separately under WP7.
Summary
MarkToBaseProviderresolves GPOSmarklookups and writes the result toShapedGlyph.XOffset/YOffset, but no renderer reads those fields. A grep acrossExport.Pdf,PdfExport,DrawingRendererandGraphicsreturns only OS/2 hits. Mark-to-baseis therefore dead code in the PDF path, and combining marks are drawn at the pen position with no
offset at all.
The visible symptom: a combining acute over a lowercase base looks correct, while the same accent
over a capital sits too far right and too low and collides with the apex.
Reproduction
KerningReproTests.Avatar_Calibri48_KerningReproSheetproducesWP1KerningRepro.pdf. Cells A7and A8 hold
A+ U+0301 anda+ U+0301 in Calibri 48 pt. Cells B7 and B8 hold the precomposedÁandáas a visual baseline; those resolve to a single glyph in cmap and never reach the markpositioning code, so they render correctly and make the defect easy to see side by side.
The content stream for A7 is:
Afollowed byacutecomb, with noTd, noTmand no adjustment. The mark is drawn at the penafter the base's full advance.
Why lowercase looks right
acutecombhas LSB = −760 fu, so it draws far to the left of its own origin. Placed on the penafter a lowercase letter it lands nearly correctly by coincidence. Measured from the anchors in
the embedded Calibri subset (mark lookup type 4, mark class 0, mark anchor (−501, 1091)):
AaFor
athe correct offset is essentially zero, which is why drawing on the pen is almost right.For
Ait is 91 fu too far right and 291 fu too low.Changes
EPPlus.Fonts.OpenType/TextShaping/Positioning/MarkToBaseProvider.cs(line 114–115)baseGlyph.XAdvance, notBaseAdvance. Kerning has already been applied by the time mark positioning runs —ApplyPositioningorders SinglePos, then kerning, then mark.+=instead of=so thatXPlacementfrom SinglePos (TextShaperline 530–532) is not overwritten.EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.csXOffset/YOffset. This requires the mark to break out of the TJ array and be positioned with its ownTd/Tm, since TJ can only adjust horizontally.Acceptance criteria
XOffsetforA+ U+0301 in Calibri is −91 fu andYOffsetis +291 fu.XOffsetfora+ U+0301 is +11 fu andYOffsetis +44 fu.YOffsetdifference betweenÁandáis 247 fu. It is 0 today — both accentsoccupy an identical row range in the rasterised output.
Ádoes not collide with the apex.ádoes not regress. Its correct offset is near zero, so a sign error would show up here as asmall shift rather than an obvious break.
Notes
Depends on WP1, which is now merged.
MarkToBaseProvidersetsXAdvance = 0but leavesBaseAdvanceuntouched, which produces an unscaled TJ adjustment. That is only correct once theunits-per-em scaling from WP1 is in place. Harmless as long as marks have an hmtx advance of 0,
but not true in general.
Breaking change.
XOffset/YOffsetbecome pen-relative rather than relative to the baseglyph's origin. There are zero consumers today, so the change is free now and breaking after
release. Both fields are public in
EPPlus.Interfaces.Not in scope:
mkmk(GPOS type 6) and MarkToLigature (type 5) are not loaded at all, sostacked diacritics remain unsupported. Tracked separately under WP7.