Files
readest/apps/readest-app/src/__tests__/utils/style-get-styles.test.ts
T
loveheaven e8675fb7eb 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>
2026-05-31 17:24:47 +02:00

783 lines
30 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
vi.mock('@/utils/misc', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
getOSPlatform: vi.fn(() => 'macos' as const),
};
});
import { getStyles, ThemeCode } from '@/utils/style';
import { CustomFont } from '@/styles/fonts';
import { ViewSettings } from '@/types/book';
import {
DEFAULT_BOOK_FONT,
DEFAULT_BOOK_LAYOUT,
DEFAULT_BOOK_LANGUAGE,
DEFAULT_BOOK_STYLE,
DEFAULT_VIEW_CONFIG,
DEFAULT_TTS_CONFIG,
DEFAULT_TRANSLATOR_CONFIG,
DEFAULT_ANNOTATOR_CONFIG,
DEFAULT_SCREEN_CONFIG,
} from '@/services/constants';
/** Build a full ViewSettings from all DEFAULT_* constants, with overrides. */
function makeViewSettings(overrides: Partial<ViewSettings> = {}): ViewSettings {
return {
...DEFAULT_BOOK_FONT,
...DEFAULT_BOOK_LAYOUT,
...DEFAULT_BOOK_LANGUAGE,
...DEFAULT_BOOK_STYLE,
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
...DEFAULT_TRANSLATOR_CONFIG,
...DEFAULT_ANNOTATOR_CONFIG,
...DEFAULT_SCREEN_CONFIG,
...overrides,
} as ViewSettings;
}
/** Build a ThemeCode for testing. */
function makeThemeCode(overrides: Partial<ThemeCode> = {}): ThemeCode {
return {
bg: '#ffffff',
fg: '#000000',
primary: '#3366cc',
isDarkMode: false,
palette: {
'base-100': '#ffffff',
'base-200': '#f0f0f0',
'base-300': '#e0e0e0',
'base-content': '#000000',
neutral: '#808080',
'neutral-content': '#ffffff',
primary: '#3366cc',
secondary: '#6699cc',
accent: '#33cc99',
},
...overrides,
};
}
// ---------------------------------------------------------------------------
// getFontStyles branches
// ---------------------------------------------------------------------------
describe('getFontStyles branches (via getStyles)', () => {
const theme = makeThemeCode();
it('uses --serif variable when defaultFont is "Serif"', () => {
const vs = makeViewSettings({ defaultFont: 'Serif' });
const css = getStyles(vs, theme);
expect(css).toContain('font-family: var(--serif)');
});
it('uses --sans-serif variable when defaultFont is "Sans-serif"', () => {
const vs = makeViewSettings({ defaultFont: 'Sans-serif' });
const css = getStyles(vs, theme);
expect(css).toContain('font-family: var(--sans-serif)');
});
it('adds !important when overrideFont is true', () => {
const vs = makeViewSettings({ overrideFont: true, defaultFont: 'Serif' });
const css = getStyles(vs, theme);
expect(css).toContain('font-family: var(--serif) !important');
// The body block should also have the font-family !important
expect(css).toContain('font-family: revert !important');
});
it('does not add !important when overrideFont is false', () => {
const vs = makeViewSettings({ overrideFont: false, defaultFont: 'Serif' });
const css = getStyles(vs, theme);
// The html rule should NOT have !important after the var
expect(css).toMatch(/font-family: var\(--serif\)\s*[^!]/);
// And body block should not have font-family at all
expect(css).not.toContain('font-family: revert !important');
});
it('sets font-size according to defaultFontSize', () => {
const vs = makeViewSettings({ defaultFontSize: 20 });
const css = getStyles(vs, theme);
// On macos, fontScale is 1, zoomLevel defaults to 100 so zoomScale is 1
expect(css).toContain('font-size: 20px !important');
});
it('sets minimum font-size via --min-font-size', () => {
const vs = makeViewSettings({ minimumFontSize: 10 });
const css = getStyles(vs, theme);
expect(css).toContain('--min-font-size: 10px');
});
it('sets font-weight', () => {
const vs = makeViewSettings({ fontWeight: 700 });
const css = getStyles(vs, theme);
expect(css).toContain('--font-weight: 700');
expect(css).toContain('font-weight: 700');
});
it('includes CJK font in serif font list when defaultCJKFont differs from serifFont', () => {
const vs = makeViewSettings({
serifFont: 'Bitter',
defaultCJKFont: 'Source Han Serif CN',
});
const css = getStyles(vs, theme);
expect(css).toContain('"Source Han Serif CN"');
});
it('does not duplicate CJK font in serif list when it equals serifFont', () => {
const vs = makeViewSettings({
serifFont: 'LXGW WenKai GB Screen',
defaultCJKFont: 'LXGW WenKai GB Screen',
});
const css = getStyles(vs, theme);
// The font should appear only once in the --serif declaration
const match = css.match(/--serif:([^;]+);/);
expect(match).not.toBeNull();
const serifDecl = match![1]!;
const occurrences = serifDecl.split('"LXGW WenKai GB Screen"').length - 1;
expect(occurrences).toBe(1);
});
it('includes CJK font in sans-serif list when defaultCJKFont differs from sansSerifFont', () => {
const vs = makeViewSettings({
sansSerifFont: 'Roboto',
defaultCJKFont: 'Noto Sans SC',
});
const css = getStyles(vs, theme);
const match = css.match(/--sans-serif:([^;]+);/);
expect(match).not.toBeNull();
expect(match![1]).toContain('"Noto Sans SC"');
});
it('does not duplicate CJK font in sans-serif list when it equals sansSerifFont', () => {
const vs = makeViewSettings({
sansSerifFont: 'Noto Sans SC',
defaultCJKFont: 'Noto Sans SC',
});
const css = getStyles(vs, theme);
const match = css.match(/--sans-serif:([^;]+);/);
expect(match).not.toBeNull();
const sansDecl = match![1]!;
const occurrences = sansDecl.split('"Noto Sans SC"').length - 1;
expect(occurrences).toBe(1);
});
it('puts monospace font first in the monospace list', () => {
const vs = makeViewSettings({ monospaceFont: 'Fira Code' });
const css = getStyles(vs, theme);
const match = css.match(/--monospace:\s*([^;]+);/);
expect(match).not.toBeNull();
expect(match![1]!.trimStart().startsWith('"Fira Code"')).toBe(true);
});
it('applies zoomLevel scaling to font size', () => {
const vs = makeViewSettings({ defaultFontSize: 16, zoomLevel: 200 });
const css = getStyles(vs, theme);
// 16 * 1 (fontScale on macos) * (200/100) = 32
expect(css).toContain('font-size: 32px !important');
});
it('includes font[size] rules for all sizes 1-7', () => {
const vs = makeViewSettings({ defaultFontSize: 16, minimumFontSize: 8 });
const css = getStyles(vs, theme);
expect(css).toContain('font[size="1"]');
expect(css).toContain('font[size="7"]');
});
});
// ---------------------------------------------------------------------------
// getLayoutStyles branches
// ---------------------------------------------------------------------------
describe('getLayoutStyles branches (via getStyles)', () => {
const theme = makeThemeCode();
it('sets text-align to justify when fullJustification is true', () => {
const vs = makeViewSettings({ fullJustification: true });
const css = getStyles(vs, theme);
expect(css).toContain('--default-text-align: justify');
});
it('sets text-align to start when fullJustification is false', () => {
const vs = makeViewSettings({ fullJustification: false });
const css = getStyles(vs, theme);
expect(css).toContain('--default-text-align: start');
});
it('sets margin CSS variables from viewSettings', () => {
const vs = makeViewSettings({
marginTopPx: 50,
marginRightPx: 30,
marginBottomPx: 40,
marginLeftPx: 20,
});
const css = getStyles(vs, theme);
expect(css).toContain('--margin-top: 50px');
expect(css).toContain('--margin-right: 30px');
expect(css).toContain('--margin-bottom: 40px');
expect(css).toContain('--margin-left: 20px');
});
it('sets hyphens to auto when hyphenation is true', () => {
const vs = makeViewSettings({ hyphenation: true });
const css = getStyles(vs, theme);
expect(css).toContain('hyphens: auto');
expect(css).toContain('-webkit-hyphens: auto');
});
it('sets hyphens to manual when hyphenation is false', () => {
const vs = makeViewSettings({ hyphenation: false });
const css = getStyles(vs, theme);
expect(css).toContain('hyphens: manual');
expect(css).toContain('-webkit-hyphens: manual');
});
it('adds !important to layout properties when overrideLayout is true', () => {
const vs = makeViewSettings({ overrideLayout: true, lineHeight: 1.6 });
const css = getStyles(vs, theme);
expect(css).toContain('line-height: 1.6 !important');
});
it('does not add !important to layout properties when overrideLayout is false', () => {
const vs = makeViewSettings({ overrideLayout: false, lineHeight: 1.6 });
const css = getStyles(vs, theme);
// Should have line-height: 1.6 without !important after it
expect(css).toMatch(/line-height: 1\.6\s*[^!]/);
});
it('sets word-spacing and letter-spacing', () => {
const vs = makeViewSettings({ wordSpacing: 2, letterSpacing: 1 });
const css = getStyles(vs, theme);
expect(css).toContain('word-spacing: 2px');
expect(css).toContain('letter-spacing: 1px');
});
it('sets text-indent', () => {
const vs = makeViewSettings({ textIndent: 2 });
const css = getStyles(vs, theme);
expect(css).toContain('text-indent: 2em');
});
it('sets paragraph margins vertically (margin-left/right) when vertical is true', () => {
const vs = makeViewSettings({ vertical: true, paragraphMargin: 1.0 });
const css = getStyles(vs, theme);
expect(css).toContain('margin-left: 1em');
expect(css).toContain('margin-right: 1em');
expect(css).toContain('margin-top: unset');
expect(css).toContain('margin-bottom: unset');
});
it('sets paragraph margins horizontally (margin-top/bottom) when vertical is false', () => {
const vs = makeViewSettings({ vertical: false, paragraphMargin: 0.8 });
const css = getStyles(vs, theme);
expect(css).toContain('margin-top: 0.8em');
expect(css).toContain('margin-bottom: 0.8em');
expect(css).toContain('margin-left: unset');
expect(css).toContain('margin-right: unset');
});
it('sets div margins with !important when vertical and overrideLayout are both true', () => {
const vs = makeViewSettings({
vertical: true,
overrideLayout: true,
paragraphMargin: 0.5,
});
const css = getStyles(vs, theme);
expect(css).toContain('margin-left: 0.5em !important');
expect(css).toContain('margin-right: 0.5em !important');
});
it('sets div margins with !important when vertical is false and overrideLayout is true', () => {
const vs = makeViewSettings({
vertical: false,
overrideLayout: true,
paragraphMargin: 0.5,
});
const css = getStyles(vs, theme);
expect(css).toContain('margin-top: 0.5em !important');
expect(css).toContain('margin-bottom: 0.5em !important');
});
it('does not set div margins when overrideLayout is false', () => {
const vs = makeViewSettings({
vertical: false,
overrideLayout: false,
paragraphMargin: 0.5,
});
const css = getStyles(vs, theme);
// The div block should not have paragraph margin !important
// (the template literals produce empty strings when conditions are false)
expect(css).not.toMatch(/div\s*\{[^}]*margin-top:\s*0\.5em\s*!important/);
});
it('applies writing-mode when not auto', () => {
const vs = makeViewSettings({ writingMode: 'vertical-rl' });
const css = getStyles(vs, theme);
expect(css).toContain('writing-mode: vertical-rl !important');
});
it('does not set writing-mode when it is auto', () => {
const vs = makeViewSettings({ writingMode: 'auto' });
const css = getStyles(vs, theme);
expect(css).not.toContain('writing-mode: auto');
expect(css).not.toContain('writing-mode: vertical');
expect(css).not.toContain('writing-mode: horizontal');
});
it('applies justify override for aligned-left when justify and overrideLayout are true', () => {
const vs = makeViewSettings({ fullJustification: true, overrideLayout: true });
const css = getStyles(vs, theme);
expect(css).toContain('text-align: justify !important');
});
it('applies initial override for aligned-justify when justify is false and overrideLayout is true', () => {
const vs = makeViewSettings({ fullJustification: false, overrideLayout: true });
const css = getStyles(vs, theme);
expect(css).toContain('text-align: initial !important');
});
it('applies img.pi vertical transforms when vertical is true', () => {
const vs = makeViewSettings({ vertical: true, lineHeight: 1.5 });
const css = getStyles(vs, theme);
expect(css).toContain('transform: rotate(90deg)');
expect(css).toContain('transform-origin: center');
expect(css).toContain('height: 2em');
expect(css).toContain('width: 1.5em');
});
it('does not apply img.pi vertical transforms when vertical is false', () => {
const vs = makeViewSettings({ vertical: false });
const css = getStyles(vs, theme);
expect(css).not.toContain('transform: rotate(90deg)');
});
it('sets img.has-text-siblings width when vertical', () => {
const vs = makeViewSettings({ vertical: true });
const css = getStyles(vs, theme);
expect(css).toMatch(/img\.has-text-siblings\s*\{[^}]*width:\s*1em/);
});
it('sets img.has-text-siblings height when not vertical', () => {
const vs = makeViewSettings({ vertical: false });
const css = getStyles(vs, theme);
expect(css).toMatch(/img\.has-text-siblings\s*\{[^}]*height:\s*1em/);
});
it('includes zoom: 1 when zoomLevel is 100 (passed as 1.0)', () => {
const vs = makeViewSettings({ zoomLevel: 100 });
const css = getStyles(vs, theme);
// getStyles passes 1.0 as zoomLevel to getLayoutStyles
expect(css).toContain('zoom: 1');
});
it('omits only paragraph-related layout rules when useBookLayout is true', () => {
const vs = makeViewSettings({
useBookLayout: true,
lineHeight: 1.7,
wordSpacing: 3,
letterSpacing: 2,
textIndent: 2,
paragraphMargin: 1.25,
fullJustification: true,
hyphenation: true,
marginTopPx: 50,
});
const css = getStyles(vs, theme);
// paragraph-specific tokens driven by the Paragraph section must be absent
expect(css).not.toContain('--default-text-align:');
expect(css).not.toContain('line-height: 1.7');
expect(css).not.toContain('word-spacing: 3px');
expect(css).not.toContain('letter-spacing: 2px');
expect(css).not.toContain('text-indent: 2em');
expect(css).not.toContain('hyphens: auto');
expect(css).not.toContain('-webkit-hyphens: auto');
// non-paragraph layout rules must still be emitted
expect(css).toContain('@namespace epub');
expect(css).toContain('--margin-top: 50px');
expect(css).toContain('--margin-right:');
expect(css).toContain('--margin-bottom:');
expect(css).toContain('--margin-left:');
// font/color/translation sections must still be present
expect(css).toContain('--serif:');
expect(css).toContain('--theme-bg-color');
expect(css).toContain('.translation-source');
});
it('includes paragraph layout rules when useBookLayout is false', () => {
const vs = makeViewSettings({
useBookLayout: false,
lineHeight: 1.7,
wordSpacing: 3,
letterSpacing: 2,
textIndent: 2,
fullJustification: true,
hyphenation: true,
});
const css = getStyles(vs, theme);
expect(css).toContain('--default-text-align: justify');
expect(css).toContain('line-height: 1.7');
expect(css).toContain('word-spacing: 3px');
expect(css).toContain('letter-spacing: 2px');
expect(css).toContain('text-indent: 2em');
expect(css).toContain('hyphens: auto');
});
});
// ---------------------------------------------------------------------------
// getColorStyles branches
// ---------------------------------------------------------------------------
describe('getColorStyles branches (via getStyles)', () => {
it('sets color-scheme to light when isDarkMode is false', () => {
const vs = makeViewSettings();
const theme = makeThemeCode({ isDarkMode: false });
const css = getStyles(vs, theme);
expect(css).toContain('color-scheme: light');
});
it('sets color-scheme to dark when isDarkMode is true', () => {
const vs = makeViewSettings();
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
const css = getStyles(vs, theme);
expect(css).toContain('color-scheme: dark');
});
it('sets theme CSS variables', () => {
const vs = makeViewSettings();
const theme = makeThemeCode({ bg: '#fafafa', fg: '#111111', primary: '#0055cc' });
const css = getStyles(vs, theme);
expect(css).toContain('--theme-bg-color: #fafafa');
expect(css).toContain('--theme-fg-color: #111111');
expect(css).toContain('--theme-primary-color: #0055cc');
});
it('forces background-color and color on elements when overrideColor is true', () => {
const vs = makeViewSettings({ overrideColor: true });
const theme = makeThemeCode({ bg: '#fff', fg: '#000' });
const css = getStyles(vs, theme);
expect(css).toContain('background-color: #fff !important');
expect(css).toContain('color: #000 !important');
expect(css).toContain('border-color: #000 !important');
});
it('does not force color overrides when overrideColor is false', () => {
const vs = makeViewSettings({ overrideColor: false });
const theme = makeThemeCode({ bg: '#fff', fg: '#000' });
const css = getStyles(vs, theme);
// Should not have !important color override on section/div/p etc
expect(css).not.toMatch(/section,.*\{[^}]*color: #000 !important/);
});
it('applies invert filter on images when isDarkMode and invertImgColorInDark are true', () => {
const vs = makeViewSettings({ invertImgColorInDark: true });
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
const css = getStyles(vs, theme);
expect(css).toContain('filter: invert(100%)');
});
it('does not apply invert filter when isDarkMode is false', () => {
const vs = makeViewSettings({ invertImgColorInDark: true });
const theme = makeThemeCode({ isDarkMode: false });
const css = getStyles(vs, theme);
expect(css).not.toContain('filter: invert(100%)');
});
it('does not apply invert filter when invertImgColorInDark is false', () => {
const vs = makeViewSettings({ invertImgColorInDark: false });
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
const css = getStyles(vs, theme);
expect(css).not.toContain('filter: invert(100%)');
});
it('applies mix-blend-mode multiply on img when not dark and overrideColor is true', () => {
const vs = makeViewSettings({ overrideColor: true });
const theme = makeThemeCode({ isDarkMode: false });
const css = getStyles(vs, theme);
expect(css).toContain('mix-blend-mode: multiply');
});
it('does not apply mix-blend-mode multiply when overrideColor is false and not dark', () => {
const vs = makeViewSettings({ overrideColor: false });
const theme = makeThemeCode({ isDarkMode: false });
const css = getStyles(vs, theme);
// mix-blend-mode: multiply on img should not appear; there's one for hr.background-img and
// has-text-siblings (which is always present), but not in the img block
expect(css).not.toMatch(/^\s*img\s*\{[^}]*mix-blend-mode: multiply/m);
});
it('sets bg-texture-id CSS variable', () => {
const vs = makeViewSettings({ backgroundTextureId: 'paper' });
const theme = makeThemeCode();
const css = getStyles(vs, theme);
expect(css).toContain('--bg-texture-id: paper');
});
it('sets bg-texture-id to none', () => {
const vs = makeViewSettings({ backgroundTextureId: 'none' });
const theme = makeThemeCode();
const css = getStyles(vs, theme);
expect(css).toContain('--bg-texture-id: none');
});
it('includes eink selection styles when isEink is true', () => {
const vs = makeViewSettings({ isEink: true });
const theme = makeThemeCode();
const css = getStyles(vs, theme);
expect(css).toContain('::selection');
expect(css).toContain('::-moz-selection');
expect(css).toContain('background: var(--theme-fg-color)');
});
it('does not include eink selection styles when isEink is false', () => {
const vs = makeViewSettings({ isEink: false });
const theme = makeThemeCode();
const css = getStyles(vs, theme);
expect(css).not.toContain('::selection');
expect(css).not.toContain('::-moz-selection');
});
it('sets text-decoration to underline for links when isEink is true', () => {
const vs = makeViewSettings({ isEink: true });
const theme = makeThemeCode();
const css = getStyles(vs, theme);
expect(css).toContain('text-decoration: underline');
});
it('sets text-decoration to none for links when isEink is false', () => {
const vs = makeViewSettings({ isEink: false });
const theme = makeThemeCode();
const css = getStyles(vs, theme);
expect(css).toContain('text-decoration: none');
});
it('forces eink background on body when isEink is true', () => {
const vs = makeViewSettings({ isEink: true });
const theme = makeThemeCode({ bg: '#ffffff' });
const css = getStyles(vs, theme);
expect(css).toContain('background-color: #ffffff !important');
});
it('sets body.pbg background in dark mode', () => {
const vs = makeViewSettings();
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
const css = getStyles(vs, theme);
expect(css).toMatch(/body\.pbg\s*\{[^}]*background-color:\s*#1a1a1a\s*!important/);
});
it('does not set body.pbg background in light mode', () => {
const vs = makeViewSettings();
const theme = makeThemeCode({ isDarkMode: false });
const css = getStyles(vs, theme);
// body.pbg block should be empty or absent
expect(css).not.toMatch(/body\.pbg\s*\{[^}]*background-color:[^}]*!important/);
});
it('applies dark mode link color lightblue when overrideColor is false', () => {
const vs = makeViewSettings({ overrideColor: false });
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
const css = getStyles(vs, theme);
expect(css).toContain('color: lightblue');
});
it('applies primary color on links when overrideColor is true', () => {
const vs = makeViewSettings({ overrideColor: true });
const theme = makeThemeCode({
isDarkMode: true,
bg: '#1a1a1a',
fg: '#e0e0e0',
primary: '#ff6600',
});
const css = getStyles(vs, theme);
expect(css).toContain('color: #ff6600 !important');
});
it('does not apply lightblue link color in light mode', () => {
const vs = makeViewSettings({ overrideColor: false });
const theme = makeThemeCode({ isDarkMode: false });
const css = getStyles(vs, theme);
expect(css).not.toContain('color: lightblue');
});
it('applies dark code styles when isDarkMode is true', () => {
const vs = makeViewSettings();
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
const css = getStyles(vs, theme);
expect(css).toContain('body.theme-dark code');
expect(css).toContain('color: #e0e0e0cc');
expect(css).toContain('color-mix(in srgb, #1a1a1a 90%, #000)');
});
it('applies dark blockquote background in dark mode', () => {
const vs = makeViewSettings();
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
const css = getStyles(vs, theme);
expect(css).toContain('color-mix(in srgb, #1a1a1a 80%, #000)');
});
it('applies dark table override when isDarkMode and overrideColor are both true', () => {
const vs = makeViewSettings({ overrideColor: true });
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
const css = getStyles(vs, theme);
// blockquote, table * rule should have background with color-mix
expect(css).toMatch(
/blockquote,\s*table\s*\*\s*\{[^}]*background:\s*color-mix\(in srgb,\s*#1a1a1a\s*80%,\s*#000\)/,
);
});
it('makes svg/img backgrounds transparent when overrideColor is true', () => {
const vs = makeViewSettings({ overrideColor: true });
const theme = makeThemeCode();
const css = getStyles(vs, theme);
expect(css).toMatch(/svg,\s*img\s*\{[^}]*background-color:\s*transparent\s*!important/);
});
it('applies screen blend mode for inline images in dark mode', () => {
const vs = makeViewSettings();
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
const css = getStyles(vs, theme);
expect(css).toContain('mix-blend-mode: screen');
});
it('applies multiply blend mode for inline images in light mode', () => {
const vs = makeViewSettings();
const theme = makeThemeCode({ isDarkMode: false });
const css = getStyles(vs, theme);
expect(css).toContain('mix-blend-mode: multiply');
});
it('sets inline image parent background when overrideColor is true', () => {
const vs = makeViewSettings({ overrideColor: true });
const theme = makeThemeCode({ bg: '#fafafa' });
const css = getStyles(vs, theme);
expect(css).toMatch(
/\*:has\(>\s*img\.has-text-siblings\):not\(body\)\s*\{[^}]*background-color:\s*#fafafa/,
);
});
});
// ---------------------------------------------------------------------------
// getTranslationStyles branches
// ---------------------------------------------------------------------------
describe('getTranslationStyles branches (via getStyles)', () => {
const theme = makeThemeCode();
it('adds margin to translation-target-block when showTranslateSource is true', () => {
const vs = makeViewSettings({ showTranslateSource: true });
const css = getStyles(vs, theme);
expect(css).toContain('margin: 0.5em 0 !important');
});
it('does not add margin to translation-target-block when showTranslateSource is false', () => {
const vs = makeViewSettings({ showTranslateSource: false });
const css = getStyles(vs, theme);
expect(css).not.toContain('margin: 0.5em 0 !important');
});
it('always includes translation-source and translation-target classes', () => {
const vs = makeViewSettings({ showTranslateSource: false });
const css = getStyles(vs, theme);
expect(css).toContain('.translation-source');
expect(css).toContain('.translation-target');
expect(css).toContain('.translation-target.hidden');
expect(css).toContain('.translation-target-block');
expect(css).toContain('.translation-target-toc');
});
});
// ---------------------------------------------------------------------------
// getStyles integration: userStylesheet appended
// ---------------------------------------------------------------------------
describe('getStyles integration', () => {
const theme = makeThemeCode();
it('appends userStylesheet content at the end', () => {
const customCSS = 'body { color: red !important; }';
const vs = makeViewSettings({ userStylesheet: customCSS });
const css = getStyles(vs, theme);
expect(css).toContain(customCSS);
// Should be at the end
expect(css.indexOf(customCSS)).toBe(css.length - customCSS.length);
});
it('concatenates all style sections', () => {
const vs = makeViewSettings();
const css = getStyles(vs, theme);
// layout styles
expect(css).toContain('@namespace epub');
// font styles
expect(css).toContain('--serif:');
// color styles
expect(css).toContain('--theme-bg-color');
// translation styles
expect(css).toContain('.translation-source');
});
it('uses default themeCode (via getThemeCode) when none is provided', () => {
const vs = makeViewSettings();
// Should not throw even without a themeCode, though getThemeCode uses
// localStorage which is mocked by jsdom as empty
const css = getStyles(vs);
expect(css).toBeTruthy();
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"');
});
});