mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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}") }`);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user