fix(pdf): align textkit line-box and font metrics to browser behaviour (#3070)

* fix(pdf): align textkit line-box and font metrics to browser behaviour

CJK characters in resumes with a tightened typography line-height
(< ~1.4) had their descenders clipped by the next line. Latin glyphs
in the same resume rendered fine. Fixes the visual regression vs the
v5.0.x Puppeteer-based renderer reported in issue #2986 and follow-ups.

The clipping is caused by two independent gaps in @react-pdf/textkit
relative to standard CSS line-box rules:

1. `height(run)` short-circuits to the user-supplied lineHeight and
   ignores the run's intrinsic ascent + descent. CSS line-boxes are
   spec'd as `max(line-height, content-area)` — when CJK glyphs are
   present the content-area is taller than a tightened lineHeight, so
   the box must grow. textkit didn't, so the baseline (computed from
   the real, larger CJK ascent) sat below the box and the descender
   bled into the next line.

2. `ascent / descent / lineGap` are read directly from fontkit's hhea
   defaults. For Source Han Sans/Serif (the CJK fallbacks registered
   in #3013) hhea is intentionally inflated for legacy Windows GDI
   compatibility (1.45 em vs 1.0 em), so even a fixed line-box would
   have been excessively tall. Browsers (and the v5.0.x Puppeteer
   renderer) read OS/2 sTypoAscender/Descender/LineGap instead, which
   are the values the type designers intend for modern shaping.

Both are upstream behaviours of `@react-pdf/textkit`, but waiting for
an upstream release would leave existing users with broken CJK output.
The fix is shipped as a pnpm patch (~30 LOC):

- `resolveTypoMetrics(font)`: prefer OS/2 typo metrics, fall back to
  hhea when an OS/2 table is absent (e.g. the StandardFont stand-ins
  for Helvetica/Courier/Times). Used by ascent/descent/lineGap so all
  height-related calculations stay consistent.
- `height(run)`: `Math.max(lineHeight || 0, intrinsic)` instead of
  the original short-circuit, matching CSS line-box rules.

The patch is self-contained: existing Latin-only resumes are
unaffected (IBM Plex Serif's typo metrics equal hhea; Roboto's typo
is slightly smaller, but only changes the rendered line-box for users
who set lineHeight below ~1.17, which already used to clip ascenders
under v5.1.x and now lays out as it would in a browser).

Tooling notes:
- `Dockerfile.dev` copies `patches/` before `pnpm install` so the
  dev image build no longer fails on `--frozen-lockfile`. The
  production `Dockerfile` already gets it for free via
  `turbo prune --docker` (the patch reference in package.json marks
  the directory as part of the pruned slice).
- The patch will become a no-op once an equivalent fix lands upstream
  in @react-pdf/textkit; the entry can then be removed from
  `pnpm.patchedDependencies` and the file deleted.

* fix(deps): regenerate lockfile and move patchedDependencies for pnpm 11

The previous commit's lockfile was authored by pnpm 8 (lockfileVersion 6.0)
and kept patchedDependencies under package.json#pnpm. The repository now
declares packageManager: pnpm@11.1.2, which:

- writes lockfileVersion 9.0 and rejects v6 with ERR_PNPM_LOCKFILE_BREAKING_CHANGE
  on --frozen-lockfile (CI failure observed in autofix.ci);
- reads pnpm settings from pnpm-workspace.yaml, silently ignoring the
  package.json#pnpm field — so the textkit patch was no longer applied.

Regenerate pnpm-lock.yaml with pnpm 11.1.2 and move patchedDependencies
to pnpm-workspace.yaml so the patch is applied and CI passes.

* chore: update dependencies

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
JamesGoslings
2026-05-18 14:08:47 +08:00
committed by GitHub
parent 63e8c3ca33
commit dd7623f11e
21 changed files with 895 additions and 939 deletions
+1
View File
@@ -15,6 +15,7 @@ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY patches ./patches
COPY apps/web/package.json ./apps/web/package.json
COPY packages/ai/package.json ./packages/ai/package.json
COPY packages/api/package.json ./packages/api/package.json
+8 -8
View File
@@ -16,7 +16,7 @@
"lingui:extract": "lingui extract --clean --overwrite"
},
"dependencies": {
"@ai-sdk/react": "^3.0.184",
"@ai-sdk/react": "^3.0.186",
"@base-ui/react": "^1.4.1",
"@better-auth/api-key": "^1.6.11",
"@better-auth/infra": "^0.2.8",
@@ -51,9 +51,9 @@
"@tanstack/react-form": "^1.32.0",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-router": "^1.169.2",
"@tanstack/react-router-ssr-query": "^1.166.12",
"@tanstack/react-start": "^1.167.65",
"@tanstack/react-router": "^1.170.4",
"@tanstack/react-router-ssr-query": "^1.167.0",
"@tanstack/react-start": "^1.168.6",
"@tiptap/extension-color": "^3.23.4",
"@tiptap/extension-highlight": "^3.23.4",
"@tiptap/extension-table": "^3.23.4",
@@ -65,14 +65,14 @@
"@types/js-cookie": "^3.0.6",
"@uiw/color-convert": "^2.10.1",
"@uiw/react-color-colorful": "^2.10.1",
"ai": "^6.0.182",
"ai": "^6.0.184",
"better-auth": "1.6.11",
"cmdk": "^1.1.1",
"drizzle-orm": "1.0.0-rc.2",
"es-toolkit": "^1.46.1",
"fuse.js": "^7.3.0",
"immer": "^11.1.8",
"js-cookie": "^3.0.5",
"js-cookie": "^3.0.7",
"motion": "^12.38.0",
"pdfjs-dist": "5.7.284",
"pg": "^8.20.0",
@@ -100,8 +100,8 @@
"@types/pg": "^8.20.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@vitejs/plugin-react": "^6.0.1",
"@typescript/native-preview": "7.0.0-dev.20260517.1",
"@vitejs/plugin-react": "^6.0.2",
"babel-plugin-macros": "^3.1.0",
"nitro": "3.0.260429-beta",
"typescript": "^6.0.3",
+1 -1
View File
@@ -18,7 +18,7 @@
"ignoreDependencies": ["ioredis"]
},
"packages/runtime-externals": {
"ignoreDependencies": ["@aws-sdk/client-s3", "bcrypt", "sharp"]
"ignoreDependencies": ["@aws-sdk/client-s3", "bcrypt", "ioredis", "linkedom", "sharp"]
}
},
"ignoreDependencies": ["@reactive-resume/config", "npm-check-updates"]
+3 -3
View File
@@ -44,13 +44,13 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.7.0",
"@types/node": "^25.8.0",
"@vitest/coverage-v8": "^4.1.6",
"happy-dom": "^20.9.0",
"knip": "^6.13.1",
"knip": "^6.14.1",
"lefthook": "^2.1.6",
"npm-check-updates": "^22.2.0",
"turbo": "^2.9.12",
"turbo": "^2.9.14",
"typescript": "^6.0.3",
"vitest": "^4.1.6"
}
+1 -1
View File
@@ -28,7 +28,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@typescript/native-preview": "7.0.0-dev.20260517.1",
"typescript": "^6.0.3"
}
}
+8 -8
View File
@@ -18,11 +18,11 @@
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.77",
"@ai-sdk/google": "^3.0.73",
"@ai-sdk/openai": "^3.0.63",
"@ai-sdk/anthropic": "^3.0.78",
"@ai-sdk/google": "^3.0.75",
"@ai-sdk/openai": "^3.0.64",
"@ai-sdk/openai-compatible": "^2.0.47",
"@aws-sdk/client-s3": "^3.1046.0",
"@aws-sdk/client-s3": "^3.1048.0",
"@mozilla/readability": "^0.6.0",
"@orpc/client": "^1.14.3",
"@orpc/experimental-ratelimit": "^1.14.3",
@@ -33,8 +33,8 @@
"@reactive-resume/env": "workspace:*",
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@tanstack/react-start": "^1.167.65",
"ai": "^6.0.182",
"@tanstack/react-start": "^1.168.6",
"ai": "^6.0.184",
"bcrypt": "^6.0.0",
"better-auth": "1.6.11",
"drizzle-orm": "1.0.0-rc.2",
@@ -47,13 +47,13 @@
"resumable-stream": "^2.2.12",
"sharp": "^0.34.5",
"ts-pattern": "^5.9.0",
"undici": "^8.2.0",
"undici": "^8.3.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@typescript/native-preview": "7.0.0-dev.20260517.1",
"typescript": "^6.0.3"
}
}
+2 -2
View File
@@ -25,7 +25,7 @@
"@reactive-resume/email": "workspace:*",
"@reactive-resume/env": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@tanstack/react-start": "^1.167.65",
"@tanstack/react-start": "^1.168.6",
"bcrypt": "^6.0.0",
"better-auth": "1.6.11",
"drizzle-orm": "1.0.0-rc.2",
@@ -37,7 +37,7 @@
"@reactive-resume/config": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@types/react": "^19.2.14",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@typescript/native-preview": "7.0.0-dev.20260517.1",
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -31,7 +31,7 @@
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/pg": "^8.20.0",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@typescript/native-preview": "7.0.0-dev.20260517.1",
"drizzle-kit": "1.0.0-rc.2",
"typescript": "^6.0.3"
}
+1 -1
View File
@@ -26,7 +26,7 @@
"@reactive-resume/config": "workspace:*",
"@types/nodemailer": "^8.0.0",
"@types/react": "^19.2.14",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@typescript/native-preview": "7.0.0-dev.20260517.1",
"typescript": "^6.0.3"
}
}
+2 -2
View File
@@ -21,8 +21,8 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^25.7.0",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@types/node": "^25.8.0",
"@typescript/native-preview": "7.0.0-dev.20260517.1",
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -19,7 +19,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@typescript/native-preview": "7.0.0-dev.20260517.1",
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -22,7 +22,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@typescript/native-preview": "7.0.0-dev.20260517.1",
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -32,7 +32,7 @@
"@react-pdf/types": "^2.11.1",
"@reactive-resume/config": "workspace:*",
"@types/react": "^19.2.14",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@typescript/native-preview": "7.0.0-dev.20260517.1",
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"private": true,
"dependencies": {
"@aws-sdk/client-s3": "^3.1046.0",
"@aws-sdk/client-s3": "^3.1048.0",
"bcrypt": "^6.0.0",
"ioredis": "^5.10.1",
"linkedom": "^0.18.12",
+1 -1
View File
@@ -22,7 +22,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@typescript/native-preview": "7.0.0-dev.20260517.1",
"typescript": "^6.0.3"
}
}
+2 -2
View File
@@ -12,9 +12,9 @@
"@reactive-resume/config": "workspace:*",
"@reactive-resume/env": "workspace:*",
"@types/pg": "^8.20.0",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@typescript/native-preview": "7.0.0-dev.20260517.1",
"drizzle-orm": "1.0.0-rc.2",
"pg": "^8.20.0",
"tsx": "^4.22.0"
"tsx": "^4.22.1"
}
}
+2 -2
View File
@@ -25,7 +25,7 @@
"@reactive-resume/utils": "workspace:*",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
"js-cookie": "^3.0.5",
"js-cookie": "^3.0.7",
"next-themes": "^0.4.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@@ -41,7 +41,7 @@
"@types/js-cookie": "^3.0.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@typescript/native-preview": "7.0.0-dev.20260517.1",
"postcss": "^8.5.14",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3"
+3 -3
View File
@@ -35,7 +35,7 @@
"@uiw/color-convert": "^2.10.1",
"clsx": "^2.1.1",
"docx": "^9.6.1",
"dompurify": "^3.4.3",
"dompurify": "^3.4.4",
"fast-json-patch": "^3.1.1",
"tailwind-merge": "^3.6.0",
"unique-names-generator": "^4.7.1",
@@ -44,8 +44,8 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^25.7.0",
"@typescript/native-preview": "7.0.0-dev.20260514.1",
"@types/node": "^25.8.0",
"@typescript/native-preview": "7.0.0-dev.20260517.1",
"typescript": "^6.0.3"
}
}
+75
View File
@@ -0,0 +1,75 @@
diff --git a/lib/textkit.js b/lib/textkit.js
index 0b8b6fa636c48ef0ac9cecb27040d2bac9303ce8..058d96e4d47339482c2c8e8769ca031b8688e3a5 100644
--- a/lib/textkit.js
+++ b/lib/textkit.js
@@ -697,6 +697,31 @@ const omit = (value, run) => {
return Object.assign({}, run, { attributes });
};
+/**
+ * Resolve a fontkit Font's vertical metrics, preferring the OS/2
+ * `sTypoAscender / sTypoDescender / sTypoLineGap` triple when available
+ * and falling back to hhea. This matches Chromium's font-metrics
+ * resolution and is what browsers (and the v5.0.x Puppeteer renderer)
+ * use, giving compact, predictable line boxes for fonts whose hhea
+ * values are inflated for Windows GDI compatibility (notably Source
+ * Han Sans/Serif a.k.a. Noto Sans/Serif CJK).
+ *
+ * `f['OS/2']` is exposed by fontkit when the font carries an OS/2 table.
+ * Standard PDF fonts (Helvetica/Courier/Times) and our internal
+ * EmbeddedFont don't, so they keep their existing hhea-based metrics.
+ */
+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),
+ };
+};
+
/**
* Get run ascent
*
@@ -706,7 +731,7 @@ const omit = (value, run) => {
const ascent$1 = (run) => {
const { font, attachment } = run.attributes;
const attachmentHeight = attachment?.height || 0;
- const fontAscent = typeof font === 'string' ? 0 : font?.[0]?.ascent || 0;
+ const fontAscent = typeof font === 'string' ? 0 : resolveTypoMetrics(font?.[0]).ascent;
return Math.max(attachmentHeight, fontAscent * scale(run));
};
@@ -718,7 +743,7 @@ const ascent$1 = (run) => {
*/
const descent = (run) => {
const font = run.attributes?.font;
- const fontDescent = typeof font === 'string' ? 0 : font?.[0]?.descent || 0;
+ const fontDescent = typeof font === 'string' ? 0 : resolveTypoMetrics(font?.[0]).descent;
return scale(run) * fontDescent;
};
@@ -730,8 +755,8 @@ const descent = (run) => {
*/
const lineGap = (run) => {
const font = run.attributes?.font;
- const lineGap = typeof font === 'string' ? 0 : font?.[0]?.lineGap || 0;
- return lineGap * scale(run);
+ const fontLineGap = typeof font === 'string' ? 0 : resolveTypoMetrics(font?.[0]).lineGap;
+ return fontLineGap * scale(run);
};
/**
@@ -742,7 +767,8 @@ const lineGap = (run) => {
*/
const height$1 = (run) => {
const lineHeight = run.attributes?.lineHeight;
- return lineHeight || lineGap(run) + ascent$1(run) - descent(run);
+ const intrinsic = lineGap(run) + ascent$1(run) - descent(run);
+ return Math.max(lineHeight || 0, intrinsic);
};
/**
+778 -900
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -11,3 +11,5 @@ minimumReleaseAgeExclude:
- postcss@8.5.14
overrides:
postcss@<8.5.10: ^8.5.14
patchedDependencies:
"@react-pdf/textkit@6.3.0": patches/@react-pdf__textkit@6.3.0.patch