mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user