fix(reader): scale publisher font sizes with the reader text size

Rewrite absolute publisher font sizes (keywords and px/pt lengths) as each EPUB loads so they
follow the reader's root size, falling back to the original value when the reader applies no
styles. Skip fixed-layout books and SVG content, and cover the rewrite and the foliate loader
hook with regression tests.

Fixes #1276
This commit is contained in:
neon
2026-10-12 22:05:19 +00:00
parent b0f0e36d30
commit ddc7e848b9
8 changed files with 360 additions and 3 deletions
@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { scalePublisherFontSizes } from '../lib/publisher-font-sizes'
// @ts-expect-error public Foliate asset has no TypeScript declarations.
import { EPUB } from '../../../../../public/assets/foliate/epub.js'
const CONTAINER = `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OPS/book.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`
const OPF = `<?xml version="1.0" encoding="UTF-8"?>
<package version="3.0" unique-identifier="id" xmlns="http://www.idpf.org/2007/opf">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Sizes</dc:title><dc:identifier id="id">urn:uuid:sizes</dc:identifier><dc:language>en</dc:language></metadata>
<manifest>
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/>
<item id="style" href="style.css" media-type="text/css"/>
</manifest>
<spine><itemref idref="chapter"/></spine>
</package>`
const STYLESHEET = 'body { font-size: medium; }\nh1 { font-size: 1.5em; }'
const CHAPTER = `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Chapter</title><link rel="stylesheet" type="text/css" href="style.css"/><style>p.note { font-size: small; }</style></head>
<body><p style="font-size: 10pt">Set font-size: 12px in your stylesheet.</p>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"><style>text { font-size: 12px; }</style><text x="0" y="15" style="font-size: 12px">Label</text></svg>
</body></html>`
const files: Record<string, string> = {
'META-INF/container.xml': CONTAINER,
'OPS/book.opf': OPF,
'OPS/style.css': STYLESHEET,
'OPS/chapter.xhtml': CHAPTER,
}
async function loadChapter(configure: (book: { transformTarget: EventTarget }) => void = () => {}) {
const book = await new EPUB({
loadText: async (uri: string) => files[uri] ?? null,
loadBlob: async () => null,
getSize: () => 0,
}).init()
configure(book)
const loaded = new Map<string, string>()
book.transformTarget.addEventListener('data', (event: Event) => {
const detail = (event as CustomEvent<{ name: string; data: string }>).detail
loaded.set(detail.name, detail.data)
})
await book.sections[0].load()
return { css: loaded.get('OPS/style.css')!, chapter: new DOMParser().parseFromString(loaded.get('OPS/chapter.xhtml')!, 'application/xhtml+xml') }
}
describe('publisher font sizes in the EPUB loader', () => {
const originalCreateObjectURL = URL.createObjectURL
beforeEach(() => {
URL.createObjectURL = vi.fn<(blob: Blob) => string>(() => 'blob:resource')
})
afterEach(() => {
URL.createObjectURL = originalCreateObjectURL
})
it('passes book CSS through unchanged when nothing listens for it', async () => {
const { css, chapter } = await loadChapter()
expect(css).toBe(STYLESHEET)
expect(chapter.querySelector('head > style')?.textContent).toBe('p.note { font-size: small; }')
expect(chapter.querySelector('p')?.getAttribute('style')).toBe('font-size: 10pt')
})
it('makes stylesheet, style element and style attribute sizes scalable', async () => {
const { css, chapter } = await loadChapter(scalePublisherFontSizes)
expect(css).toBe('body { font-size: var(--bookorbit-font-medium, medium); }\nh1 { font-size: 1.5em; }')
expect(chapter.querySelector('head > style')?.textContent).toBe('p.note { font-size: var(--bookorbit-font-small, small); }')
expect(chapter.querySelector('p')?.getAttribute('style')).toBe('font-size: calc(10pt * var(--bookorbit-font-scale, 1))')
})
it('leaves book text and SVG drawings untouched', async () => {
const { chapter } = await loadChapter(scalePublisherFontSizes)
const svg = chapter.getElementsByTagNameNS('http://www.w3.org/2000/svg', 'svg')[0]!
expect(chapter.querySelector('p')?.textContent).toBe('Set font-size: 12px in your stylesheet.')
expect(svg.getElementsByTagNameNS('http://www.w3.org/2000/svg', 'style')[0]?.textContent).toBe('text { font-size: 12px; }')
expect(svg.getElementsByTagNameNS('http://www.w3.org/2000/svg', 'text')[0]?.getAttribute('style')).toBe('font-size: 12px')
})
})
@@ -190,6 +190,30 @@ describe('useFoliate.open', () => {
expect(foliate.isFixedLayout.value).toBe(false)
})
it('makes publisher font sizes in a reflowable EPUB follow the reader text size', async () => {
const book = { type: 'book', rendition: { layout: 'reflowable' }, transformTarget: new EventTarget() }
;(window as unknown as { makeStreamingBook: ReturnType<typeof vi.fn> }).makeStreamingBook.mockResolvedValueOnce(book)
const foliate = useFoliate(() => container)
await foliate.open(1, 2, 'epub', null, undefined)
const detail = { data: 'body { font-size: medium }' }
book.transformTarget.dispatchEvent(new CustomEvent('css', { detail }))
expect(detail.data).toBe('body { font-size: var(--bookorbit-font-medium, medium) }')
})
it('leaves publisher font sizes in a fixed-layout EPUB alone', async () => {
const book = { type: 'book', rendition: { layout: 'pre-paginated' }, transformTarget: new EventTarget() }
;(window as unknown as { makeStreamingBook: ReturnType<typeof vi.fn> }).makeStreamingBook.mockResolvedValueOnce(book)
const foliate = useFoliate(() => container)
await foliate.open(1, 2, 'epub', null, undefined)
const detail = { data: 'body { font-size: medium }' }
book.transformTarget.dispatchEvent(new CustomEvent('css', { detail }))
expect(detail.data).toBe('body { font-size: medium }')
})
it('tracks whether the opened EPUB is fixed-layout', async () => {
const book = { type: 'book', rendition: { layout: 'pre-paginated', spread: 'auto' } }
;(window as unknown as { makeStreamingBook: ReturnType<typeof vi.fn> }).makeStreamingBook.mockResolvedValueOnce(book)
@@ -171,6 +171,17 @@ describe('useReaderState', () => {
expect(css).toContain('hyphens: none;')
})
it('sets the publisher font-size scale on the root beside the reader text size', () => {
const state = useReaderState()
state.setFontSize(20)
const rootRule = state.generateCSS().match(/html \{[^}]*font-size: 20px;[^}]*\}/)?.[0] ?? ''
expect(rootRule).toContain('--bookorbit-font-scale: 1.25;')
expect(rootRule).toContain('--bookorbit-font-medium: 20px;')
expect(rootRule).toContain('--bookorbit-font-small: 16.25px;')
})
it('overrides a more specific publisher line height on body text', () => {
const state = useReaderState()
state.setLineHeight(3)
@@ -5,6 +5,7 @@ import { useFoliateAnnotations } from './useFoliateAnnotations'
import { useFoliateSelection } from './useFoliateSelection'
import { useFoliateInput } from './useFoliateInput'
import { ensureMediaOverlayActiveClass } from '../../media-overlay/lib/media-overlay-highlight'
import { scalePublisherFontSizes } from '../lib/publisher-font-sizes'
import type { EpubBookInfo, EpubReaderSettings } from '@bookorbit/types'
export interface RelocateDetail {
@@ -307,6 +308,7 @@ export function useFoliate(
applyEpubOpenOptions(book, options)
shouldRestoreByFraction = isFixedLayoutBook(book)
isFixedLayout.value = shouldRestoreByFraction
if (!shouldRestoreByFraction) scalePublisherFontSizes(book)
await view.open(book as never)
} else {
const mimeType = format === 'pdf' ? 'application/pdf' : 'application/zip'
@@ -17,6 +17,7 @@ import {
import { themes } from '../constants/themes'
import type { Theme, ThemeMode } from '../constants/themes'
import { MEDIA_OVERLAY_HIGHLIGHT_CSS_VARIABLE } from '../../media-overlay/lib/media-overlay-highlight'
import { readerFontScaleDeclarations } from '../lib/publisher-font-sizes'
import type { FoliateRenderer } from './useFoliate'
export interface ReaderState {
@@ -214,6 +215,7 @@ export function useReaderState() {
color-scheme: ${dark ? 'dark' : 'light'};
color: ${mode.fg};
font-size: ${fs}px;
${readerFontScaleDeclarations(fs).join('\n ')}
}${bodyFontRule}
a:any-link {
color: ${mode.link};
@@ -0,0 +1,140 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { makePublisherFontSizesScalable, readerFontScaleDeclarations, scalePublisherFontSizes } from '../publisher-font-sizes'
const scaled = (value: string) => `calc(${value} * var(--bookorbit-font-scale, 1))`
describe('makePublisherFontSizesScalable', () => {
it('routes an absolute-size keyword through its reader variable, falling back to the keyword', () => {
expect(makePublisherFontSizesScalable('body { font-size: medium; }')).toBe('body { font-size: var(--bookorbit-font-medium, medium); }')
})
it.each(['xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large', 'xxx-large'])('handles the %s keyword', (keyword) => {
expect(makePublisherFontSizesScalable(`p{font-size:${keyword}}`)).toBe(`p{font-size:var(--bookorbit-font-${keyword}, ${keyword})}`)
})
it('matches property names and keywords case-insensitively', () => {
expect(makePublisherFontSizesScalable('P { FONT-SIZE: Small }')).toBe('P { FONT-SIZE: var(--bookorbit-font-small, Small) }')
})
it.each(['12px', '10pt', '.75pt', '12.5px', '1pc', '0.2in', '0.5cm', '4mm', '16Q', '0px'])('scales the absolute length %s', (value) => {
expect(makePublisherFontSizesScalable(`.calibre { font-size: ${value}; }`)).toBe(`.calibre { font-size: ${scaled(value)}; }`)
})
it('keeps !important on a rewritten declaration', () => {
expect(makePublisherFontSizesScalable('p { font-size: 12px !important; }')).toBe(`p { font-size: ${scaled('12px')} !important; }`)
expect(makePublisherFontSizesScalable('p { font-size: small!important }')).toBe('p { font-size: var(--bookorbit-font-small, small)!important }')
})
it('rewrites a style attribute value, with or without a trailing semicolon', () => {
expect(makePublisherFontSizesScalable('font-size:10pt')).toBe(`font-size:${scaled('10pt')}`)
expect(makePublisherFontSizesScalable('color: red; font-size: 9px;')).toBe(`color: red; font-size: ${scaled('9px')};`)
})
it('rewrites every declaration in a stylesheet, including inside at-rules', () => {
const css = `body {
font-size: medium;
line-height: 1.2em;
}
@media screen {
.note { font-size: 9pt }
}
h1 { font-size: 1.5em; }`
expect(makePublisherFontSizesScalable(css)).toBe(`body {
font-size: var(--bookorbit-font-medium, medium);
line-height: 1.2em;
}
@media screen {
.note { font-size: ${scaled('9pt')} }
}
h1 { font-size: 1.5em; }`)
})
it.each(['1em', '1.2rem', '90%', 'smaller', 'larger', 'inherit', 'initial', 'unset', '0', 'calc(12px + 1em)', 'var(--size)', '12px\\9'])(
'leaves the relative or unrecognised value %s alone',
(value) => {
const css = `p { font-size: ${value}; }`
expect(makePublisherFontSizesScalable(css)).toBe(css)
},
)
it('leaves other properties alone, including longer names that end in font-size', () => {
const css = 'p { font-size-adjust: 0.5; --title-font-size: 12px; line-height: 12px; font: bold 12px serif; }'
expect(makePublisherFontSizesScalable(css)).toBe(css)
})
it('does not touch font-size text inside comments or strings', () => {
const css = `/* font-size: 12px; */
p::before { content: "font-size: 12px"; }
[style*='font-size: small'] { color: red; }`
expect(makePublisherFontSizesScalable(css)).toBe(css)
})
it('is idempotent', () => {
const once = makePublisherFontSizesScalable('body { font-size: medium } p { font-size: 10pt !important }')
expect(makePublisherFontSizesScalable(once)).toBe(once)
})
it('returns CSS with no font-size untouched', () => {
const css = 'p { margin: 0; }'
expect(makePublisherFontSizesScalable(css)).toBe(css)
})
})
describe('readerFontScaleDeclarations', () => {
it('resolves to the browser keyword sizes at the 16px default', () => {
expect(readerFontScaleDeclarations(16)).toEqual([
'--bookorbit-font-scale: 1;',
'--bookorbit-font-xx-small: 9px;',
'--bookorbit-font-x-small: 10px;',
'--bookorbit-font-small: 13px;',
'--bookorbit-font-medium: 16px;',
'--bookorbit-font-large: 18px;',
'--bookorbit-font-x-large: 24px;',
'--bookorbit-font-xx-large: 32px;',
'--bookorbit-font-xxx-large: 48px;',
])
})
it('scales every keyword with the reader size so medium matches it', () => {
const declarations = readerFontScaleDeclarations(24)
expect(declarations).toContain('--bookorbit-font-scale: 1.5;')
expect(declarations).toContain('--bookorbit-font-medium: 24px;')
expect(declarations).toContain('--bookorbit-font-small: 19.5px;')
})
it('keeps fractional values short', () => {
expect(readerFontScaleDeclarations(17)).toContain('--bookorbit-font-scale: 1.0625;')
expect(readerFontScaleDeclarations(17)).toContain('--bookorbit-font-small: 13.8125px;')
expect(readerFontScaleDeclarations(11)).toContain('--bookorbit-font-x-small: 6.875px;')
})
})
describe('scalePublisherFontSizes', () => {
function dispatchCss(target: EventTarget, data: unknown) {
const detail = { data }
target.dispatchEvent(new CustomEvent('css', { detail }))
return detail.data
}
it('rewrites the CSS the book loader hands over', () => {
const transformTarget = new EventTarget()
scalePublisherFontSizes({ transformTarget })
expect(dispatchCss(transformTarget, 'body { font-size: medium }')).toBe('body { font-size: var(--bookorbit-font-medium, medium) }')
})
it('ignores data that is not a CSS string', () => {
const transformTarget = new EventTarget()
scalePublisherFontSizes({ transformTarget })
const pending = Promise.resolve('body { font-size: medium }')
expect(dispatchCss(transformTarget, pending)).toBe(pending)
})
it('tolerates books without a transform target', () => {
expect(() => scalePublisherFontSizes({})).not.toThrow()
expect(() => scalePublisherFontSizes(null)).not.toThrow()
})
})
@@ -0,0 +1,68 @@
// Publisher CSS often fixes text size with a keyword (`body { font-size: medium }`) or an absolute
// length (`p { font-size: 10pt }`). Neither inherits from the root, so the reader's text size,
// which is set on `html`, never reaches that text. Rewriting those values to read a reader-owned
// scale lets them follow the setting. Every rewrite falls back to the original value, so a book
// renders exactly as before whenever the reader injects no styles.
export const READER_FONT_SCALE_CSS_VARIABLE = '--bookorbit-font-scale'
// Browsers resolve the absolute-size keywords against a 16px default with this shared table.
const BROWSER_DEFAULT_FONT_SIZE_PX = 16
const KEYWORD_FONT_SIZES_PX = new Map([
['xx-small', 9],
['x-small', 10],
['small', 13],
['medium', 16],
['large', 18],
['x-large', 24],
['xx-large', 32],
['xxx-large', 48],
])
const ABSOLUTE_LENGTH = /^\+?(?:\d+\.?\d*|\.\d+)(?:px|pt|pc|in|cm|mm|q)$/i
// Comments and strings are matched first so a `font-size:` inside either is passed through.
// The property must not be the tail of a longer name such as `--title-font-size`.
const FONT_SIZE_DECLARATION =
/(\/\*[\s\S]*?\*\/|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*')|(^|[^-\w])(font-size\s*:\s*)([^;{}!"'/]*?)(?=\s*(?:!\s*important\s*)?(?:[;}]|$))/gi
function keywordVariable(keyword: string): string {
return `--bookorbit-font-${keyword}`
}
function formatNumber(value: number): string {
return String(Number(value.toFixed(4)))
}
function scalableFontSize(value: string): string | null {
const keyword = value.toLowerCase()
if (KEYWORD_FONT_SIZES_PX.has(keyword)) return `var(${keywordVariable(keyword)}, ${value})`
if (ABSOLUTE_LENGTH.test(value)) return `calc(${value} * var(${READER_FONT_SCALE_CSS_VARIABLE}, 1))`
return null
}
export function makePublisherFontSizesScalable(css: string): string {
if (!/font-size/i.test(css)) return css
return css.replace(FONT_SIZE_DECLARATION, (match, skipped: string | undefined, prefix: string, property: string, value: string) => {
if (skipped !== undefined) return match
const scalable = scalableFontSize(value)
return scalable ? `${prefix}${property}${scalable}` : match
})
}
// A keyword fallback cannot sit inside calc(), so each keyword gets its own resolved variable.
export function readerFontScaleDeclarations(fontSizePx: number): string[] {
const scale = fontSizePx / BROWSER_DEFAULT_FONT_SIZE_PX
return [
`${READER_FONT_SCALE_CSS_VARIABLE}: ${formatNumber(scale)};`,
...[...KEYWORD_FONT_SIZES_PX].map(([keyword, px]) => `${keywordVariable(keyword)}: ${formatNumber(px * scale)}px;`),
]
}
export function scalePublisherFontSizes(book: unknown): void {
const target = (book as { transformTarget?: EventTarget } | null)?.transformTarget
target?.addEventListener('css', (event) => {
const detail = (event as CustomEvent<{ data: unknown }>).detail
if (typeof detail?.data === 'string') detail.data = makePublisherFontSizesScalable(detail.data)
})
}