fix(producer): anchor local-font embedding to its url() occurrence (#3405)

The embed step rewrote the compiled document with
result.replaceAll(localPath, dataUri) — a bare substring replace with no
surrounding syntax. That also rewrites the path anywhere else it appears,
including inside a LONGER url whose tail happens to match, producing a
corrupted value like url("file:///abs/data:font/woff2;base64,...").

Any two paths where one is a suffix of the other collide the same way;
img/logo.ttf and assets/img/logo.ttf are enough. Every sibling rewrite in
this file already anchors on url(...), so this one was the outlier.

Also add file: to LOCAL_FONTFACE_URL_RE's exclusion list. Without it an
absolute file:// src was classified as a project-relative path and
resolved to <projectDir>/file:/abs/..., and the failed read was swallowed
by an empty catch. That catch now logs, since a silently skipped font
means the composition renders in a fallback typeface with nothing saying
why.

Closes #3369
This commit is contained in:
Miguel Ángel
2026-08-21 18:58:21 -04:00
committed by GitHub
parent 41af866bcb
commit e1191edba6
2 changed files with 83 additions and 4 deletions
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { urlOccurrenceRe } from "./htmlCompiler.js";
/**
* The embed step used `result.replaceAll(localPath, dataUri)` — a bare
* substring rewrite over the whole compiled document. These pin the collision
* that made unsafe: it is invisible in ordinary projects and easy to
* reintroduce, because the naive form passes every test that uses one font.
*/
describe("font embed url() anchoring", () => {
const DATA_URI = "data:font/woff2;base64,AAAA";
const replace = (html: string, path: string) =>
html.replace(urlOccurrenceRe(path), `url("${DATA_URI}")`);
it("rewrites the exact occurrence, quoted or bare", () => {
expect(replace(`src: url("fonts/x.ttf")`, "fonts/x.ttf")).toBe(`src: url("${DATA_URI}")`);
expect(replace(`src: url(fonts/x.ttf)`, "fonts/x.ttf")).toBe(`src: url("${DATA_URI}")`);
expect(replace(`src: url('fonts/x.ttf')`, "fonts/x.ttf")).toBe(`src: url("${DATA_URI}")`);
});
it("does not corrupt a longer url whose tail matches the embedded path", () => {
// The reported corruption: embedding `fonts/x.ttf` rewrote the tail of an
// untouched absolute rule, producing `url("file:///abs/<data-uri>")`.
const html = `a { src: url("fonts/x.ttf") } b { src: url("file:///abs/fonts/x.ttf") }`;
const out = replace(html, "fonts/x.ttf");
expect(out).toContain(`a { src: url("${DATA_URI}") }`);
expect(out).toContain(`b { src: url("file:///abs/fonts/x.ttf") }`);
});
it("does not corrupt a sibling whose path is a suffix of another project path", () => {
// No file:// needed. Any two paths where one is a suffix of the other
// collide under a bare substring replace.
const html = `a { src: url("img/logo.ttf") } b { src: url("assets/img/logo.ttf") }`;
const out = replace(html, "img/logo.ttf");
expect(out).toContain(`a { src: url("${DATA_URI}") }`);
expect(out).toContain(`b { src: url("assets/img/logo.ttf") }`);
});
it("leaves the path alone outside a url() wrapper", () => {
const html = `/* see fonts/x.ttf for the source */ a { src: url("fonts/x.ttf") }`;
const out = replace(html, "fonts/x.ttf");
expect(out).toContain("/* see fonts/x.ttf for the source */");
});
it("treats regex metacharacters in a path literally", () => {
const html = `a { src: url("fonts/x+y(1).ttf") }`;
expect(replace(html, "fonts/x+y(1).ttf")).toBe(`a { src: url("${DATA_URI}") }`);
});
});
+34 -4
View File
@@ -1668,7 +1668,21 @@ export async function localizeRemoteFontFaces(
);
}
const LOCAL_FONTFACE_URL_RE = /url\(["']?(?!data:|https?:\/\/)([^"')]+)["']?\)/gi;
// `file:` joins data: and http(s): in the exclusion list. Without it an
// absolute `file:///abs/path/font.ttf` src was read as a project-RELATIVE path,
// resolved to `<projectDir>/file:/abs/path/...`, and the failed read was
// swallowed below — leaving the rule untouched for the browser to reject.
const LOCAL_FONTFACE_URL_RE = /url\(["']?(?!data:|file:|https?:\/\/)([^"')]+)["']?\)/gi;
/**
* Match one `url(<path>)` occurrence, with or without quotes, for a literal
* path. Exported for tests: the suffix-collision it prevents is invisible in
* ordinary projects and easy to reintroduce.
*/
export function urlOccurrenceRe(localPath: string): RegExp {
const escaped = localPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`url\\((["']?)${escaped}\\1\\)`, "g");
}
// Base64 expands bytes by ~33%, then immutable HTML replacements retain more
// string copies while compiling. Files up to and including 5 MiB remain inline;
// the first byte above that stays file-backed. This conservative ceiling keeps
@@ -1739,10 +1753,26 @@ async function embedLocalFontFaces(html: string, projectDir: string): Promise<st
`[Compiler] Embedded local font file: ${localPath} (${(font.buffer.length / 1024).toFixed(0)} KB → data URI)`,
);
}
result = result.replaceAll(localPath, dataUri);
// Anchored on the `url(...)` occurrence, not a bare substring. A
// plain replaceAll of `localPath` also rewrites that text anywhere
// else it appears -- including inside a LONGER url whose tail
// happens to match, e.g. embedding `fonts/x.ttf` would corrupt an
// untouched `url("file:///abs/fonts/x.ttf")` into
// `url("file:///abs/<data-uri>")`. Any two paths where one is a
// suffix of the other collide the same way. Every sibling rewrite
// in this file already anchors like this.
result = result.replace(urlOccurrenceRe(localPath), `url("${dataUri}")`);
embeddedPaths.add(localPath);
} catch {
// File read or compression failed — keep the original path
} catch (error) {
// Keep the original path: a font that cannot be read must not fail
// the render. Logged rather than silently swallowed -- a silent skip
// here means the composition renders in a fallback typeface and
// nothing says why.
defaultLogger.warn(
`[Compiler] Could not embed local font ${localPath}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
}