feat(reader): add epub typography controls

Preserve publisher typography by default while allowing explicit spacing and indentation overrides across reader clients.

Ref #1206
This commit is contained in:
neon
2026-09-01 00:15:28 +00:00
parent 53cfe16b04
commit 2912429ca6
13 changed files with 1075 additions and 98 deletions
+30 -6
View File
@@ -83,6 +83,9 @@ const {
setFontSize,
setLineHeight,
setParagraphSpacing,
setLetterSpacing,
setWordSpacing,
setTextIndent,
setFontFamily,
setFontWeight,
setFontStyle,
@@ -111,7 +114,9 @@ const { onActivity, elapsedMinutes } = useReadingSession(
{ trackingEnabled },
)
const progress = useReaderProgress(bookId, fileId, elapsedMinutes, 0, { trackingEnabled })
const progress = useReaderProgress(bookId, fileId, elapsedMinutes, 0, {
trackingEnabled,
})
const { cfi, chapterTitle, sectionIndex, totalSections, fraction, locationTotal, footerMode, cycleFooterMode, updateHeadsFeet } = progress
const visibility = useVisibility()
@@ -320,7 +325,13 @@ onMounted(async () => {
await annotations.load(bookId)
const drawableAnnotations = annotations.annotations.value.filter((a): a is typeof a & { cfi: string } => a.cfi != null)
if (drawableAnnotations.length > 0) {
addAnnotations(drawableAnnotations.map((a) => ({ cfi: a.cfi, color: a.color, style: a.style })))
addAnnotations(
drawableAnnotations.map((a) => ({
cfi: a.cfi,
color: a.color,
style: a.style,
})),
)
}
void hydrateSidebarLocationMeta()
@@ -344,6 +355,9 @@ const epubSetters: Record<string, (v: unknown) => void> = {
fontSize: (v) => setFontSize(v as number),
lineHeight: (v) => setLineHeight(v as number),
paragraphSpacing: (v) => setParagraphSpacing(v as number),
letterSpacing: (v) => setLetterSpacing(v as number | null),
wordSpacing: (v) => setWordSpacing(v as number | null),
textIndent: (v) => setTextIndent(v as number | null),
fontFamily: (v) => setFontFamily(v as string | null),
fontWeight: (v) => setFontWeight(v as number),
fontStyle: (v) => setFontStyle(v as EpubReaderSettings['fontStyle']),
@@ -478,7 +492,10 @@ function pruneSidebarLocationMeta(targets: string[]) {
sidebarLocationMetaByCfi.value = next
}
function toSidebarLocationMeta(context: FoliateLocationContext): { chapterTitle: string | null; percentage: number | null } {
function toSidebarLocationMeta(context: FoliateLocationContext): {
chapterTitle: string | null
percentage: number | null
} {
const percentage =
typeof context.fraction === 'number' && Number.isFinite(context.fraction) ? Math.max(0, Math.min(100, Math.round(context.fraction * 100))) : null
return { chapterTitle: context.chapterTitle, percentage }
@@ -505,7 +522,10 @@ async function hydrateSidebarLocationMeta() {
)
if (requestSeq !== sidebarLocationResolveSeq) return
sidebarLocationMetaByCfi.value = { ...sidebarLocationMetaByCfi.value, ...Object.fromEntries(entries) }
sidebarLocationMetaByCfi.value = {
...sidebarLocationMetaByCfi.value,
...Object.fromEntries(entries),
}
}
function onSearchQuery(q: string) {
@@ -633,13 +653,17 @@ watch(
<div v-if="loading" class="absolute inset-0 flex items-center justify-center z-10 bg-background">
<div class="flex flex-col items-center gap-3">
<div class="w-8 h-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
<p class="text-sm text-muted-foreground">{{ t('reader.loadingBook') }}</p>
<p class="text-sm text-muted-foreground">
{{ t('reader.loadingBook') }}
</p>
</div>
</div>
<div v-if="error && !loading" class="absolute inset-0 flex items-center justify-center z-10 p-8 bg-background">
<div class="text-center max-w-sm">
<p class="text-sm font-medium mb-2 text-foreground">{{ t('reader.failedToLoadBook') }}</p>
<p class="text-sm font-medium mb-2 text-foreground">
{{ t('reader.failedToLoadBook') }}
</p>
<p class="text-xs text-muted-foreground">{{ error }}</p>
</div>
</div>
@@ -4,8 +4,14 @@ import { useI18n } from 'vue-i18n'
import {
EPUB_FONT_SIZE_MAX,
EPUB_FONT_SIZE_MIN,
EPUB_LETTER_SPACING_MAX,
EPUB_LETTER_SPACING_MIN,
EPUB_PARAGRAPH_SPACING_MAX,
EPUB_PARAGRAPH_SPACING_MIN,
EPUB_TEXT_INDENT_MAX,
EPUB_TEXT_INDENT_MIN,
EPUB_WORD_SPACING_MAX,
EPUB_WORD_SPACING_MIN,
isCustomFontCssFamily,
type FontNamedInstance,
type FontVariant,
@@ -68,13 +74,34 @@ const modeOptions = computed(() => [
])
const flowOptions = computed(() => [
{ value: 'paginated', label: t('reader.settings.flow.paginated'), icon: BookOpen },
{ value: 'scrolled', label: t('reader.settings.flow.scrolled'), icon: ScrollText },
{
value: 'paginated',
label: t('reader.settings.flow.paginated'),
icon: BookOpen,
},
{
value: 'scrolled',
label: t('reader.settings.flow.scrolled'),
icon: ScrollText,
},
])
const spreadOptions = computed(() => [
{ value: 'auto', label: t('reader.settings.spread.bookDefault'), icon: LayoutGrid },
{ value: 'none', label: t('reader.settings.spread.singlePage'), icon: BookOpen },
{
value: 'auto',
label: t('reader.settings.spread.bookDefault'),
icon: LayoutGrid,
},
{
value: 'none',
label: t('reader.settings.spread.singlePage'),
icon: BookOpen,
},
])
const typographySourceOptions = computed(() => [
{ value: 'book', label: t('reader.settings.bookDefault') },
{ value: 'custom', label: t('reader.settings.custom') },
])
const currentMode = computed(() => (props.state.isDark ? 'dark' : 'light'))
@@ -95,9 +122,21 @@ const paragraphSpacingLabel = computed(() =>
props.state.paragraphSpacing === EPUB_PARAGRAPH_SPACING_MIN
? t('reader.settings.paragraphSpacingDefault')
: t('reader.settings.paragraphSpacingValue', {
value: formatNumber(props.state.paragraphSpacing, { minimumFractionDigits: 1, maximumFractionDigits: 1 }),
value: formatNumber(props.state.paragraphSpacing, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}),
}),
)
const letterSpacingSource = computed(() => (props.state.letterSpacing === null ? 'book' : 'custom'))
const wordSpacingSource = computed(() => (props.state.wordSpacing === null ? 'book' : 'custom'))
const textIndentSource = computed(() => (props.state.textIndent === null ? 'book' : 'custom'))
const letterSpacingValue = computed(() => props.state.letterSpacing ?? EPUB_LETTER_SPACING_MIN)
const wordSpacingValue = computed(() => props.state.wordSpacing ?? EPUB_WORD_SPACING_MIN)
const textIndentValue = computed(() => props.state.textIndent ?? EPUB_TEXT_INDENT_MIN)
const letterSpacingLabel = computed(() => formatEmValue(letterSpacingValue.value, 2))
const wordSpacingLabel = computed(() => formatEmValue(wordSpacingValue.value, 2))
const textIndentLabel = computed(() => formatEmValue(textIndentValue.value, 2))
const columnGapPercent = computed(() => Math.round(props.state.gap * 100))
@@ -110,15 +149,22 @@ function selectTheme(themeName: string) {
}
function decreaseTextSize() {
emit('update', { fontSize: Math.max(EPUB_FONT_SIZE_MIN, props.state.fontSize - 1) })
emit('update', {
fontSize: Math.max(EPUB_FONT_SIZE_MIN, props.state.fontSize - 1),
})
}
function increaseTextSize() {
emit('update', { fontSize: Math.min(EPUB_FONT_SIZE_MAX, props.state.fontSize + 1) })
emit('update', {
fontSize: Math.min(EPUB_FONT_SIZE_MAX, props.state.fontSize + 1),
})
}
function selectBuiltInFont(font: ReaderBuiltInFontOption) {
emit('update', { fontFamily: font.value, ...variantUpdateFor(builtInVariants()) })
emit('update', {
fontFamily: font.value,
...variantUpdateFor(builtInVariants()),
})
}
function setLineHeight(value: number) {
@@ -129,6 +175,45 @@ function setParagraphSpacing(value: number) {
emit('update', { paragraphSpacing: Math.round(value * 10) / 10 })
}
function formatEmValue(value: number, maximumFractionDigits: number): string {
return t('reader.settings.emValue', {
value: formatNumber(value, {
minimumFractionDigits: 0,
maximumFractionDigits,
}),
})
}
function setLetterSpacingSource(value: string) {
emit('update', {
letterSpacing: value === 'book' ? null : (props.state.letterSpacing ?? 0),
})
}
function setWordSpacingSource(value: string) {
emit('update', {
wordSpacing: value === 'book' ? null : (props.state.wordSpacing ?? 0),
})
}
function setTextIndentSource(value: string) {
emit('update', {
textIndent: value === 'book' ? null : (props.state.textIndent ?? 0),
})
}
function setLetterSpacing(value: number) {
emit('update', { letterSpacing: Math.round(value * 100) / 100 })
}
function setWordSpacing(value: number) {
emit('update', { wordSpacing: Math.round(value * 20) / 20 })
}
function setTextIndent(value: number) {
emit('update', { textIndent: Math.round(value * 4) / 4 })
}
function setPageWidth(value: number) {
emit('update', { maxInlineSize: value })
}
@@ -138,15 +223,21 @@ function setFlow(value: string) {
}
function setFixedLayoutSpread(value: string) {
emit('update', { fixedLayoutSpread: value as ReaderState['fixedLayoutSpread'] })
emit('update', {
fixedLayoutSpread: value as ReaderState['fixedLayoutSpread'],
})
}
function decreaseColumns() {
emit('update', { maxColumnCount: Math.max(COLUMN_MIN, props.state.maxColumnCount - 1) })
emit('update', {
maxColumnCount: Math.max(COLUMN_MIN, props.state.maxColumnCount - 1),
})
}
function increaseColumns() {
emit('update', { maxColumnCount: Math.min(COLUMN_MAX, props.state.maxColumnCount + 1) })
emit('update', {
maxColumnCount: Math.min(COLUMN_MAX, props.state.maxColumnCount + 1),
})
}
function setColumnGap(value: number) {
@@ -198,8 +289,16 @@ const customFontSections = computed(() => {
const customFonts = props.customFonts
if (!customFonts) return []
return [
{ key: 'server', label: t('reader.settings.fontServer'), families: customFonts.visibleServerFamilies.value },
{ key: 'user', label: t('reader.settings.fontYours'), families: customFonts.families.value },
{
key: 'server',
label: t('reader.settings.fontServer'),
families: customFonts.visibleServerFamilies.value,
},
{
key: 'user',
label: t('reader.settings.fontYours'),
families: customFonts.families.value,
},
].filter((section) => section.families.length > 0)
})
@@ -207,7 +306,11 @@ const hasCustomFontSections = computed(() => customFontSections.value.length > 0
function selectCustomFont(family: FontFamily) {
const cssFamilyName = props.customFonts?.getCssFamilyForDisplay(family.name, family.scope)
if (cssFamilyName) emit('update', { fontFamily: cssFamilyName, ...variantUpdateFor(familyVariants(family.variants)) })
if (cssFamilyName)
emit('update', {
fontFamily: cssFamilyName,
...variantUpdateFor(familyVariants(family.variants)),
})
}
function isCustomFontSelected(family: FontFamily): boolean {
@@ -215,7 +318,10 @@ function isCustomFontSelected(family: FontFamily): boolean {
return props.customFonts.isFontFamilySelected(family.name, props.state.fontFamily, family.scope)
}
const currentVariant = computed<FontVariant>(() => ({ weight: props.state.fontWeight, style: props.state.fontStyle }))
const currentVariant = computed<FontVariant>(() => ({
weight: props.state.fontWeight,
style: props.state.fontStyle,
}))
/**
* The styles the selected family offers. Built-in stacks have no file list to read, so
@@ -278,7 +384,9 @@ const cardBaseClass =
<template>
<section class="flex min-h-0 flex-col overflow-hidden bg-card text-card-foreground">
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-card px-4 py-2.5 transition-shadow" :class="isScrolled ? 'shadow-sm' : ''">
<h2 class="mr-auto text-sm font-semibold">{{ t('reader.settings.title') }}</h2>
<h2 class="mr-auto text-sm font-semibold">
{{ t('reader.settings.title') }}
</h2>
<ReaderSegmentedControl
class="w-[9.75rem] shrink-0"
:options="modeOptions"
@@ -344,9 +452,17 @@ const cardBaseClass =
aria-hidden="true"
class="relative flex h-9 w-full items-center justify-center overflow-hidden rounded-md font-serif text-[13px] ring-2 ring-offset-2 ring-offset-card transition-all"
:class="state.themeName === theme.name ? 'ring-primary' : 'ring-transparent group-hover:ring-border'"
:style="{ background: state.isDark ? theme.dark.bg : theme.light.bg, color: state.isDark ? theme.dark.fg : theme.light.fg }"
:style="{
background: state.isDark ? theme.dark.bg : theme.light.bg,
color: state.isDark ? theme.dark.fg : theme.light.fg,
}"
>
<span class="absolute inset-x-0 top-0 h-[3px]" :style="{ background: state.isDark ? theme.dark.link : theme.light.link }" />
<span
class="absolute inset-x-0 top-0 h-[3px]"
:style="{
background: state.isDark ? theme.dark.link : theme.light.link,
}"
/>
Aa
</span>
<span
@@ -385,7 +501,9 @@ const cardBaseClass =
</div>
<template v-for="section in customFontSections" :key="section.key">
<p class="mb-1.5 mt-3 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{{ section.label }}</p>
<p class="mb-1.5 mt-3 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{{ section.label }}
</p>
<div class="grid grid-cols-2 gap-2">
<button
v-for="family in section.families"
@@ -421,7 +539,11 @@ const cardBaseClass =
? 'border-primary bg-primary text-primary-foreground'
: 'border-border text-foreground hover:border-muted-foreground/40 hover:bg-muted'
"
:style="{ fontFamily: variantPreviewFamily, fontWeight: variant.weight, fontStyle: variant.style }"
:style="{
fontFamily: variantPreviewFamily,
fontWeight: variant.weight,
fontStyle: variant.style,
}"
:aria-pressed="isVariantSelected(variant)"
@click="selectVariant(variant)"
>
@@ -527,6 +649,72 @@ const cardBaseClass =
@update:model-value="setColumnGap"
/>
<div class="space-y-2">
<p class="text-[13px] font-medium text-foreground">
{{ t('reader.settings.letterSpacing') }}
</p>
<ReaderSegmentedControl
:options="typographySourceOptions"
:model-value="letterSpacingSource"
:aria-label="t('reader.settings.letterSpacing')"
@update:model-value="setLetterSpacingSource"
/>
<ReaderRangeField
v-if="state.letterSpacing !== null"
:model-value="letterSpacingValue"
:min="EPUB_LETTER_SPACING_MIN"
:max="EPUB_LETTER_SPACING_MAX"
:step="0.01"
:label="t('reader.settings.letterSpacing')"
:display-value="letterSpacingLabel"
@update:model-value="setLetterSpacing"
/>
</div>
<div class="space-y-2">
<p class="text-[13px] font-medium text-foreground">
{{ t('reader.settings.wordSpacing') }}
</p>
<ReaderSegmentedControl
:options="typographySourceOptions"
:model-value="wordSpacingSource"
:aria-label="t('reader.settings.wordSpacing')"
@update:model-value="setWordSpacingSource"
/>
<ReaderRangeField
v-if="state.wordSpacing !== null"
:model-value="wordSpacingValue"
:min="EPUB_WORD_SPACING_MIN"
:max="EPUB_WORD_SPACING_MAX"
:step="0.05"
:label="t('reader.settings.wordSpacing')"
:display-value="wordSpacingLabel"
@update:model-value="setWordSpacing"
/>
</div>
<div class="space-y-2">
<p class="text-[13px] font-medium text-foreground">
{{ t('reader.settings.textIndent') }}
</p>
<ReaderSegmentedControl
:options="typographySourceOptions"
:model-value="textIndentSource"
:aria-label="t('reader.settings.textIndent')"
@update:model-value="setTextIndentSource"
/>
<ReaderRangeField
v-if="state.textIndent !== null"
:model-value="textIndentValue"
:min="EPUB_TEXT_INDENT_MIN"
:max="EPUB_TEXT_INDENT_MAX"
:step="0.25"
:label="t('reader.settings.textIndent')"
:display-value="textIndentLabel"
@update:model-value="setTextIndent"
/>
</div>
<div class="flex items-center justify-between gap-3">
<span :id="justifyLabelId" class="text-[13px] font-medium text-foreground">{{ t('reader.settings.justifyText') }}</span>
<ToggleSwitch :model-value="state.justify" :aria-labelledby="justifyLabelId" @update:model-value="setJustify" />
@@ -548,7 +736,9 @@ const cardBaseClass =
:aria-label="t('reader.settings.pageSpreads')"
@update:model-value="setFixedLayoutSpread"
/>
<p class="mt-2 text-xs leading-snug text-muted-foreground">{{ t('reader.settings.pageSpreadsHint') }}</p>
<p class="mt-2 text-xs leading-snug text-muted-foreground">
{{ t('reader.settings.pageSpreadsHint') }}
</p>
</div>
</div>
</section>
@@ -12,6 +12,9 @@ function makeState(overrides: Partial<ReaderState> = {}): ReaderState {
fontSize: 16,
lineHeight: 1.5,
paragraphSpacing: 0,
letterSpacing: null,
wordSpacing: null,
textIndent: null,
fontFamily: null,
fontWeight: 400,
fontStyle: 'normal',
@@ -58,6 +61,10 @@ function buttonByText(wrapper: VueWrapper, text: string) {
return wrapper.findAll('button').find((button) => button.text() === text)
}
function groupByAriaLabel(wrapper: VueWrapper, label: string) {
return wrapper.find(`[role="group"][aria-label="${label}"]`)
}
/** Resolves the style chips through the group their heading labels, a11y wiring included. */
function styleChips(wrapper: VueWrapper) {
const group = wrapper.findAll('[role="group"]').find((candidate) => {
@@ -216,7 +223,14 @@ describe('ReaderSettingsPanel', () => {
})
it('emits advanced layout changes', async () => {
const wrapper = mountPanel({ state: makeState({ maxColumnCount: 2, gap: 0.05, justify: true, hyphenate: true }) })
const wrapper = mountPanel({
state: makeState({
maxColumnCount: 2,
gap: 0.05,
justify: true,
hyphenate: true,
}),
})
await buttonByAriaLabel(wrapper, 'More columns').trigger('click')
expect(wrapper.emitted('update')?.[0]).toEqual([{ maxColumnCount: 3 }])
@@ -231,6 +245,33 @@ describe('ReaderSettingsPanel', () => {
expect(wrapper.emitted('update')?.[3]).toEqual([{ hyphenate: false }])
})
it('offers publisher and custom typography controls with nullable overrides', async () => {
const wrapper = mountPanel()
expect(wrapper.text()).toContain('Letter spacing')
expect(wrapper.text()).toContain('Word spacing')
expect(wrapper.text()).toContain('First-line indent')
expect(groupByAriaLabel(wrapper, 'Letter spacing').get('button[aria-pressed="true"]').text()).toBe('Book')
await groupByAriaLabel(wrapper, 'Letter spacing').findAll('button')[1]!.trigger('click')
expect(wrapper.emitted('update')?.[0]).toEqual([{ letterSpacing: 0 }])
const customized = mountPanel({
state: makeState({ letterSpacing: 0.1, wordSpacing: 0.2, textIndent: 1 }),
})
await rangeByLabel(customized, 'Letter spacing')!.setValue('0.13')
await rangeByLabel(customized, 'Word spacing')!.setValue('0.35')
await rangeByLabel(customized, 'First-line indent')!.setValue('1.75')
await groupByAriaLabel(customized, 'Letter spacing').findAll('button')[0]!.trigger('click')
expect(customized.emitted('update')).toEqual([
[{ letterSpacing: 0.13 }],
[{ wordSpacing: 0.35 }],
[{ textIndent: 1.75 }],
[{ letterSpacing: null }],
])
})
it('resets only when the book carries overrides', async () => {
const untouched = mountPanel({ canReset: false })
expect(buttonByAriaLabel(untouched, 'Reset to defaults').attributes('disabled')).toBeDefined()
@@ -244,13 +285,17 @@ describe('ReaderSettingsPanel', () => {
})
it('offers spread choices and hides text controls for fixed-layout books', async () => {
const wrapper = mountPanel({ state: makeState({ fixedLayoutSpread: 'auto' }), isFixedLayout: true })
const wrapper = mountPanel({
state: makeState({ fixedLayoutSpread: 'auto' }),
isFixedLayout: true,
})
expect(wrapper.text()).toContain('Page spreads')
expect(wrapper.text()).toContain('Page color')
expect(wrapper.text()).not.toContain('Text size')
expect(wrapper.text()).not.toContain('Reading flow')
expect(wrapper.text()).not.toContain('Advanced layout')
expect(wrapper.text()).not.toContain('Letter spacing')
await buttonByText(wrapper, 'Single page')?.trigger('click')
expect(wrapper.emitted('update')?.[0]).toEqual([{ fixedLayoutSpread: 'none' }])
@@ -279,7 +324,13 @@ describe('ReaderSettingsPanel', () => {
})
it('marks the style in use as pressed', () => {
const wrapper = mountPanel({ state: makeState({ fontFamily: 'serif', fontWeight: 700, fontStyle: 'normal' }) })
const wrapper = mountPanel({
state: makeState({
fontFamily: 'serif',
fontWeight: 700,
fontStyle: 'normal',
}),
})
expect(styleChip(wrapper, 'Bold')?.attributes('aria-pressed')).toBe('true')
expect(styleChip(wrapper, 'Regular')?.attributes('aria-pressed')).toBe('false')
@@ -299,14 +350,20 @@ describe('ReaderSettingsPanel', () => {
}),
]),
])
const wrapper = mountPanel({ state: makeState({ fontFamily: userCss('Inter') }), customFonts })
const wrapper = mountPanel({
state: makeState({ fontFamily: userCss('Inter') }),
customFonts,
})
expect(styleChipLabels(wrapper)).toEqual(['Thin', 'Book', 'Heavy'])
})
it('hides the row for a family that offers a single style', () => {
const customFonts = makeCustomFonts([makeFamily('Solo', [makeFont()])])
const wrapper = mountPanel({ state: makeState({ fontFamily: userCss('Solo') }), customFonts })
const wrapper = mountPanel({
state: makeState({ fontFamily: userCss('Solo') }),
customFonts,
})
expect(wrapper.text()).not.toContain('Style')
expect(styleChipLabels(wrapper)).toEqual([])
@@ -314,7 +371,10 @@ describe('ReaderSettingsPanel', () => {
it('moves to the nearest style when the newly picked family lacks the current one', async () => {
const customFonts = makeCustomFonts([makeFamily('Sparse', [makeFont({ weight: 300 }), makeFont({ id: 2, weight: 500 })])])
const wrapper = mountPanel({ state: makeState({ fontFamily: 'serif', fontWeight: 700 }), customFonts })
const wrapper = mountPanel({
state: makeState({ fontFamily: 'serif', fontWeight: 700 }),
customFonts,
})
await buttonByText(wrapper, 'Sparse')?.trigger('click')
@@ -323,7 +383,10 @@ describe('ReaderSettingsPanel', () => {
it('leaves the style alone when the newly picked family offers it', async () => {
const customFonts = makeCustomFonts([makeFamily('Full', [makeFont({ weight: 400 }), makeFont({ id: 2, weight: 700 })])])
const wrapper = mountPanel({ state: makeState({ fontFamily: 'serif', fontWeight: 700 }), customFonts })
const wrapper = mountPanel({
state: makeState({ fontFamily: 'serif', fontWeight: 700 }),
customFonts,
})
await buttonByText(wrapper, 'Full')?.trigger('click')
@@ -8,6 +8,9 @@ describe('useReaderState', () => {
state.setFontSize(100)
state.setLineHeight(0.71)
state.setParagraphSpacing(3)
state.setLetterSpacing(0.3)
state.setWordSpacing(0.8)
state.setTextIndent(8)
state.setMaxColumnCount(0)
state.setGap(0.9)
state.setMaxInlineSize(200)
@@ -16,6 +19,9 @@ describe('useReaderState', () => {
expect(state.fontSize.value).toBe(32)
expect(state.lineHeight.value).toBe(0.8)
expect(state.paragraphSpacing.value).toBe(2)
expect(state.letterSpacing.value).toBe(0.2)
expect(state.wordSpacing.value).toBe(0.5)
expect(state.textIndent.value).toBe(4)
expect(state.maxColumnCount.value).toBe(1)
expect(state.gap.value).toBe(0.5)
expect(state.maxInlineSize.value).toBe(400)
@@ -23,8 +29,14 @@ describe('useReaderState', () => {
state.setFontSize(1)
state.setParagraphSpacing(-1)
state.setLetterSpacing(-1)
state.setWordSpacing(-1)
state.setTextIndent(-1)
expect(state.fontSize.value).toBe(6)
expect(state.paragraphSpacing.value).toBe(0)
expect(state.letterSpacing.value).toBe(0)
expect(state.wordSpacing.value).toBe(0)
expect(state.textIndent.value).toBe(0)
})
it('selects default theme when unknown theme name is set', () => {
@@ -154,6 +166,51 @@ describe('useReaderState', () => {
expect(css).toContain('margin-block: unset !important;')
})
it('preserves publisher typography when nullable controls are unset', () => {
const state = useReaderState()
expect(state.letterSpacing.value).toBeNull()
expect(state.wordSpacing.value).toBeNull()
expect(state.textIndent.value).toBeNull()
expect(state.generateCSS()).not.toContain('letter-spacing:')
expect(state.generateCSS()).not.toContain('word-spacing:')
expect(state.generateCSS()).not.toContain('text-indent:')
})
it('emits explicit zero typography overrides and can return to publisher styles', () => {
const state = useReaderState()
state.setLetterSpacing(0)
state.setWordSpacing(0)
state.setTextIndent(0)
const css = state.generateCSS()
expect(css).toContain('letter-spacing: 0em !important;')
expect(css).toContain('word-spacing: 0em !important;')
expect(css).toContain('text-indent: 0em !important;')
state.setLetterSpacing(null)
state.setWordSpacing(null)
state.setTextIndent(null)
expect(state.state.value).toMatchObject({
letterSpacing: null,
wordSpacing: null,
textIndent: null,
})
})
it('generates em-based typography CSS for positive values', () => {
const state = useReaderState()
state.setLetterSpacing(0.12)
state.setWordSpacing(0.35)
state.setTextIndent(1.75)
const css = state.generateCSS()
expect(css).toContain('letter-spacing: 0.12em !important;')
expect(css).toContain('word-spacing: 0.35em !important;')
expect(css).toContain('text-indent: 1.75em !important;')
expect(css).toContain(':is(hgroup, header, figure, figcaption, blockquote, li) > p')
})
describe('font style', () => {
it('leaves weight and slant out of the CSS while they sit at their defaults', () => {
const state = useReaderState()
@@ -2,9 +2,15 @@ import { computed, ref } from 'vue'
import {
EPUB_FONT_SIZE_MAX,
EPUB_FONT_SIZE_MIN,
EPUB_LETTER_SPACING_MAX,
EPUB_LETTER_SPACING_MIN,
EPUB_PARAGRAPH_SPACING_MAX,
EPUB_PARAGRAPH_SPACING_MIN,
EPUB_READER_DEFAULTS,
EPUB_TEXT_INDENT_MAX,
EPUB_TEXT_INDENT_MIN,
EPUB_WORD_SPACING_MAX,
EPUB_WORD_SPACING_MIN,
type EpubReaderSettings,
type FontStyle,
} from '@bookorbit/types'
@@ -16,6 +22,9 @@ export interface ReaderState {
fontSize: number
lineHeight: number
paragraphSpacing: number
letterSpacing: number | null
wordSpacing: number | null
textIndent: number | null
fontFamily: string | null
fontWeight: number
fontStyle: FontStyle
@@ -39,6 +48,9 @@ const defaults: ReaderState = {
fontSize: 16,
lineHeight: 1.5,
paragraphSpacing: EPUB_READER_DEFAULTS.paragraphSpacing,
letterSpacing: EPUB_READER_DEFAULTS.letterSpacing,
wordSpacing: EPUB_READER_DEFAULTS.wordSpacing,
textIndent: EPUB_READER_DEFAULTS.textIndent,
fontFamily: null,
fontWeight: EPUB_READER_DEFAULTS.fontWeight,
fontStyle: EPUB_READER_DEFAULTS.fontStyle,
@@ -58,6 +70,9 @@ export function useReaderState() {
const fontSize = ref(defaults.fontSize)
const lineHeight = ref(defaults.lineHeight)
const paragraphSpacing = ref(defaults.paragraphSpacing)
const letterSpacing = ref<number | null>(defaults.letterSpacing)
const wordSpacing = ref<number | null>(defaults.wordSpacing)
const textIndent = ref<number | null>(defaults.textIndent)
const fontFamily = ref<string | null>(defaults.fontFamily)
const fontWeight = ref(defaults.fontWeight)
const fontStyle = ref<FontStyle>(defaults.fontStyle)
@@ -78,6 +93,9 @@ export function useReaderState() {
fontSize: fontSize.value,
lineHeight: lineHeight.value,
paragraphSpacing: paragraphSpacing.value,
letterSpacing: letterSpacing.value,
wordSpacing: wordSpacing.value,
textIndent: textIndent.value,
fontFamily: fontFamily.value,
fontWeight: fontWeight.value,
fontStyle: fontStyle.value,
@@ -104,6 +122,9 @@ export function useReaderState() {
const {
lineHeight: lh,
paragraphSpacing: ps,
letterSpacing: ls,
wordSpacing: ws,
textIndent: ti,
justify: j,
hyphenate: h,
fontSize: fs,
@@ -157,6 +178,26 @@ export function useReaderState() {
:is(hgroup, header) p {
margin-block: unset !important;
}`
const inlineSpacingDeclarations = [
ls === null ? '' : `letter-spacing: ${ls}em !important;`,
ws === null ? '' : `word-spacing: ${ws}em !important;`,
].filter(Boolean)
const inlineSpacingRule = inlineSpacingDeclarations.length
? `
p, li, blockquote, dd {
${inlineSpacingDeclarations.join('\n ')}
}`
: ''
const textIndentRule =
ti === null
? ''
: `
p {
text-indent: ${ti}em !important;
}
:is(hgroup, header, figure, figcaption, blockquote, li) > p {
text-indent: unset !important;
}`
return `
${fontFaceBlock}
@@ -240,6 +281,8 @@ export function useReaderState() {
hyphens: ${h ? 'auto' : 'none'};
}
${paragraphSpacingRule}
${inlineSpacingRule}
${textIndentRule}
::selection {
background-color: rgba(128, 128, 128, 0.3);
}
@@ -277,6 +320,15 @@ export function useReaderState() {
function setParagraphSpacing(v: number) {
paragraphSpacing.value = Math.max(EPUB_PARAGRAPH_SPACING_MIN, Math.min(EPUB_PARAGRAPH_SPACING_MAX, Math.round(v * 10) / 10))
}
function setLetterSpacing(v: number | null) {
letterSpacing.value = clampNullable(v, EPUB_LETTER_SPACING_MIN, EPUB_LETTER_SPACING_MAX, 100)
}
function setWordSpacing(v: number | null) {
wordSpacing.value = clampNullable(v, EPUB_WORD_SPACING_MIN, EPUB_WORD_SPACING_MAX, 20)
}
function setTextIndent(v: number | null) {
textIndent.value = clampNullable(v, EPUB_TEXT_INDENT_MIN, EPUB_TEXT_INDENT_MAX, 4)
}
function setFontFamily(v: string | null) {
fontFamily.value = v
}
@@ -326,6 +378,9 @@ export function useReaderState() {
fontSize,
lineHeight,
paragraphSpacing,
letterSpacing,
wordSpacing,
textIndent,
fontFamily,
fontWeight,
fontStyle,
@@ -347,6 +402,9 @@ export function useReaderState() {
setFontSize,
setLineHeight,
setParagraphSpacing,
setLetterSpacing,
setWordSpacing,
setTextIndent,
setFontFamily,
setFontWeight,
setFontStyle,
@@ -363,3 +421,9 @@ export function useReaderState() {
setFontFaceCSS,
}
}
function clampNullable(value: number | null, min: number, max: number, precision: number): number | null {
if (value === null) return null
const finiteValue = Number.isFinite(value) ? value : min
return Math.max(min, Math.min(max, Math.round(finiteValue * precision) / precision))
}
@@ -5,11 +5,15 @@ import { CBX_READER_DEFAULTS, READER_GROUP_DEFAULTS, type PdfReaderSettings } fr
const apiMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<unknown>>())
vi.mock('@/lib/api', () => ({ api: apiMock }))
const toastMock = vi.hoisted(() => ({ success: vi.fn<(...args: unknown[]) => unknown>() }))
const toastMock = vi.hoisted(() => ({
success: vi.fn<(...args: unknown[]) => unknown>(),
}))
vi.mock('vue-sonner', () => ({ toast: toastMock }))
const useAuthMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => unknown>(() => ({ user: ref(null) })))
vi.mock('@/features/auth/composables/useAuth', () => ({ useAuth: useAuthMock }))
vi.mock('@/features/auth/composables/useAuth', () => ({
useAuth: useAuthMock,
}))
import { useReaderSettings, useReaderDefaultSettings } from './useReaderSettings'
@@ -69,7 +73,10 @@ describe('useReaderSettings - load() with valid localStorage epub delta', () =>
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
await s.load()
expect(s.effective.value).toMatchObject({ ...READER_GROUP_DEFAULTS.epub, fontSize: 24 })
expect(s.effective.value).toMatchObject({
...READER_GROUP_DEFAULTS.epub,
fontSize: 24,
})
})
it('preserves the minimum EPUB font size', async () => {
@@ -101,6 +108,33 @@ describe('useReaderSettings - load() with valid localStorage epub delta', () =>
expect(s.effective.value).toMatchObject({ paragraphSpacing: 0 })
})
it('preserves nullable typography and explicit zero in per-book settings', async () => {
localStorage.setItem(
'reader:default:epub',
JSON.stringify({
...READER_GROUP_DEFAULTS.epub,
letterSpacing: 0.12,
wordSpacing: 0.25,
textIndent: 1.5,
}),
)
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify({ letterSpacing: null, wordSpacing: 0, textIndent: 0 }))
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
await s.load()
expect(s.bookDelta.value).toEqual({
letterSpacing: null,
wordSpacing: 0,
textIndent: 0,
})
expect(s.effective.value).toMatchObject({
letterSpacing: null,
wordSpacing: 0,
textIndent: 0,
})
})
it('filters invalid EPUB spread settings from localStorage deltas', async () => {
const raw = { fontSize: 20, fixedLayoutSpread: 'two-page' }
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify(raw))
@@ -143,6 +177,23 @@ describe('useReaderSettings - load() with invalid localStorage value', () => {
expect(localStorage.getItem(`reader:book:${BOOK_FILE_ID}`)).toBeNull()
})
it('removes typography values outside their supported ranges', async () => {
localStorage.setItem(
`reader:book:${BOOK_FILE_ID}`,
JSON.stringify({
letterSpacing: 0.21,
wordSpacing: 0.51,
textIndent: 4.01,
}),
)
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
await s.load()
expect(s.bookDelta.value).toBeNull()
expect(localStorage.getItem(`reader:book:${BOOK_FILE_ID}`)).toBeNull()
})
it('removes localStorage key and leaves bookDelta null when value is not an object', async () => {
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify('not-an-object'))
@@ -193,12 +244,23 @@ describe('useReaderSettings - PDF validation', () => {
})
it('migrates wrapped mode and retains new read-only layout choices', async () => {
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify({ scrollMode: 'wrapped', spread: 'auto', zoomMode: 'automatic' }))
localStorage.setItem(
`reader:book:${BOOK_FILE_ID}`,
JSON.stringify({
scrollMode: 'wrapped',
spread: 'auto',
zoomMode: 'automatic',
}),
)
const settings = useReaderSettings(BOOK_FILE_ID, 'pdf')
await settings.load()
expect(settings.bookDelta.value).toEqual({ scrollMode: 'vertical', spread: 'auto', zoomMode: 'automatic' })
expect(settings.bookDelta.value).toEqual({
scrollMode: 'vertical',
spread: 'auto',
zoomMode: 'automatic',
})
expect(JSON.parse(localStorage.getItem(`reader:book:${BOOK_FILE_ID}`) ?? '{}')).toEqual({
scrollMode: 'vertical',
spread: 'auto',
@@ -226,7 +288,10 @@ describe('useReaderSettings - load() with valid cbx localStorage delta', () => {
const s = useReaderSettings(BOOK_FILE_ID, 'cbz')
await s.load()
expect(s.bookDelta.value).toMatchObject({ fitMode: 'fit-page', viewMode: 'two-page' })
expect(s.bookDelta.value).toMatchObject({
fitMode: 'fit-page',
viewMode: 'two-page',
})
expect(s.isCustomized.value).toBe(true)
})
@@ -236,7 +301,10 @@ describe('useReaderSettings - load() with valid cbx localStorage delta', () => {
const valid = useReaderSettings(BOOK_FILE_ID, 'cbz')
await valid.load()
expect(valid.bookDelta.value).toMatchObject({ spreadGap: 0, viewMode: 'two-page' })
expect(valid.bookDelta.value).toMatchObject({
spreadGap: 0,
viewMode: 'two-page',
})
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify({ spreadGap: 65, viewMode: 'two-page' }))
@@ -274,7 +342,9 @@ describe('useReaderSettings - updateBookSettings', () => {
})
it('calls api() with PUT when sync is enabled', () => {
useAuthMock.mockReturnValue({ user: ref({ settings: { syncReaderPreferences: true } }) })
useAuthMock.mockReturnValue({
user: ref({ settings: { syncReaderPreferences: true } }),
})
apiMock.mockResolvedValue({ ok: true })
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
@@ -305,7 +375,9 @@ describe('useReaderSettings - resetBookSettings', () => {
})
it('calls api() with DELETE when sync is enabled', () => {
useAuthMock.mockReturnValue({ user: ref({ settings: { syncReaderPreferences: true } }) })
useAuthMock.mockReturnValue({
user: ref({ settings: { syncReaderPreferences: true } }),
})
apiMock.mockResolvedValue({ ok: true })
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
@@ -321,7 +393,10 @@ describe('useReaderSettings - updateDefaultSettings', () => {
s.updateDefaultSettings({ fontSize: 18 } as never)
const stored = JSON.parse(localStorage.getItem('reader:default:epub') ?? 'null')
expect(stored).toMatchObject({ ...READER_GROUP_DEFAULTS.epub, fontSize: 18 })
expect(stored).toMatchObject({
...READER_GROUP_DEFAULTS.epub,
fontSize: 18,
})
})
it('merges patch into existing defaultSettings', () => {
@@ -334,7 +409,9 @@ describe('useReaderSettings - updateDefaultSettings', () => {
})
it('calls api() with PUT when sync is enabled', () => {
useAuthMock.mockReturnValue({ user: ref({ settings: { syncReaderPreferences: true } }) })
useAuthMock.mockReturnValue({
user: ref({ settings: { syncReaderPreferences: true } }),
})
apiMock.mockResolvedValue({ ok: true })
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
@@ -362,7 +439,9 @@ describe('useReaderSettings - resetDefaultSettings', () => {
})
it('calls api() with DELETE when sync is enabled', () => {
useAuthMock.mockReturnValue({ user: ref({ settings: { syncReaderPreferences: true } }) })
useAuthMock.mockReturnValue({
user: ref({ settings: { syncReaderPreferences: true } }),
})
apiMock.mockResolvedValue({ ok: true })
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
@@ -390,7 +469,9 @@ describe('useReaderSettings - syncEnabled behavior', () => {
})
it('calls api() when user has syncReaderPreferences: true', () => {
useAuthMock.mockReturnValue({ user: ref({ settings: { syncReaderPreferences: true } }) })
useAuthMock.mockReturnValue({
user: ref({ settings: { syncReaderPreferences: true } }),
})
apiMock.mockResolvedValue({ ok: true })
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
@@ -400,7 +481,9 @@ describe('useReaderSettings - syncEnabled behavior', () => {
})
it('does not call api() when syncReaderPreferences is false', () => {
useAuthMock.mockReturnValue({ user: ref({ settings: { syncReaderPreferences: false } }) })
useAuthMock.mockReturnValue({
user: ref({ settings: { syncReaderPreferences: false } }),
})
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
s.updateBookSettings({ fontSize: 22 } as never)
@@ -411,7 +494,9 @@ describe('useReaderSettings - syncEnabled behavior', () => {
describe('useReaderSettings - syncFromDb (via load() when syncEnabled)', () => {
beforeEach(() => {
useAuthMock.mockReturnValue({ user: ref({ settings: { syncReaderPreferences: true } }) })
useAuthMock.mockReturnValue({
user: ref({ settings: { syncReaderPreferences: true } }),
})
})
it('updates bookDelta when api returns valid settings', async () => {
@@ -425,9 +510,11 @@ describe('useReaderSettings - syncFromDb (via load() when syncEnabled)', () => {
})
it('updates defaultSettings when api returns group key', async () => {
apiMock
.mockResolvedValueOnce(makeFakeResponse(false, {}))
.mockResolvedValueOnce(makeFakeResponse(true, { cbx: { fitMode: 'fit-width', viewMode: 'single' } }))
apiMock.mockResolvedValueOnce(makeFakeResponse(false, {})).mockResolvedValueOnce(
makeFakeResponse(true, {
cbx: { fitMode: 'fit-width', viewMode: 'single' },
}),
)
const s = useReaderSettings(BOOK_FILE_ID, 'cbz')
await s.load()
@@ -464,7 +551,10 @@ describe('useReaderDefaultSettings - load() with localStorage', () => {
const s = useReaderDefaultSettings('epub')
await s.load()
expect(s.effective.value).toMatchObject({ fontSize: 20, fixedLayoutSpread: 'auto' })
expect(s.effective.value).toMatchObject({
fontSize: 20,
fixedLayoutSpread: 'auto',
})
expect(JSON.parse(localStorage.getItem('reader:default:epub') ?? '{}')).toMatchObject({ fixedLayoutSpread: 'auto' })
})
@@ -476,10 +566,35 @@ describe('useReaderDefaultSettings - load() with localStorage', () => {
const s = useReaderDefaultSettings('epub')
await s.load()
expect(s.effective.value).toMatchObject({ fontSize: 20, paragraphSpacing: 0 })
expect(s.effective.value).toMatchObject({
fontSize: 20,
paragraphSpacing: 0,
})
expect(JSON.parse(localStorage.getItem('reader:default:epub') ?? '{}')).toMatchObject({ paragraphSpacing: 0 })
})
it('fills nullable publisher typography for older epub defaults in localStorage', async () => {
const stored = { ...READER_GROUP_DEFAULTS.epub, fontSize: 20 }
delete (stored as Record<string, unknown>).letterSpacing
delete (stored as Record<string, unknown>).wordSpacing
delete (stored as Record<string, unknown>).textIndent
localStorage.setItem('reader:default:epub', JSON.stringify(stored))
const s = useReaderDefaultSettings('epub')
await s.load()
expect(s.effective.value).toMatchObject({
letterSpacing: null,
wordSpacing: null,
textIndent: null,
})
expect(JSON.parse(localStorage.getItem('reader:default:epub') ?? '{}')).toMatchObject({
letterSpacing: null,
wordSpacing: null,
textIndent: null,
})
})
it('merges cbx localStorage settings with CBX_READER_DEFAULTS', async () => {
const stored = { fitMode: 'fit-width', viewMode: 'single' }
localStorage.setItem('reader:default:cbx', JSON.stringify(stored))
@@ -487,7 +602,10 @@ describe('useReaderDefaultSettings - load() with localStorage', () => {
const s = useReaderDefaultSettings<typeof CBX_READER_DEFAULTS>('cbz')
await s.load()
expect(s.effective.value).toMatchObject({ ...CBX_READER_DEFAULTS, fitMode: 'fit-width' })
expect(s.effective.value).toMatchObject({
...CBX_READER_DEFAULTS,
fitMode: 'fit-width',
})
})
it('falls back to group defaults when localStorage is empty', async () => {
@@ -515,7 +633,9 @@ describe('useReaderDefaultSettings - update', () => {
})
it('calls api() with PUT when sync is enabled', () => {
useAuthMock.mockReturnValue({ user: ref({ settings: { syncReaderPreferences: true } }) })
useAuthMock.mockReturnValue({
user: ref({ settings: { syncReaderPreferences: true } }),
})
apiMock.mockResolvedValue({ ok: true })
const s = useReaderDefaultSettings('epub')
@@ -544,7 +664,9 @@ describe('useReaderDefaultSettings - reset', () => {
})
it('calls api() with DELETE when sync is enabled', () => {
useAuthMock.mockReturnValue({ user: ref({ settings: { syncReaderPreferences: true } }) })
useAuthMock.mockReturnValue({
user: ref({ settings: { syncReaderPreferences: true } }),
})
apiMock.mockResolvedValue({ ok: true })
const s = useReaderDefaultSettings('epub')
@@ -8,9 +8,15 @@ import {
CBX_READER_DEFAULTS,
EPUB_FONT_SIZE_MAX,
EPUB_FONT_SIZE_MIN,
EPUB_LETTER_SPACING_MAX,
EPUB_LETTER_SPACING_MIN,
EPUB_PARAGRAPH_SPACING_MAX,
EPUB_PARAGRAPH_SPACING_MIN,
EPUB_READER_DEFAULTS,
EPUB_TEXT_INDENT_MAX,
EPUB_TEXT_INDENT_MIN,
EPUB_WORD_SPACING_MAX,
EPUB_WORD_SPACING_MIN,
PDF_READER_DEFAULTS,
type CbxReaderSettings,
type EpubReaderSettings,
@@ -60,6 +66,10 @@ function isIntegerInRange(value: unknown, min: number, max: number): value is nu
return Number.isInteger(value) && isNumberInRange(value, min, max)
}
function isNullableNumberInRange(value: unknown, min: number, max: number): value is number | null {
return value === null || isNumberInRange(value, min, max)
}
function sanitizeEpubPartialSettings(settings: unknown): Partial<EpubReaderSettings> | null {
if (!isRecord(settings)) return null
@@ -89,6 +99,15 @@ function sanitizeEpubPartialSettings(settings: unknown): Partial<EpubReaderSetti
if (isNumberInRange(settings.paragraphSpacing, EPUB_PARAGRAPH_SPACING_MIN, EPUB_PARAGRAPH_SPACING_MAX)) {
out.paragraphSpacing = settings.paragraphSpacing
}
if (isNullableNumberInRange(settings.letterSpacing, EPUB_LETTER_SPACING_MIN, EPUB_LETTER_SPACING_MAX)) {
out.letterSpacing = settings.letterSpacing
}
if (isNullableNumberInRange(settings.wordSpacing, EPUB_WORD_SPACING_MIN, EPUB_WORD_SPACING_MAX)) {
out.wordSpacing = settings.wordSpacing
}
if (isNullableNumberInRange(settings.textIndent, EPUB_TEXT_INDENT_MIN, EPUB_TEXT_INDENT_MAX)) {
out.textIndent = settings.textIndent
}
if (isIntegerInRange(settings.maxColumnCount, 1, 10)) {
out.maxColumnCount = settings.maxColumnCount
}
@@ -311,7 +330,10 @@ export function useReaderSettings(bookFileId: number, format: string) {
// Merges only the changed field(s) into the existing delta — never saves a full snapshot.
function updateBookSettings(patch: Partial<ReaderSettings>) {
const next = { ...(bookDelta.value ?? undefined), ...patch } as Partial<ReaderSettings>
const next = {
...(bookDelta.value ?? undefined),
...patch,
} as Partial<ReaderSettings>
bookDelta.value = next
isCustomized.value = Object.keys(next).length > 0
writeLs(lsBookKey(bookFileId), next)
@@ -331,7 +353,9 @@ export function useReaderSettings(bookFileId: number, format: string) {
removeLs(lsBookKey(bookFileId))
if (syncEnabled.value) {
api(`/api/v1/reader/preferences/${bookFileId}`, { method: 'DELETE' }).catch(() => {})
api(`/api/v1/reader/preferences/${bookFileId}`, {
method: 'DELETE',
}).catch(() => {})
}
}
+214 -6
View File
@@ -2,8 +2,14 @@
import { computed, onMounted, onUnmounted, ref, useId, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
EPUB_LETTER_SPACING_MAX,
EPUB_LETTER_SPACING_MIN,
EPUB_PARAGRAPH_SPACING_MAX,
EPUB_PARAGRAPH_SPACING_MIN,
EPUB_TEXT_INDENT_MAX,
EPUB_TEXT_INDENT_MIN,
EPUB_WORD_SPACING_MAX,
EPUB_WORD_SPACING_MIN,
isCustomFontCssFamily,
type EpubReaderSettings,
type FontNamedInstance,
@@ -39,6 +45,12 @@ const props = withDefaults(
const { t } = useI18n()
const fontStyleSelectId = `ebook-font-style-${useId()}`
const paragraphSpacingInputId = `ebook-paragraph-spacing-${useId()}`
const letterSpacingSourceId = `ebook-letter-spacing-source-${useId()}`
const letterSpacingInputId = `ebook-letter-spacing-${useId()}`
const wordSpacingSourceId = `ebook-word-spacing-source-${useId()}`
const wordSpacingInputId = `ebook-word-spacing-${useId()}`
const textIndentSourceId = `ebook-text-indent-source-${useId()}`
const textIndentInputId = `ebook-text-indent-${useId()}`
const { effective, load, update, reset } = useReaderDefaultSettings<EpubReaderSettings>('epub')
@@ -84,22 +96,90 @@ function fontStyleLabel(variant: FontNamedInstance): string {
return t('settings.reader.fonts.weightItalicOf', { weight: base })
}
const selectedFontStyle = computed(() => variantKey({ weight: effective.value.fontWeight, style: effective.value.fontStyle }))
const selectedFontStyle = computed(() =>
variantKey({
weight: effective.value.fontWeight,
style: effective.value.fontStyle,
}),
)
const paragraphSpacingLabel = computed(() =>
effective.value.paragraphSpacing === EPUB_PARAGRAPH_SPACING_MIN
? t('settings.reader.ebook.paragraphSpacingDefault')
: t('settings.reader.ebook.paragraphSpacingValue', {
value: formatNumber(effective.value.paragraphSpacing, { minimumFractionDigits: 1, maximumFractionDigits: 1 }),
value: formatNumber(effective.value.paragraphSpacing, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}),
}),
)
const letterSpacingValue = computed(() => effective.value.letterSpacing ?? EPUB_LETTER_SPACING_MIN)
const wordSpacingValue = computed(() => effective.value.wordSpacing ?? EPUB_WORD_SPACING_MIN)
const textIndentValue = computed(() => effective.value.textIndent ?? EPUB_TEXT_INDENT_MIN)
const letterSpacingLabel = computed(() => formatEmValue(letterSpacingValue.value, 2))
const wordSpacingLabel = computed(() => formatEmValue(wordSpacingValue.value, 2))
const textIndentLabel = computed(() => formatEmValue(textIndentValue.value, 2))
function selectFontStyle(event: Event) {
const chosen = fontStyleOptions.value.find((option) => option.value === (event.target as HTMLSelectElement).value)
if (chosen) update({ fontWeight: chosen.variant.weight, fontStyle: chosen.variant.style })
if (chosen)
update({
fontWeight: chosen.variant.weight,
fontStyle: chosen.variant.style,
})
}
function setParagraphSpacing(event: Event) {
update({ paragraphSpacing: Number((event.target as HTMLInputElement).value) })
update({
paragraphSpacing: Number((event.target as HTMLInputElement).value),
})
}
function formatEmValue(value: number, maximumFractionDigits: number): string {
return t('settings.reader.ebook.emValue', {
value: formatNumber(value, {
minimumFractionDigits: 0,
maximumFractionDigits,
}),
})
}
function setLetterSpacingSource(event: Event) {
const source = (event.target as HTMLSelectElement).value
update({
letterSpacing: source === 'book' ? null : (effective.value.letterSpacing ?? 0),
})
}
function setWordSpacingSource(event: Event) {
const source = (event.target as HTMLSelectElement).value
update({
wordSpacing: source === 'book' ? null : (effective.value.wordSpacing ?? 0),
})
}
function setTextIndentSource(event: Event) {
const source = (event.target as HTMLSelectElement).value
update({
textIndent: source === 'book' ? null : (effective.value.textIndent ?? 0),
})
}
function setLetterSpacing(event: Event) {
update({
letterSpacing: Math.round(Number((event.target as HTMLInputElement).value) * 100) / 100,
})
}
function setWordSpacing(event: Event) {
update({
wordSpacing: Math.round(Number((event.target as HTMLInputElement).value) * 20) / 20,
})
}
function setTextIndent(event: Event) {
update({
textIndent: Math.round(Number((event.target as HTMLInputElement).value) * 4) / 4,
})
}
/**
@@ -109,7 +189,10 @@ function setParagraphSpacing(event: Event) {
function selectFontFamily(event: Event) {
const fontFamily = (event.target as HTMLSelectElement).value || null
const variants = variantsForFamily(fontFamily)
const current = { weight: effective.value.fontWeight, style: effective.value.fontStyle }
const current = {
weight: effective.value.fontWeight,
style: effective.value.fontStyle,
}
if (variants.some((variant) => isSameVariant(variant, current))) {
update({ fontFamily })
@@ -145,7 +228,15 @@ function reconcileSavedFont() {
weight: effective.value.fontWeight,
style: effective.value.fontStyle,
})
update(fallback ? { fontFamily: null, fontWeight: fallback.weight, fontStyle: fallback.style } : { fontFamily: null })
update(
fallback
? {
fontFamily: null,
fontWeight: fallback.weight,
fontStyle: fallback.style,
}
: { fontFamily: null },
)
}
}
@@ -579,6 +670,123 @@ function setFixedLayoutSpreadNone() {
{{ t('settings.reader.ebook.advanced') }}
</p>
<div class="settings-card">
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div class="mb-3 flex items-start justify-between gap-3">
<div>
<label :for="letterSpacingSourceId" class="settings-label">{{ t('settings.reader.ebook.letterSpacing') }}</label>
<p class="settings-hint">
{{ t('settings.reader.ebook.letterSpacingHint') }}
</p>
</div>
<select
:id="letterSpacingSourceId"
class="text-xs border border-border rounded-md px-2 py-1.5 bg-card text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
:value="effective.letterSpacing === null ? 'book' : 'custom'"
@change="setLetterSpacingSource"
>
<option value="book">
{{ t('settings.reader.ebook.bookDefault') }}
</option>
<option value="custom">
{{ t('settings.reader.ebook.custom') }}
</option>
</select>
</div>
<div v-if="effective.letterSpacing !== null" class="flex items-center gap-3">
<input
:id="letterSpacingInputId"
type="range"
:min="EPUB_LETTER_SPACING_MIN"
:max="EPUB_LETTER_SPACING_MAX"
step="0.01"
class="w-full accent-primary cursor-pointer"
:value="letterSpacingValue"
:aria-label="t('settings.reader.ebook.letterSpacing')"
:aria-valuetext="letterSpacingLabel"
@input="setLetterSpacing"
/>
<span class="settings-value w-16 text-right">{{ letterSpacingLabel }}</span>
</div>
</div>
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div class="mb-3 flex items-start justify-between gap-3">
<div>
<label :for="wordSpacingSourceId" class="settings-label">{{ t('settings.reader.ebook.wordSpacing') }}</label>
<p class="settings-hint">
{{ t('settings.reader.ebook.wordSpacingHint') }}
</p>
</div>
<select
:id="wordSpacingSourceId"
class="text-xs border border-border rounded-md px-2 py-1.5 bg-card text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
:value="effective.wordSpacing === null ? 'book' : 'custom'"
@change="setWordSpacingSource"
>
<option value="book">
{{ t('settings.reader.ebook.bookDefault') }}
</option>
<option value="custom">
{{ t('settings.reader.ebook.custom') }}
</option>
</select>
</div>
<div v-if="effective.wordSpacing !== null" class="flex items-center gap-3">
<input
:id="wordSpacingInputId"
type="range"
:min="EPUB_WORD_SPACING_MIN"
:max="EPUB_WORD_SPACING_MAX"
step="0.05"
class="w-full accent-primary cursor-pointer"
:value="wordSpacingValue"
:aria-label="t('settings.reader.ebook.wordSpacing')"
:aria-valuetext="wordSpacingLabel"
@input="setWordSpacing"
/>
<span class="settings-value w-16 text-right">{{ wordSpacingLabel }}</span>
</div>
</div>
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div class="mb-3 flex items-start justify-between gap-3">
<div>
<label :for="textIndentSourceId" class="settings-label">{{ t('settings.reader.ebook.textIndent') }}</label>
<p class="settings-hint">
{{ t('settings.reader.ebook.textIndentHint') }}
</p>
</div>
<select
:id="textIndentSourceId"
class="text-xs border border-border rounded-md px-2 py-1.5 bg-card text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
:value="effective.textIndent === null ? 'book' : 'custom'"
@change="setTextIndentSource"
>
<option value="book">
{{ t('settings.reader.ebook.bookDefault') }}
</option>
<option value="custom">
{{ t('settings.reader.ebook.custom') }}
</option>
</select>
</div>
<div v-if="effective.textIndent !== null" class="flex items-center gap-3">
<input
:id="textIndentInputId"
type="range"
:min="EPUB_TEXT_INDENT_MIN"
:max="EPUB_TEXT_INDENT_MAX"
step="0.25"
class="w-full accent-primary cursor-pointer"
:value="textIndentValue"
:aria-label="t('settings.reader.ebook.textIndent')"
:aria-valuetext="textIndentLabel"
@input="setTextIndent"
/>
<span class="settings-value w-16 text-right">{{ textIndentLabel }}</span>
</div>
</div>
<!-- Max inline size -->
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div class="mb-3">
@@ -15,6 +15,9 @@ const readerSettingsMock = vi.hoisted(() => ({
fontSize: 16,
lineHeight: 1.5,
paragraphSpacing: 0,
letterSpacing: null,
wordSpacing: null,
textIndent: null,
maxColumnCount: 2,
gap: 0.05,
maxInlineSize: 720,
@@ -57,6 +60,9 @@ describe('EbookSettings', () => {
readerSettingsMock.effective.value.fontFamily = null
readerSettingsMock.effective.value.fontWeight = 400
readerSettingsMock.effective.value.fontStyle = 'normal'
readerSettingsMock.effective.value.letterSpacing = null
readerSettingsMock.effective.value.wordSpacing = null
readerSettingsMock.effective.value.textIndent = null
customFontsMock.cssFamilyAvailable.mockReturnValue(true)
customFontsMock.fetchAllFonts.mockResolvedValue(undefined)
})
@@ -92,7 +98,37 @@ describe('EbookSettings', () => {
expect(slider.attributes('aria-valuetext')).toBe('Book default')
await slider.setValue('1.2')
expect(readerSettingsMock.update).toHaveBeenCalledWith({ paragraphSpacing: 1.2 })
expect(readerSettingsMock.update).toHaveBeenCalledWith({
paragraphSpacing: 1.2,
})
})
it('switches typography defaults between publisher and custom values', async () => {
const wrapper = mount(EbookSettings, { props: { embedded: true } })
await flushPromises()
const letterSource = wrapper.get('select[id^="ebook-letter-spacing-source-"]')
expect((letterSource.element as HTMLSelectElement).value).toBe('book')
await letterSource.setValue('custom')
expect(readerSettingsMock.update).toHaveBeenCalledWith({
letterSpacing: 0,
})
readerSettingsMock.effective.value.letterSpacing = 0.1
const customized = mount(EbookSettings, { props: { embedded: true } })
await flushPromises()
const slider = customized.get('input[id^="ebook-letter-spacing-"]')
expect(slider.attributes('aria-valuetext')).toBe('0.1 em')
await slider.setValue('0.13')
expect(readerSettingsMock.update).toHaveBeenCalledWith({
letterSpacing: 0.13,
})
await customized.get('select[id^="ebook-letter-spacing-source-"]').setValue('book')
expect(readerSettingsMock.update).toHaveBeenCalledWith({
letterSpacing: null,
})
})
it('waits for both font catalogs before resetting an unavailable saved family', async () => {
@@ -107,6 +143,10 @@ describe('EbookSettings', () => {
mount(EbookSettings, { props: { embedded: true } })
await flushPromises()
expect(readerSettingsMock.update).toHaveBeenCalledWith({ fontFamily: null, fontWeight: 700, fontStyle: 'italic' })
expect(readerSettingsMock.update).toHaveBeenCalledWith({
fontFamily: null,
fontWeight: 700,
fontStyle: 'italic',
})
})
})
+15
View File
@@ -1926,6 +1926,15 @@
"paragraphSpacingHint": "Space between paragraphs. Book default preserves the publisher's spacing.",
"paragraphSpacingDefault": "Book default",
"paragraphSpacingValue": "{value} em",
"letterSpacing": "Letter spacing",
"letterSpacingHint": "Space between letters. Book default preserves the publisher's spacing.",
"wordSpacing": "Word spacing",
"wordSpacingHint": "Space between words. Book default preserves the publisher's spacing.",
"textIndent": "First-line indent",
"textIndentHint": "Indent at the start of ordinary paragraphs. Book default preserves the publisher's indentation.",
"bookDefault": "Book default",
"custom": "Custom",
"emValue": "{value} em",
"justify": "Justify text",
"justifyHint": "Align text to both margins",
"hyphenation": "Hyphenation",
@@ -8657,6 +8666,12 @@
"paragraphSpacing": "Paragraph spacing",
"paragraphSpacingDefault": "Default",
"paragraphSpacingValue": "{value} em",
"letterSpacing": "Letter spacing",
"wordSpacing": "Word spacing",
"textIndent": "First-line indent",
"bookDefault": "Book",
"custom": "Custom",
"emValue": "{value} em",
"pageWidth": "Page width",
"pageWidthNarrow": "Narrow",
"pageWidthMedium": "Medium",
+21 -2
View File
@@ -6,6 +6,12 @@ export const EPUB_FONT_SIZE_MIN = 6;
export const EPUB_FONT_SIZE_MAX = 32;
export const EPUB_PARAGRAPH_SPACING_MIN = 0;
export const EPUB_PARAGRAPH_SPACING_MAX = 2;
export const EPUB_LETTER_SPACING_MIN = 0;
export const EPUB_LETTER_SPACING_MAX = 0.2;
export const EPUB_WORD_SPACING_MIN = 0;
export const EPUB_WORD_SPACING_MAX = 0.5;
export const EPUB_TEXT_INDENT_MIN = 0;
export const EPUB_TEXT_INDENT_MAX = 4;
export const CBX_SPREAD_GAP_MIN = 0;
export const CBX_SPREAD_GAP_MAX = 64;
@@ -59,7 +65,10 @@ export function getFormatGroup(format: string): ReaderFormatGroup {
/** Formats a given reader can open, so a caller can ask for "another file this same reader handles". */
export function getOpenableFormatsForGroup(group: ReaderFormatGroup): string[] {
return Object.entries(FORMAT_TO_GROUP)
.filter(([format, formatGroup]) => formatGroup === group && READER_OPENABLE_FORMATS.has(format))
.filter(
([format, formatGroup]) =>
formatGroup === group && READER_OPENABLE_FORMATS.has(format),
)
.map(([format]) => format);
}
@@ -74,6 +83,9 @@ export interface EpubReaderSettings {
fontSize: number; // EPUB_FONT_SIZE_MIN-EPUB_FONT_SIZE_MAX
lineHeight: number; // 0.8-3.0
paragraphSpacing: number; // EPUB_PARAGRAPH_SPACING_MIN-EPUB_PARAGRAPH_SPACING_MAX em; 0 preserves publisher spacing
letterSpacing: number | null; // null preserves publisher spacing
wordSpacing: number | null; // null preserves publisher spacing
textIndent: number | null; // null preserves publisher first-line indentation
maxColumnCount: number; // 1-10
gap: number; // 0-0.5 (column gap as fraction)
maxInlineSize: number; // 400-1600 (max content width in px)
@@ -126,7 +138,11 @@ export type ReaderSettingsMap = {
audio: AudioReaderSettings;
};
export type ReaderSettings = EpubReaderSettings | PdfReaderSettings | CbxReaderSettings | AudioReaderSettings;
export type ReaderSettings =
| EpubReaderSettings
| PdfReaderSettings
| CbxReaderSettings
| AudioReaderSettings;
export const EPUB_READER_DEFAULTS: EpubReaderSettings = {
themeName: "default",
@@ -137,6 +153,9 @@ export const EPUB_READER_DEFAULTS: EpubReaderSettings = {
fontSize: 16,
lineHeight: 1.5,
paragraphSpacing: EPUB_PARAGRAPH_SPACING_MIN,
letterSpacing: null,
wordSpacing: null,
textIndent: null,
maxColumnCount: 2,
gap: 0.05,
maxInlineSize: 720,
@@ -77,7 +77,9 @@ describe('ReaderPreferencesService', () => {
it('verifies file access before loading a per-book preference', async () => {
const user = makeUser({ id: 12 });
mockRepo.findPreference.mockResolvedValueOnce({ settings: { zoomMode: 'fit-width' } });
mockRepo.findPreference.mockResolvedValueOnce({
settings: { zoomMode: 'fit-width' },
});
const result = await service.getPreference(user, 42);
@@ -98,15 +100,25 @@ describe('ReaderPreferencesService', () => {
const user = makeUser({ id: 4 });
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'pdf' });
await service.upsertPreference(user, 10, { zoomMode: 'custom', customScale: 1.4 });
await service.upsertPreference(user, 10, {
zoomMode: 'custom',
customScale: 1.4,
});
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(4, 10, { zoomMode: 'custom', customScale: 1.4 });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(4, 10, {
zoomMode: 'custom',
customScale: 1.4,
});
});
it('accepts new PDF layout choices and normalizes legacy wrapped mode', async () => {
const user = makeUser({ id: 4 });
await service.upsertPreference(user, 10, { scrollMode: 'wrapped', spread: 'auto', zoomMode: 'automatic' });
await service.upsertPreference(user, 10, {
scrollMode: 'wrapped',
spread: 'auto',
zoomMode: 'automatic',
});
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(4, 10, {
scrollMode: 'vertical',
@@ -119,9 +131,17 @@ describe('ReaderPreferencesService', () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'cbz' });
await service.upsertPreference(user, 11, { fitMode: 'fit-page', direction: 'rtl', spreadGap: 24 });
await service.upsertPreference(user, 11, {
fitMode: 'fit-page',
direction: 'rtl',
spreadGap: 24,
});
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 11, { fitMode: 'fit-page', direction: 'rtl', spreadGap: 24 });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 11, {
fitMode: 'fit-page',
direction: 'rtl',
spreadGap: 24,
});
});
it('rejects comic spread gaps outside the supported range', async () => {
@@ -177,7 +197,9 @@ describe('ReaderPreferencesService', () => {
});
it('fills the gapless default for cbx payloads saved before spread gaps were supported', async () => {
const legacyCbxDefaults: Partial<typeof validCbxDefaults> = { ...validCbxDefaults };
const legacyCbxDefaults: Partial<typeof validCbxDefaults> = {
...validCbxDefaults,
};
delete legacyCbxDefaults.spreadGap;
await service.upsertDefault(3, 'cbx', legacyCbxDefaults);
@@ -224,6 +246,9 @@ describe('ReaderPreferencesService', () => {
fontSize: 16,
lineHeight: 1.5,
paragraphSpacing: 0,
letterSpacing: null,
wordSpacing: null,
textIndent: null,
maxColumnCount: 2,
gap: 0.05,
maxInlineSize: 720,
@@ -240,14 +265,27 @@ describe('ReaderPreferencesService', () => {
for (const mode of [0, 1, 2] as const) {
vi.clearAllMocks();
mockRepo.upsertDefault.mockResolvedValue(undefined);
await service.upsertDefault(1, 'epub', { ...validEpubDefaults, footerDisplayMode: mode });
await service.upsertDefault(1, 'epub', {
...validEpubDefaults,
footerDisplayMode: mode,
});
expect(mockRepo.upsertDefault).toHaveBeenCalledWith(1, 'epub', expect.objectContaining({ footerDisplayMode: mode }));
}
});
it('rejects invalid footerDisplayMode values in epub defaults', async () => {
await expect(service.upsertDefault(1, 'epub', { ...validEpubDefaults, footerDisplayMode: 3 })).rejects.toThrow(BadRequestException);
await expect(service.upsertDefault(1, 'epub', { ...validEpubDefaults, footerDisplayMode: -1 })).rejects.toThrow(BadRequestException);
await expect(
service.upsertDefault(1, 'epub', {
...validEpubDefaults,
footerDisplayMode: 3,
}),
).rejects.toThrow(BadRequestException);
await expect(
service.upsertDefault(1, 'epub', {
...validEpubDefaults,
footerDisplayMode: -1,
}),
).rejects.toThrow(BadRequestException);
});
it('rejects a full epub default that omits the font style keys', async () => {
@@ -262,81 +300,181 @@ describe('ReaderPreferencesService', () => {
await expect(service.upsertDefault(1, 'epub', missingParagraphSpacing)).rejects.toThrow(BadRequestException);
});
it('accepts publisher typography defaults represented by null', async () => {
await service.upsertDefault(1, 'epub', validEpubDefaults);
expect(mockRepo.upsertDefault).toHaveBeenCalledWith(
1,
'epub',
expect.objectContaining({
letterSpacing: null,
wordSpacing: null,
textIndent: null,
}),
);
});
it.each(['letterSpacing', 'wordSpacing', 'textIndent'] as const)('rejects a full epub default that omits %s', async (field) => {
const missingField = Object.fromEntries(Object.entries(validEpubDefaults).filter(([key]) => key !== field));
await expect(service.upsertDefault(1, 'epub', missingField)).rejects.toThrow(BadRequestException);
});
it('accepts a full epub default carrying a font weight and style', async () => {
await service.upsertDefault(1, 'epub', { ...validEpubDefaults, fontWeight: 700, fontStyle: 'italic' });
await service.upsertDefault(1, 'epub', {
...validEpubDefaults,
fontWeight: 700,
fontStyle: 'italic',
});
expect(mockRepo.upsertDefault).toHaveBeenCalledWith(1, 'epub', expect.objectContaining({ fontWeight: 700, fontStyle: 'italic' }));
});
it.each([[1], [350], [1000]])('accepts the CSS font weight %s', async (weight) => {
await service.upsertDefault(1, 'epub', { ...validEpubDefaults, fontWeight: weight });
await service.upsertDefault(1, 'epub', {
...validEpubDefaults,
fontWeight: weight,
});
expect(mockRepo.upsertDefault).toHaveBeenCalledWith(1, 'epub', expect.objectContaining({ fontWeight: weight }));
});
it.each([[0], [1001], [400.5]])('rejects the unsupported font weight %s', async (weight) => {
await expect(service.upsertDefault(1, 'epub', { ...validEpubDefaults, fontWeight: weight })).rejects.toThrow(BadRequestException);
await expect(
service.upsertDefault(1, 'epub', {
...validEpubDefaults,
fontWeight: weight,
}),
).rejects.toThrow(BadRequestException);
});
it('rejects an unsupported font style', async () => {
await expect(service.upsertDefault(1, 'epub', { ...validEpubDefaults, fontStyle: 'oblique' })).rejects.toThrow(BadRequestException);
await expect(
service.upsertDefault(1, 'epub', {
...validEpubDefaults,
fontStyle: 'oblique',
}),
).rejects.toThrow(BadRequestException);
});
it('accepts partial epub per-book settings with a font weight and style', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await service.upsertPreference(user, 26, { fontWeight: 300, fontStyle: 'italic' });
await service.upsertPreference(user, 26, {
fontWeight: 300,
fontStyle: 'italic',
});
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 26, { fontWeight: 300, fontStyle: 'italic' });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 26, {
fontWeight: 300,
fontStyle: 'italic',
});
});
it.each([[0], [0.8], [2]])('accepts paragraph spacing %s em', async (paragraphSpacing) => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await service.upsertPreference(user, 27, { paragraphSpacing });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 27, { paragraphSpacing });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 27, {
paragraphSpacing,
});
});
it.each([[-0.1], [2.1]])('rejects paragraph spacing %s em', async (paragraphSpacing) => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await expect(service.upsertPreference(user, 27, { paragraphSpacing })).rejects.toThrow(BadRequestException);
});
it('accepts explicit zero and null typography overrides in partial epub settings', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await service.upsertPreference(user, 28, {
letterSpacing: 0,
wordSpacing: null,
textIndent: 0,
});
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 28, {
letterSpacing: 0,
wordSpacing: null,
textIndent: 0,
});
});
it.each([
['letterSpacing', -0.01],
['letterSpacing', 0.21],
['wordSpacing', -0.01],
['wordSpacing', 0.51],
['textIndent', -0.01],
['textIndent', 4.01],
])('rejects out-of-range typography setting %s=%s', async (field, value) => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await expect(service.upsertPreference(user, 29, { [field]: value })).rejects.toThrow(BadRequestException);
});
it('accepts partial epub per-book settings with footerDisplayMode', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await service.upsertPreference(user, 20, { footerDisplayMode: 1 });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 20, { footerDisplayMode: 1 });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 20, {
footerDisplayMode: 1,
});
});
it('accepts partial epub per-book settings with fixedLayoutSpread', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await service.upsertPreference(user, 22, { fixedLayoutSpread: 'none' });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 22, { fixedLayoutSpread: 'none' });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 22, {
fixedLayoutSpread: 'none',
});
});
it('accepts the minimum epub font size', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await service.upsertPreference(user, 24, { fontSize: 6 });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 24, { fontSize: 6 });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 24, {
fontSize: 6,
});
});
it('rejects epub font sizes below the minimum', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await expect(service.upsertPreference(user, 25, { fontSize: 5 })).rejects.toThrow(BadRequestException);
expect(mockRepo.upsertPreference).not.toHaveBeenCalled();
@@ -344,7 +482,9 @@ describe('ReaderPreferencesService', () => {
it('rejects partial epub per-book settings with invalid footerDisplayMode', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await expect(service.upsertPreference(user, 21, { footerDisplayMode: 5 })).rejects.toThrow(BadRequestException);
expect(mockRepo.upsertPreference).not.toHaveBeenCalled();
@@ -352,7 +492,9 @@ describe('ReaderPreferencesService', () => {
it('rejects partial epub per-book settings with invalid fixedLayoutSpread', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await expect(service.upsertPreference(user, 23, { fixedLayoutSpread: 'two-page' })).rejects.toThrow(BadRequestException);
expect(mockRepo.upsertPreference).not.toHaveBeenCalled();
@@ -6,8 +6,14 @@ import {
CSS_FONT_WEIGHT_MIN,
EPUB_FONT_SIZE_MAX,
EPUB_FONT_SIZE_MIN,
EPUB_LETTER_SPACING_MAX,
EPUB_LETTER_SPACING_MIN,
EPUB_PARAGRAPH_SPACING_MAX,
EPUB_PARAGRAPH_SPACING_MIN,
EPUB_TEXT_INDENT_MAX,
EPUB_TEXT_INDENT_MIN,
EPUB_WORD_SPACING_MAX,
EPUB_WORD_SPACING_MIN,
READER_GROUP_DEFAULTS,
getFormatGroup,
type ReaderFormatGroup,
@@ -31,6 +37,9 @@ const EPUB_SETTINGS_SCHEMA = z
fontSize: z.number().min(EPUB_FONT_SIZE_MIN).max(EPUB_FONT_SIZE_MAX),
lineHeight: z.number().min(0.8).max(3),
paragraphSpacing: z.number().min(EPUB_PARAGRAPH_SPACING_MIN).max(EPUB_PARAGRAPH_SPACING_MAX),
letterSpacing: z.number().min(EPUB_LETTER_SPACING_MIN).max(EPUB_LETTER_SPACING_MAX).nullable(),
wordSpacing: z.number().min(EPUB_WORD_SPACING_MIN).max(EPUB_WORD_SPACING_MAX).nullable(),
textIndent: z.number().min(EPUB_TEXT_INDENT_MIN).max(EPUB_TEXT_INDENT_MAX).nullable(),
maxColumnCount: z.number().int().min(1).max(10),
gap: z.number().min(0).max(0.5),
maxInlineSize: z.number().int().min(400).max(1600),