Repository navigation
Description
Describe the bug
injectDeterministicFontFaces can embed a bundled family such as Noto Sans JP and also add a supplementary Google Fonts text= subset. When it does both, any codepoint outside BUNDLED_SUBSET_UNICODE_RANGE is drawn in a system font, not the embedded one, whenever the element's font-weight is one of the bundled weights. For Noto Sans JP that means 400 and 700, which covers regular and bold text. The family resolves and nothing is logged, so the render just shows the wrong typeface, and which one depends on the fonts installed on the host.
For Noto Sans JP, the injected <style data-hyperframes-deterministic-fonts> contains:
@font-face {
font-family: "Noto Sans JP";
src: url(data:…text-subset…);
font-weight: 100 900;
unicode-range: U+3044, U+300C, …;
} /* Google text= subset */
@font-face {
font-family: "Noto Sans JP";
src: url(data:…bundled…);
font-weight: 400;
unicode-range: U+0000-00FF, …;
} /* bundled, Latin only */
@font-face {
font-family: "Noto Sans JP";
src: url(data:…bundled…);
font-weight: 700;
unicode-range: U+0000-00FF, …;
} /* bundled, Latin only */At font-weight: 700, Chromium matches only the exact-weight 700 face. It does not use the 100 900 face for codepoints the 700 face lacks, so those codepoints skip the family and fall to the generic fallback.
Minimal reproduction
repro.mjs:
import puppeteer from "puppeteer-core";
import { injectDeterministicFontFaces } from "@hyperframes/producer";
const text = "①「日本語のテスト」です";
const html = `<!DOCTYPE html><html lang="ja"><head><meta charset="UTF-8">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@100..900&display=block">
</head><body>${[400, 500, 700]
.map(
(w) =>
`<div style="font-family:'Noto Sans JP';font-weight:${w};font-size:40px"><span id="w${w}">${text}</span></div>`,
)
.join("")}
</body></html>`;
const embedded = (await injectDeterministicFontFaces(html, { failClosedFontFetch: true })).replace(
/<link[^>]*fonts\.googleapis\.com[^>]*>/g,
"",
);
const browser = await puppeteer.launch({ executablePath: process.env.CHROME });
for (const [name, doc] of [
["link", html],
["embedded", embedded],
]) {
const page = await browser.newPage();
await page.setContent(doc, { waitUntil: "networkidle0" });
await page.evaluate(() => document.fonts.ready);
const cdp = await page.createCDPSession();
await cdp.send("DOM.enable");
await cdp.send("CSS.enable");
const { root } = await cdp.send("DOM.getDocument", { depth: -1 });
for (const w of [400, 500, 700]) {
const { nodeId } = await cdp.send("DOM.querySelector", {
nodeId: root.nodeId,
selector: `#w${w}`,
});
const { fonts } = await cdp.send("CSS.getPlatformFontsForNode", { nodeId });
const width = await page.evaluate(
(w) => Math.round(document.getElementById(`w${w}`).getBoundingClientRect().width),
w,
);
console.log(
name,
w,
width,
fonts.map((f) => `${f.familyName} custom=${f.isCustomFont}`),
);
}
}
await browser.close();Steps to reproduce
- In an empty directory:
npm init -y && npm pkg set type=module npm install @hyperframes/producer@0.9.19 puppeteer-core- Save the script above as
repro.mjs CHROME=/path/to/chrome-headless-shell node repro.mjs
Expected behavior
Every weight in the embedded run renders with the embedded Noto Sans JP (custom=true) at the same width as the link run:
link 400 480 [ 'Noto Sans JP Thin custom=true' ]
link 500 480 [ 'Noto Sans JP Thin custom=true' ]
link 700 480 [ 'Noto Sans JP Thin custom=true' ]
embedded 400 480 [ 'Noto Sans JP Thin custom=true' ]
embedded 500 480 [ 'Noto Sans JP Thin custom=true' ]
embedded 700 480 [ 'Noto Sans JP Thin custom=true' ]
Actual behavior
At 400 and 700 the embedded run renders with a host font:
link 400 480 [ 'Noto Sans JP Thin custom=true' ]
link 500 480 [ 'Noto Sans JP Thin custom=true' ]
link 700 480 [ 'Noto Sans JP Thin custom=true' ]
embedded 400 417 [ 'TakaoPGothic custom=false' ]
embedded 500 480 [ 'Noto Sans JP Thin custom=true' ]
embedded 700 417 [ 'TakaoPGothic custom=false' ]
With Google Chrome 148 on the same host, the fallback at 400/700 is the system Noto Sans CJK JP instead. It looks almost identical, so the bug is easy to miss.
Environment
✓ Version 0.9.19 (not checked yet)
✓ Node.js v26.1.0 (linux x64)
✓ Environment WSL · non-TTY
✓ FFmpeg ffmpeg 8.1.2
✓ FFprobe ffprobe 8.1.2
✓ Chrome chrome-headless-shell 152.0.7977.54 (passed via CHROME=; doctor's own lookup reports missing shared libraries under Nix, unrelated to this issue)Additional context
bundledFamilyFaceRules keeps the text= faces at 400 and 700 because their unicode-range is not the bundled one, which is correct. But partitionWeightRuns then merges them with the other weights into one 100 900 rule. It only starts a new run when a covered weight lies strictly between two neighbouring faces. Here the covered weights are faces of the run themselves, so the run is never split.
A possible fix: partitionWeightRuns closes the current run before and after any face whose coverageKey is in coveredWeights, so that weight is emitted as its own single-weight rule beside the bundled one. Chromium then treats both rules as the same weight and picks between them by unicode-range. Downstream, also emitting the ranged rule at font-weight: 400 and font-weight: 700 makes all three weights render with the embedded face.
This affects any CANONICAL_FONTS family whose page text has codepoints outside BUNDLED_SUBSET_UNICODE_RANGE. CJK is the most visible case, but symbols like ① and other non-Latin scripts are affected too.
Activity
feat: port upstream 6fab219 (heygen-com#7329); advance upstream revie…
jrusso1020 commentedon Nov 8, 2026
Fixed by #7299, which emits the supplementary range face at each bundled weight so 400 and 700 no longer fall back to a system font. It shipped in v0.9.20. Please reopen if you still see it on 0.9.20 or later.