Skip to content

injectDeterministicFontFaces: non-Latin text at a bundled weight (400/700) falls back to a system font #7313

Closed

Description

@shiwano

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

  1. In an empty directory: npm init -y && npm pkg set type=module
  2. npm install @hyperframes/producer@0.9.19 puppeteer-core
  3. Save the script above as repro.mjs
  4. 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

  1. added
    bugSomething isn't working
    triage/needs-triageNeeds current-behavior, related-work, and scope evaluation.
    on Nov 6, 2026
  2. added
    triage/readyAccepted scope, current evidence, acceptance criteria, and validation path are recorded.
    and removed
    triage/needs-triageNeeds current-behavior, related-work, and scope evaluation.
    on Nov 6, 2026
  3. added a commit that references this issue on Nov 6, 2026
  4. jrusso1020 commented on Nov 8, 2026

    @jrusso1020
    Collaborator

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingtriage/readyAccepted scope, current evidence, acceptance criteria, and validation path are recorded.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions