diff --git a/apps/readest-app/src/__tests__/utils/style-get-styles.test.ts b/apps/readest-app/src/__tests__/utils/style-get-styles.test.ts index f0faef09..eb752073 100644 --- a/apps/readest-app/src/__tests__/utils/style-get-styles.test.ts +++ b/apps/readest-app/src/__tests__/utils/style-get-styles.test.ts @@ -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 { + 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"'); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index d0c97075..7a577c6f 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -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') { diff --git a/apps/readest-app/src/app/reader/components/FootnotePopup.tsx b/apps/readest-app/src/app/reader/components/FootnotePopup.tsx index 2a57e6cc..d7e45715 100644 --- a/apps/readest-app/src/app/reader/components/FootnotePopup.tsx +++ b/apps/readest-app/src/app/reader/components/FootnotePopup.tsx @@ -150,7 +150,7 @@ const FootnotePopup: React.FC = ({ 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}`); }; diff --git a/apps/readest-app/src/utils/style.ts b/apps/readest-app/src/utils/style.ts index 72026008..2a99d059 100644 --- a/apps/readest-app/src/utils/style.ts +++ b/apps/readest-app/src/utils/style.ts @@ -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 `