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:
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
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
Textis rendered in a CJK script (Chinese / Japanese / Korean) andlineHeightis set below ~1.4, the descenders of CJK glyphs are clipped by the next line:Latin glyphs in the same
Textrender correctly. DefaultlineHeightof 1.5 hides the issue. v3 (Puppeteer / browser) renders the same content correctly at any reasonablelineHeight.Reproducer
Font.register({ family: 'Noto Sans SC', src: '<a Noto Sans SC TTF URL>' })first.Root cause
Two independent gaps in
@react-pdf/textkitvs CSS line-box rules. Either alone reproduces a different visual issue; both together produce the clipping above.1.
height(run)short-circuits user lineHeightpackages/textkit/src/run/height.js:CSS specifies the line-box height as
max(line-height, content-area). textkit's||discards the content-area whenever alineHeightis set. The line baseline (infinalizeLine) 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 / lineGapread hhea, not OS/2 typo metricsIn
packages/textkit/src/run/ascent.jsetc., 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: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.maxwould over-inflate CJK line-boxes by ~45 %.Why fixing only one gap doesn't work
||short-circuit still ignores the (now correct) intrinsic height when userlineHeightis set.max(user lineHeight, OS/2-derived intrinsic), matching browser layout. Latin and CJK both render as they did in v3.Proposed fix (textkit)
~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 patchagainst@react-pdf/textkit@6.3.0in reactive-resume (fix PR / downstream issue) and verified:lineHeight: 1.15.lineHeight ≥ 1.17; tighter at lower values, matching Chromium.lineHeight ≥ 1.5unchanged.Happy to upstream
I'll open a PR with this change against
masterif 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