Skip to content

[Bug] Line-box height ignores font intrinsic metrics, causing CJK descenders to clip when lineHeight is below ~1.4 #3424

@JamesGoslings

Description

@JamesGoslings

A focused report on the CJK side-effect of textkit's line-box and font-metrics handling. Likely the same root cause as #2988 (introduced by #2952), but with a clearer reproducer and root-cause analysis.

Symptom

When a Text is rendered in a CJK script (Chinese / Japanese / Korean) and lineHeight is set below ~1.4, the descenders of CJK glyphs are clipped by the next line:

Image

Latin glyphs in the same Text render correctly. Default lineHeight of 1.5 hides the issue. v3 (Puppeteer / browser) renders the same content correctly at any reasonable lineHeight.

Reproducer

<Document>
  <Page style={{ padding: 40 }}>
    <Text style={{ fontFamily: 'Noto Sans SC', fontSize: 12, lineHeight: 1.15 }}>
      软件工程 教育背景 实习经历 项目经验
      软件工程 教育背景 实习经历 项目经验
      软件工程 教育背景 实习经历 项目经验
    </Text>
  </Page>
</Document>

Font.register({ family: 'Noto Sans SC', src: '<a Noto Sans SC TTF URL>' }) first.

Root cause

Two independent gaps in @react-pdf/textkit vs CSS line-box rules. Either alone reproduces a different visual issue; both together produce the clipping above.

1. height(run) short-circuits user lineHeight

packages/textkit/src/run/height.js:

const height = (run) => {
  const lineHeight = run.attributes?.lineHeight;
  return lineHeight || lineGap(run) + ascent(run) - descent(run);
};

CSS specifies the line-box height as max(line-height, content-area). textkit's || discards the content-area whenever a lineHeight is set. The line baseline (in finalizeLine) is still computed from the run's real ascent — which does account for the CJK font, so the descender is positioned outside the (too-short) box and bleeds into the next line.

2. ascent / descent / lineGap read hhea, not OS/2 typo metrics

In packages/textkit/src/run/ascent.js etc., metrics come straight from fontkit's hhea defaults. For Source Han Sans / Serif (a.k.a. Noto Sans / Serif CJK) the hhea ascender is inflated for legacy Windows GDI compatibility:

Font (1000 unitsPerEm) hhea ascent / descent OS/2 sTypoAscender / sTypoDescender intrinsic em
Noto Sans SC 1160 / -288 880 / -120 hhea 1.448 vs typo 1.000
Roboto (2048 upem) 1900 / -500 1536 / -512 hhea 1.172 vs typo 1.000
IBM Plex Serif 1025 / -275 1025 / -275 identical
Helvetica (StandardFont) hhea no OS/2 table n/a (fallback)

Chromium (and therefore the Puppeteer-based v3 renderer) uses OS/2 typo metrics for line-box content height. textkit always picks the larger hhea values, so even a properly enforced Math.max would over-inflate CJK line-boxes by ~45 %.

Why fixing only one gap doesn't work

  • Only Bug 1 (Math.max): line-boxes balloon to 1.45 em on CJK content; Latin layouts on Roboto also tighten by ~17 %. Visual regression vs v3.
  • Only Bug 2 (typo metrics): clipping persists because the || short-circuit still ignores the (now correct) intrinsic height when user lineHeight is set.
  • Both together: line-box tracks max(user lineHeight, OS/2-derived intrinsic), matching browser layout. Latin and CJK both render as they did in v3.

Proposed fix (textkit)

// New helper, used by ascent / descent / lineGap.
const resolveTypoMetrics = (font) => {
  const os2 = font?.['OS/2'];
  if (!os2 || typeof os2.typoAscender !== 'number' || typeof os2.typoDescender !== 'number') {
    return { ascent: font?.ascent || 0, descent: font?.descent || 0, lineGap: font?.lineGap || 0 };
  }
  return {
    ascent: os2.typoAscender,
    descent: os2.typoDescender,
    lineGap: typeof os2.typoLineGap === 'number' ? os2.typoLineGap : (font.lineGap || 0),
  };
};

const height = (run) => {
  const lineHeight = run.attributes?.lineHeight;
  const intrinsic = lineGap(run) + ascent(run) - descent(run);
  return Math.max(lineHeight || 0, intrinsic); // CSS line-box rule
};

~30 LOC total. The OS/2 fallback to hhea preserves behaviour for StandardFont (Helvetica / Courier / Times-Roman), which has no OS/2 table.

Validation

We've shipped this as a pnpm patch against @react-pdf/textkit@6.3.0 in reactive-resume (fix PR / downstream issue) and verified:

  • ✅ CJK clipping resolved at lineHeight: 1.15.
  • ✅ Latin-only resumes on Helvetica / IBM Plex Serif unchanged at all line-heights.
  • ✅ Latin resumes on Roboto unchanged at lineHeight ≥ 1.17; tighter at lower values, matching Chromium.
  • ✅ CJK resumes at lineHeight ≥ 1.5 unchanged.

Happy to upstream

I'll open a PR with this change against master if it's a direction you'd accept. Open to alternative shapes (e.g. opt-in via a flag) if you'd rather not change defaults.

Refs: #2988, #2952

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions