diegomura / diegomura/react-pdf
[Bug] Line-box height ignores font intrinsic metrics, causing CJK descenders to clip when lineHeight is below ~1.4
- Dominant language
- TypeScript
- Stars
- 16.8k
- Forks
- 1.3k
- Avg merge
- 5h 6m
- Merged PRs (30d)
- 52
Description
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
```jsx
软件工程 教育背景 实习经历 项目经验
软件工程 教育背景 实习经历 项目经验
软件工程 教育背景 实习经历 项目经验
```
`Font.register({ family: 'Noto Sans SC', src: '' })` 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`:
```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)
```js
// 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](https://github.com/amruthpillai/reactive-resume) ([fix PR](https://github.com/amruthpillai/reactive-resume/pull/) / [downstream issue](https://github.com/amruthpillai/reactive-resume/issues/3069)) 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
Contributor guide
Assessment
This issue has not been assessed yet.