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) + }`, + ); } } }