fix(reader): use blob URLs for fonts to bypass sandboxed iframe restrictions

The paginator renders inside a sandboxed iframe that blocks @font-face
URL fetches. Fonts were never loading — all font-family rules fell back
to the generic 'serif' system font, making every reading font identical.

Fix: fetch font files on the parent page, create blob: URLs via
URL.createObjectURL(), and use those blob URLs in the @font-face rules
injected into the iframe via setStyles(). Blob URLs are always
same-origin with the creating document, so the sandboxed iframe can
access them with allow-same-origin.

Also added Playwrite NZ Guides as a test font for verifying font
switching works.
This commit is contained in:
2026-04-19 21:36:19 -04:00
parent 0007b4d1d0
commit 9ca8e2aa44
4 changed files with 80 additions and 136 deletions
+67 -132
View File
@@ -12,142 +12,76 @@ const FONT_MAP: Record<string, string> = {
"noto-serif": '"Noto Serif"',
"charis-sil": '"Charis SIL"',
"ibm-plex": '"IBM Plex Serif"',
"playwrite": '"Playwrite NZ Guides"',
};
const FONT_FACE_RULES = `
@font-face {
font-family: "Literata";
src: url("/static/fonts/literata/Literata-Variable.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
interface FontFile {
family: string;
url: string;
weight: string;
style: string;
}
@font-face {
font-family: "Literata";
src: url("/static/fonts/literata/Literata-Italic-Variable.woff2") format("woff2");
font-weight: 100 900;
font-style: italic;
const FONT_FILES: FontFile[] = [
{ family: '"Literata"', url: "/static/fonts/literata/Literata-Variable.woff2", weight: "100 900", style: "normal" },
{ family: '"Literata"', url: "/static/fonts/literata/Literata-Italic-Variable.woff2", weight: "100 900", style: "italic" },
{ family: '"Crimson Pro"', url: "/static/fonts/crimson/CrimsonPro-Variable.woff2", weight: "100 900", style: "normal" },
{ family: '"Crimson Pro"', url: "/static/fonts/crimson/CrimsonPro-Italic-Variable.woff2", weight: "100 900", style: "italic" },
{ family: '"Source Serif 4"', url: "/static/fonts/source-serif/SourceSerif4-Variable.woff2", weight: "100 900", style: "normal" },
{ family: '"Source Serif 4"', url: "/static/fonts/source-serif/SourceSerif4-Italic-Variable.woff2", weight: "100 900", style: "italic" },
{ family: '"EB Garamond"', url: "/static/fonts/eb-garamond/EBGaramond-Variable.woff2", weight: "100 900", style: "normal" },
{ family: '"EB Garamond"', url: "/static/fonts/eb-garamond/EBGaramond-Italic-Variable.woff2", weight: "100 900", style: "italic" },
{ family: '"Libertinus Serif"', url: "/static/fonts/libertinus/LibertinusSerif-Regular.woff2", weight: "400", style: "normal" },
{ family: '"Libertinus Serif"', url: "/static/fonts/libertinus/LibertinusSerif-Bold.woff2", weight: "700", style: "normal" },
{ family: '"Libertinus Serif"', url: "/static/fonts/libertinus/LibertinusSerif-Italic.woff2", weight: "400", style: "italic" },
{ family: '"Libertinus Serif"', url: "/static/fonts/libertinus/LibertinusSerif-BoldItalic.woff2", weight: "700", style: "italic" },
{ family: '"Noto Serif"', url: "/static/fonts/noto-serif/NotoSerif-Variable.woff2", weight: "100 900", style: "normal" },
{ family: '"Noto Serif"', url: "/static/fonts/noto-serif/NotoSerif-Italic-Variable.woff2", weight: "100 900", style: "italic" },
{ family: '"Charis SIL"', url: "/static/fonts/charis-sil/CharisSIL-Regular.woff2", weight: "400", style: "normal" },
{ family: '"Charis SIL"', url: "/static/fonts/charis-sil/CharisSIL-Bold.woff2", weight: "700", style: "normal" },
{ family: '"Charis SIL"', url: "/static/fonts/charis-sil/CharisSIL-Italic.woff2", weight: "400", style: "italic" },
{ family: '"Charis SIL"', url: "/static/fonts/charis-sil/CharisSIL-BoldItalic.woff2", weight: "700", style: "italic" },
{ family: '"IBM Plex Serif"', url: "/static/fonts/ibm-plex/IBMPlexSerif-Regular.woff2", weight: "400", style: "normal" },
{ family: '"IBM Plex Serif"', url: "/static/fonts/ibm-plex/IBMPlexSerif-Bold.woff2", weight: "700", style: "normal" },
{ family: '"IBM Plex Serif"', url: "/static/fonts/ibm-plex/IBMPlexSerif-Italic.woff2", weight: "400", style: "italic" },
{ family: '"IBM Plex Serif"', url: "/static/fonts/ibm-plex/IBMPlexSerif-BoldItalic.woff2", weight: "700", style: "italic" },
{ family: '"Playwrite NZ Guides"', url: "/static/fonts/Playwrite_NZ_Guides/PlaywriteNZGuides-Regular.woff2", weight: "400", style: "normal" },
];
let fontBlobUrls: Record<string, string> = {};
let fontsLoaded = false;
async function loadFontBlobUrls(): Promise<void> {
if (fontsLoaded) return;
const entries = await Promise.all(
FONT_FILES.map(async (f) => {
try {
const resp = await fetch(f.url);
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob);
return { key: f.url, blobUrl };
} catch (_e) {
return { key: f.url, blobUrl: f.url };
}
}),
);
for (const { key, blobUrl } of entries) {
fontBlobUrls[key] = blobUrl;
}
fontsLoaded = true;
}
@font-face {
font-family: "Crimson Pro";
src: url("/static/fonts/crimson/CrimsonPro-Variable.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
function buildFontFaceCSS(): string {
return FONT_FILES.map((f) => {
const src = fontBlobUrls[f.url] ?? f.url;
return `@font-face {
font-family: ${f.family};
src: url("${src}") format("woff2");
font-weight: ${f.weight};
font-style: ${f.style};
}`;
}).join("\n");
}
@font-face {
font-family: "Crimson Pro";
src: url("/static/fonts/crimson/CrimsonPro-Italic-Variable.woff2") format("woff2");
font-weight: 100 900;
font-style: italic;
}
@font-face {
font-family: "Source Serif 4";
src: url("/static/fonts/source-serif/SourceSerif4-Variable.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
}
@font-face {
font-family: "Source Serif 4";
src: url("/static/fonts/source-serif/SourceSerif4-Italic-Variable.woff2") format("woff2");
font-weight: 100 900;
font-style: italic;
}
@font-face {
font-family: "EB Garamond";
src: url("/static/fonts/eb-garamond/EBGaramond-Variable.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
}
@font-face {
font-family: "EB Garamond";
src: url("/static/fonts/eb-garamond/EBGaramond-Italic-Variable.woff2") format("woff2");
font-weight: 100 900;
font-style: italic;
}
@font-face {
font-family: "Libertinus Serif";
src: url("/static/fonts/libertinus/LibertinusSerif-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "Libertinus Serif";
src: url("/static/fonts/libertinus/LibertinusSerif-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: "Libertinus Serif";
src: url("/static/fonts/libertinus/LibertinusSerif-Italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: "Libertinus Serif";
src: url("/static/fonts/libertinus/LibertinusSerif-BoldItalic.woff2") format("woff2");
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: "Noto Serif";
src: url("/static/fonts/noto-serif/NotoSerif-Variable.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
}
@font-face {
font-family: "Noto Serif";
src: url("/static/fonts/noto-serif/NotoSerif-Italic-Variable.woff2") format("woff2");
font-weight: 100 900;
font-style: italic;
}
@font-face {
font-family: "Charis SIL";
src: url("/static/fonts/charis-sil/CharisSIL-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "Charis SIL";
src: url("/static/fonts/charis-sil/CharisSIL-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: "Charis SIL";
src: url("/static/fonts/charis-sil/CharisSIL-Italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: "Charis SIL";
src: url("/static/fonts/charis-sil/CharisSIL-BoldItalic.woff2") format("woff2");
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: "IBM Plex Serif";
src: url("/static/fonts/ibm-plex/IBMPlexSerif-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Serif";
src: url("/static/fonts/ibm-plex/IBMPlexSerif-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Serif";
src: url("/static/fonts/ibm-plex/IBMPlexSerif-Italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: "IBM Plex Serif";
src: url("/static/fonts/ibm-plex/IBMPlexSerif-BoldItalic.woff2") format("woff2");
font-weight: 700;
font-style: italic;
}
`;
interface ThemeColors {
fg: string;
@@ -259,7 +193,7 @@ const getCSS = ({
}` : "";
return `
${FONT_FACE_RULES}
${buildFontFaceCSS()}
@namespace epub "http://www.idpf.org/2007/ops";
html {
color-scheme: light dark;
@@ -362,6 +296,7 @@ document.addEventListener("alpine:init", () => {
const themeColors = this.readingMode === "dark" ? themeSet.dark : themeSet.light;
viewport.style.backgroundColor = themeColors.bg;
this.view = document.getElementById("reader-view") as any;
await loadFontBlobUrls();
const resp = await fetch(config.fileUrl, {
headers: { Authorization: `Bearer ${getToken()}` },
});