Repository navigation
PDF: kerning written in font units instead of 1/1000 em, and GPOS glyph offsets are dropped #2498
Description
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
TJarray. - Emit
TmatfragmentOrigin * Translation(penX + XOffset * scale, YOffset * scale). - Draw the single glyph.
- Emit
Tmback atfragmentOrigin * Translation(penX, 0)and reopen theTJarray.
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 againstShapedText.GetWidthInPointsfor 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
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.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsBacklog
Activity
Merge pull request #2502 from EPPlusSoftware/bug/i2498