diff --git a/packages/producer/src/services/htmlCompiler.fontEmbed.test.ts b/packages/producer/src/services/htmlCompiler.fontEmbed.test.ts
new file mode 100644
index 000000000..494f35525
--- /dev/null
+++ b/packages/producer/src/services/htmlCompiler.fontEmbed.test.ts
@@ -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/")`.
+ 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}") }`);
+ });
+});
diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts
index e279aebcb..5cbcd62d8 100644
--- a/packages/producer/src/services/htmlCompiler.ts
+++ b/packages/producer/src/services/htmlCompiler.ts
@@ -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 `/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()` 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")`. 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)
+ }`,
+ );
}
}
}