Skip to content

PDF: kerning written in font units instead of 1/1000 em, and GPOS glyph offsets are dropped #2498

Description

@swmal

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:

  1. Close the open TJ array.
  2. Emit Tm at fragmentOrigin * Translation(penX + XOffset * scale, YOffset * scale).
  3. Draw the single glyph.
  4. 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

  1. 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.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Relationships

None yet

Development

No branches or pull requests

Issue actions