fix(reader): inline custom @font-face rules in iframe stylesheet (#4383)

* fix(reader): inline custom @font-face rules in iframe stylesheet

The reader iframe's first paint resolved with the default serif/sans
fallback and only swapped to the user's configured custom font a moment
later, producing a visible font flash when opening a book.

Custom font @font-face rules were registered via mountCustomFont on the
host document only, while paginator's setStyles writes its CSS into
the iframe synchronously before the first 'load' event. The iframe had
no knowledge of the user's custom fonts at that point, so font-family
declarations in the stylesheet matched nothing and fell back.

Inline the @font-face rules for every loaded custom font (blob URLs
already in memory, no network round-trip) at the front of getStyles
output, so paginator delivers them to the iframe atomically with the
rest of the stylesheet. Defensive try/catch around createFontCSS keeps
a single bad font from breaking the whole stylesheet.

* refactor(reader): pass custom fonts into getStyles instead of reading the store

getStyles lives in src/utils, where every other file is a pure function;
it was the only one importing a store (useCustomFontStore). Keep the
util pure: accept the loaded custom fonts as a parameter and let the
reader components — which already own the font store — supply them.

- style.ts drops the useCustomFontStore import and the SSR/store-error
  guards in getCustomFontFaces; the helper is now a pure CustomFont[] ->
  CSS transform.
- getStyles(viewSettings, themeCode?, customFonts = []) inlines the
  @font-face rules for the passed fonts.
- The first-paint call sites (FoliateViewer, FootnotePopup) pass
  getLoadedFonts(); settings-panel re-styles keep the default [] since
  custom fonts are already mounted as persistent <style> elements there.
- Add tests that exercise the font-face inlining path (the existing
  suite never did, since the store is empty under jsdom).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
loveheaven
2026-05-31 23:24:47 +08:00
committed by GitHub
parent 9b4db44490
commit e8675fb7eb
4 changed files with 97 additions and 5 deletions
@@ -9,6 +9,7 @@ vi.mock('@/utils/misc', async (importOriginal) => {
});
import { getStyles, ThemeCode } from '@/utils/style';
import { CustomFont } from '@/styles/fonts';
import { ViewSettings } from '@/types/book';
import {
DEFAULT_BOOK_FONT,
@@ -719,3 +720,63 @@ describe('getStyles integration', () => {
expect(css).toContain('--theme-bg-color');
});
});
// ---------------------------------------------------------------------------
// custom @font-face inlining
// ---------------------------------------------------------------------------
/** Build a loaded CustomFont (blob URL in memory) for testing. */
function makeCustomFont(overrides: Partial<CustomFont> = {}): CustomFont {
return {
id: 'my-test-font',
name: 'My Test Font',
path: '/fonts/my-test-font.ttf',
blobUrl: 'blob:http://localhost/my-test-font',
...overrides,
};
}
describe('custom @font-face inlining (via getStyles)', () => {
const theme = makeThemeCode();
it('inlines an @font-face rule for each loaded custom font', () => {
const vs = makeViewSettings();
const css = getStyles(vs, theme, [makeCustomFont()]);
expect(css).toContain('@font-face');
expect(css).toContain('font-family: "My Test Font"');
expect(css).toContain('blob:http://localhost/my-test-font');
});
it('inlines the @font-face rules ahead of the rest of the stylesheet', () => {
const vs = makeViewSettings();
const css = getStyles(vs, theme, [makeCustomFont()]);
// Paginator writes this CSS into the iframe before its first paint,
// so the custom @font-face must precede the font-family declarations
// that reference it (layout styles begin with `@namespace epub`).
expect(css.indexOf('@font-face')).toBeLessThan(css.indexOf('@namespace epub'));
});
it('emits one @font-face per loaded font', () => {
const vs = makeViewSettings();
const fonts = [
makeCustomFont({ id: 'font-a', name: 'Font A', blobUrl: 'blob:http://localhost/a' }),
makeCustomFont({ id: 'font-b', name: 'Font B', blobUrl: 'blob:http://localhost/b' }),
];
const css = getStyles(vs, theme, fonts);
expect(css.split('@font-face').length - 1).toBe(2);
expect(css).toContain('font-family: "Font A"');
expect(css).toContain('font-family: "Font B"');
});
it('skips fonts that have no blob URL', () => {
const vs = makeViewSettings();
const css = getStyles(vs, theme, [makeCustomFont({ blobUrl: undefined })]);
expect(css).not.toContain('font-family: "My Test Font"');
});
it('emits no custom @font-face when no fonts are passed', () => {
const vs = makeViewSettings();
const css = getStyles(vs, theme);
expect(css).not.toContain('font-family: "My Test Font"');
});
});
@@ -558,7 +558,7 @@ const FoliateViewer: React.FC<{
const width = viewWidth - insets.left - insets.right;
const height = viewHeight - insets.top - insets.bottom;
book.transformTarget?.addEventListener('data', getDocTransformHandler({ width, height }));
view.renderer.setStyles?.(getStyles(viewSettings));
view.renderer.setStyles?.(getStyles(viewSettings, undefined, getLoadedFonts()));
applyTranslationStyle(viewSettings);
doubleClickDisabled.current = viewSettings.disableDoubleClick!;
@@ -685,7 +685,7 @@ const FoliateViewer: React.FC<{
if (viewRef.current && viewRef.current.renderer) {
const renderer = viewRef.current.renderer;
const viewSettings = getViewSettings(bookKey)!;
viewRef.current.renderer.setStyles?.(getStyles(viewSettings));
viewRef.current.renderer.setStyles?.(getStyles(viewSettings, undefined, getLoadedFonts()));
const docs = viewRef.current.renderer.getContents();
docs.forEach(({ doc }) => {
if (bookDoc.rendition?.layout === 'pre-paginated') {
@@ -150,7 +150,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const backgroundColor = getComputedStyle(popupContainer).backgroundColor;
popupTheme.bg = backgroundColor;
}
const mainStyles = getStyles(viewSettings, popupTheme);
const mainStyles = getStyles(viewSettings, popupTheme, getLoadedFonts());
const footnoteStyles = getFootnoteStyles();
renderer.setStyles?.(`${mainStyles}\n${footnoteStyles}`);
};
+33 -2
View File
@@ -14,6 +14,7 @@ import {
generateLightPalette,
generateDarkPalette,
} from '@/styles/themes';
import { createFontCSS, CustomFont } from '@/styles/fonts';
import { getOSPlatform } from './misc';
const getFontStyles = (
@@ -691,7 +692,11 @@ export const getThemeCode = () => {
} as ThemeCode;
};
export const getStyles = (viewSettings: ViewSettings, themeCode?: ThemeCode) => {
export const getStyles = (
viewSettings: ViewSettings,
themeCode?: ThemeCode,
customFonts: CustomFont[] = [],
) => {
if (!themeCode) {
themeCode = getThemeCode();
}
@@ -733,6 +738,14 @@ export const getStyles = (viewSettings: ViewSettings, themeCode?: ThemeCode) =>
viewSettings.fontWeight!,
viewSettings.overrideFont!,
);
// Inline `@font-face` rules for the caller-supplied custom fonts so
// they ship to the iframe synchronously with the rest of the
// stylesheet. Paginator injects this CSS into the iframe `<style>`
// before the 'load' event fires, so the first paint already resolves
// the configured font instead of falling back to serif/sans-serif and
// visibly swapping a moment later. Blob URLs are already in memory, so
// no network round-trip happens here.
const customFontFaces = getCustomFontFaces(customFonts);
const colorStyles = getColorStyles(
viewSettings.overrideColor!,
viewSettings.invertImgColorInDark!,
@@ -744,9 +757,27 @@ export const getStyles = (viewSettings: ViewSettings, themeCode?: ThemeCode) =>
const warichuStyles = getWarichuStyles();
const rubyStyles = getRubyStyles();
const userStylesheet = viewSettings.userStylesheet!;
return `${pageLayoutStyles}\n${paragraphLayoutStyles}\n${fontStyles}\n${colorStyles}\n${translationStyles}\n${warichuStyles}\n${rubyStyles}\n${userStylesheet}`;
return `${customFontFaces}\n${pageLayoutStyles}\n${paragraphLayoutStyles}\n${fontStyles}\n${colorStyles}\n${translationStyles}\n${warichuStyles}\n${rubyStyles}\n${userStylesheet}`;
};
// Build a CSS chunk of `@font-face` rules for the given user custom
// fonts. The caller (a reader component) owns the font store and passes
// in the loaded fonts, keeping this util free of store dependencies.
// Fonts without a blob URL are skipped; createFontCSS throws when the
// blob URL is unset, so the inner try/catch keeps a single bad font from
// breaking the whole stylesheet.
const getCustomFontFaces = (fonts: CustomFont[]): string =>
fonts
.filter((font) => !!font.blobUrl)
.map((font) => {
try {
return createFontCSS(font);
} catch {
return '';
}
})
.join('\n');
export const applyTranslationStyle = (viewSettings: ViewSettings) => {
const styleId = 'translation-style';