Summary
Two defects in PdfContentStream.AddText cause shaped glyph positioning to be lost or misapplied when text is written to the content stream. Both sit between EPPlus.Fonts.OpenType (which computes the positioning correctly) and the PDF writer (which does not consume it correctly).
Branch: develop9 @ 7481a3c
Affected file: src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs
1. TJ kerning adjustment is not scaled to text space
PdfContentStream.cs (glyph loop in AddText):
int kerning = glyph.XAdvance - glyph.BaseAdvance;
if (kerning != 0)
{
double adjustment = -(kerning * 1000.0 / 1000);
sb.Append($" {adjustment.ToPdfStringF0()}");
}
glyph.XAdvance and glyph.BaseAdvance are in font design units. A number in a TJ array is in thousandths of a unit of text space, i.e. 1/1000 em. The expression 1000.0 / 1000 hardcodes an assumption that unitsPerEm == 1000, which holds for CFF fonts but not for TrueType.
PdfCIDFont already scales correctly for the width arrays:
DW = (int)Math.Round(1000.0d * 1000.0d / FontData.HeadTable.UnitsPerEm);
int scaledWidth = (int)Math.Round(1000.0d * rawWidth / FontData.HeadTable.UnitsPerEm);
so /W and the TJ adjustments are in different units.
Impact. For any 2048 upem font (Calibri, Arial, Times New Roman, Segoe UI, i.e. most of what Excel documents actually use) every kerning adjustment is applied at roughly 2.05x its intended magnitude. Because ShapedTextBase.GetWidthInPoints scales by FontUnitsPerEm correctly, the measured width used for layout, alignment and wrapping no longer matches the rendered width. Expect text that overshoots its cell, drifts under right alignment, and underline/strikethrough that ends short of the glyphs.
Secondary: ToPdfStringF0 rounds to whole 1/1000 em. Once the scaling is fixed, sub-unit adjustments are quantised away.
Fix. Scale by the units per em of the font the glyph came from, and keep decimals:
double adjustment = -(kerning * 1000.0 / unitsPerEm);
sb.Append($" {adjustment.ToPdfStringF2()}");
unitsPerEm must be resolved per glyph via ShapedText.FontUnitsPerEm[glyph.FontId], not from the primary font. With font fallback a single ShapedText can mix fonts with different em squares.
2. ShapedGlyph.XOffset / YOffset are never read
MarkToBaseProvider.TryPositionMark (GPOS lookup type 4) resolves the base and mark anchors and writes the result to the glyph:
markGlyph.XOffset = (short)xOffset;
markGlyph.YOffset = (short)yOffset;
markGlyph.XAdvance = 0;
markGlyph.YAdvance = 0;
Nothing consumes those fields. Grepping XOffset|YOffset across EPPlus.Export.Pdf, EPPlus/Export/PdfExport, EPPlus.DrawingRenderer and EPPlus.Graphics returns only the OS/2 superscript and subscript code, which is unrelated.
Impact. Mark-to-base attachment is dead code in the PDF path. Combining marks are drawn at the pen position rather than at the anchor. This is often survivable for lowercase bases, where the mark glyph's negative left side bearing approximates the right result, but the vertical component is always lost and placement over uppercase bases or in fonts that genuinely rely on anchors is wrong.
Fix. A TJ adjustment can only express the horizontal component, so a glyph carrying an offset has to be drawn with its own text matrix:
- Close the open
TJ array.
- Emit
Tm at fragmentOrigin * Translation(penX + XOffset * scale, YOffset * scale).
- Draw the single glyph.
- Emit
Tm back at fragmentOrigin * Translation(penX, 0) and reopen the TJ array.
Td is not suitable: it moves the line start, not the pen, so it would require separate bookkeeping of the line origin. Ts only covers the vertical component.
This requires tracking the pen advance explicitly in the glyph loop as XAdvance * size / unitsPerEm. That value only matches where the PDF pen actually ends up once issue 1 is fixed, so the two changes need to land together.
Reproduction
- Export a worksheet using a 2048 upem font (Calibri, Arial) containing a kerning-heavy string such as
AVATAR Wa To. Compare the accumulated pen position in the content stream against ShapedText.GetWidthInPoints for the same string and size. They diverge by roughly the kerning total.
- Export a cell containing a decomposed diacritic over an uppercase base, for example
A followed by U+0301. The accent renders at the baseline pen position with no vertical anchor adjustment.
Out of scope, noted while investigating
advanceX += textLength uses the width of the whole ShapedText rather than the fragment being rendered. This misplaces the second and later fragments in rich text and overdraws underline and strikethrough. Should be tracked separately.
Matrix3x3 multiplies with a row-vector convention, so textMatrix * Translation(...) applies the offset in page space after rotation. Rotated text therefore advances along the page axis rather than the text axis. Pre-existing and affects advanceX and the superscript/subscript offsets equally; a fix for mark placement should follow the same convention until AddText is reviewed as a whole.
Summary
Two defects in
PdfContentStream.AddTextcause shaped glyph positioning to be lost or misapplied when text is written to the content stream. Both sit betweenEPPlus.Fonts.OpenType(which computes the positioning correctly) and the PDF writer (which does not consume it correctly).Branch:
develop9@7481a3cAffected file:
src/EPPlus.Export.Pdf/DocumentObjects/PdfContentStream.cs1. TJ kerning adjustment is not scaled to text space
PdfContentStream.cs(glyph loop inAddText):glyph.XAdvanceandglyph.BaseAdvanceare in font design units. A number in aTJarray is in thousandths of a unit of text space, i.e. 1/1000 em. The expression1000.0 / 1000hardcodes an assumption thatunitsPerEm == 1000, which holds for CFF fonts but not for TrueType.PdfCIDFontalready scales correctly for the width arrays:so
/Wand theTJadjustments are in different units.Impact. For any 2048 upem font (Calibri, Arial, Times New Roman, Segoe UI, i.e. most of what Excel documents actually use) every kerning adjustment is applied at roughly 2.05x its intended magnitude. Because
ShapedTextBase.GetWidthInPointsscales byFontUnitsPerEmcorrectly, the measured width used for layout, alignment and wrapping no longer matches the rendered width. Expect text that overshoots its cell, drifts under right alignment, and underline/strikethrough that ends short of the glyphs.Secondary:
ToPdfStringF0rounds to whole 1/1000 em. Once the scaling is fixed, sub-unit adjustments are quantised away.Fix. Scale by the units per em of the font the glyph came from, and keep decimals:
unitsPerEmmust be resolved per glyph viaShapedText.FontUnitsPerEm[glyph.FontId], not from the primary font. With font fallback a singleShapedTextcan mix fonts with different em squares.2.
ShapedGlyph.XOffset/YOffsetare never readMarkToBaseProvider.TryPositionMark(GPOS lookup type 4) resolves the base and mark anchors and writes the result to the glyph:Nothing consumes those fields. Grepping
XOffset|YOffsetacrossEPPlus.Export.Pdf,EPPlus/Export/PdfExport,EPPlus.DrawingRendererandEPPlus.Graphicsreturns only the OS/2 superscript and subscript code, which is unrelated.Impact. Mark-to-base attachment is dead code in the PDF path. Combining marks are drawn at the pen position rather than at the anchor. This is often survivable for lowercase bases, where the mark glyph's negative left side bearing approximates the right result, but the vertical component is always lost and placement over uppercase bases or in fonts that genuinely rely on anchors is wrong.
Fix. A
TJadjustment can only express the horizontal component, so a glyph carrying an offset has to be drawn with its own text matrix:TJarray.TmatfragmentOrigin * Translation(penX + XOffset * scale, YOffset * scale).Tmback atfragmentOrigin * Translation(penX, 0)and reopen theTJarray.Tdis not suitable: it moves the line start, not the pen, so it would require separate bookkeeping of the line origin.Tsonly covers the vertical component.This requires tracking the pen advance explicitly in the glyph loop as
XAdvance * size / unitsPerEm. That value only matches where the PDF pen actually ends up once issue 1 is fixed, so the two changes need to land together.Reproduction
AVATAR Wa To. Compare the accumulated pen position in the content stream againstShapedText.GetWidthInPointsfor the same string and size. They diverge by roughly the kerning total.Afollowed by U+0301. The accent renders at the baseline pen position with no vertical anchor adjustment.Out of scope, noted while investigating
advanceX += textLengthuses the width of the wholeShapedTextrather than the fragment being rendered. This misplaces the second and later fragments in rich text and overdraws underline and strikethrough. Should be tracked separately.Matrix3x3multiplies with a row-vector convention, sotextMatrix * Translation(...)applies the offset in page space after rotation. Rotated text therefore advances along the page axis rather than the text axis. Pre-existing and affectsadvanceXand the superscript/subscript offsets equally; a fix for mark placement should follow the same convention untilAddTextis reviewed as a whole.