feat(reader): add epub paragraph spacing

Preserve publisher spacing at the minimum while allowing synchronized
format defaults and per-book overrides.

Closes #1206
This commit is contained in:
neon
2026-08-31 23:04:55 +00:00
parent 7057d350fd
commit 53cfe16b04
13 changed files with 247 additions and 5 deletions
@@ -82,6 +82,7 @@ const {
applyToRenderer,
setFontSize,
setLineHeight,
setParagraphSpacing,
setFontFamily,
setFontWeight,
setFontStyle,
@@ -342,6 +343,7 @@ onMounted(async () => {
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),
fontFamily: (v) => setFontFamily(v as string | null),
fontWeight: (v) => setFontWeight(v as number),
fontStyle: (v) => setFontStyle(v as EpubReaderSettings['fontStyle']),
@@ -1,7 +1,15 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, useId, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { EPUB_FONT_SIZE_MAX, EPUB_FONT_SIZE_MIN, isCustomFontCssFamily, type FontNamedInstance, type FontVariant } from '@bookorbit/types'
import {
EPUB_FONT_SIZE_MAX,
EPUB_FONT_SIZE_MIN,
EPUB_PARAGRAPH_SPACING_MAX,
EPUB_PARAGRAPH_SPACING_MIN,
isCustomFontCssFamily,
type FontNamedInstance,
type FontVariant,
} from '@bookorbit/types'
import {
BookOpen,
ChevronDown,
@@ -20,6 +28,7 @@ import type { FontFamily, useCustomFonts } from '../composables/useCustomFonts'
import { themes } from '../constants/themes'
import { BUILTIN_READER_FONT_OPTIONS, type ReaderBuiltInFontOption } from '@/features/reader/shared/constants/font-options'
import { formatFontFamilyLabel } from '@/features/reader/shared/lib/font-display'
import { formatNumber } from '@/i18n/formatters'
import { FONT_WEIGHT_LABEL_KEYS, builtInVariants, closestVariant, familyVariants, isSameVariant } from '@/features/reader/shared/lib/font-variants'
import ReaderRangeField from '@/features/reader/shared/components/ReaderRangeField.vue'
import ReaderSegmentedControl from '@/features/reader/shared/components/ReaderSegmentedControl.vue'
@@ -82,6 +91,14 @@ const pageWidthLabel = computed(() => {
return t('reader.settings.pageWidthFull')
})
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 }),
}),
)
const columnGapPercent = computed(() => Math.round(props.state.gap * 100))
function setMode(value: string) {
@@ -108,6 +125,10 @@ function setLineHeight(value: number) {
emit('update', { lineHeight: Math.round(value * 10) / 10 })
}
function setParagraphSpacing(value: number) {
emit('update', { paragraphSpacing: Math.round(value * 10) / 10 })
}
function setPageWidth(value: number) {
emit('update', { maxInlineSize: value })
}
@@ -424,6 +445,20 @@ const cardBaseClass =
/>
</div>
<div class="border-b border-border px-4 py-3.5">
<ReaderRangeField
:model-value="state.paragraphSpacing"
:min="EPUB_PARAGRAPH_SPACING_MIN"
:max="EPUB_PARAGRAPH_SPACING_MAX"
:step="0.1"
:label="t('reader.settings.paragraphSpacing')"
:display-value="paragraphSpacingLabel"
:min-icon="Rows4"
:max-icon="Rows2"
@update:model-value="setParagraphSpacing"
/>
</div>
<div class="border-b border-border px-4 py-3.5">
<ReaderRangeField
:model-value="state.maxInlineSize"
@@ -11,6 +11,7 @@ function makeState(overrides: Partial<ReaderState> = {}): ReaderState {
return {
fontSize: 16,
lineHeight: 1.5,
paragraphSpacing: 0,
fontFamily: null,
fontWeight: 400,
fontStyle: 'normal',
@@ -118,6 +119,7 @@ describe('ReaderSettingsPanel', () => {
expect(wrapper.text()).toContain('Page color')
expect(wrapper.text()).toContain('Font')
expect(wrapper.text()).toContain('Line spacing')
expect(wrapper.text()).toContain('Paragraph spacing')
expect(wrapper.text()).toContain('Page width')
expect(wrapper.text()).toContain('Reading flow')
expect(wrapper.text()).toContain('Advanced layout')
@@ -180,6 +182,18 @@ describe('ReaderSettingsPanel', () => {
expect(wrapper.emitted('update')?.[0]).toEqual([{ lineHeight: 1.8 }])
})
it('labels and emits paragraph spacing from the slider', async () => {
const publisherDefault = mountPanel()
expect(rangeByLabel(publisherDefault, 'Paragraph spacing')!.attributes('aria-valuetext')).toBe('Default')
const wrapper = mountPanel({ state: makeState({ paragraphSpacing: 1.4 }) })
const slider = rangeByLabel(wrapper, 'Paragraph spacing')!
expect(slider.attributes('aria-valuetext')).toBe('1.4 em')
await slider.setValue('1.7')
expect(wrapper.emitted('update')?.[0]).toEqual([{ paragraphSpacing: 1.7 }])
})
it('describes page width in words rather than pixels', async () => {
const wrapper = mountPanel({ state: makeState({ maxInlineSize: 720 }) })
@@ -7,6 +7,7 @@ describe('useReaderState', () => {
state.setFontSize(100)
state.setLineHeight(0.71)
state.setParagraphSpacing(3)
state.setMaxColumnCount(0)
state.setGap(0.9)
state.setMaxInlineSize(200)
@@ -14,13 +15,16 @@ describe('useReaderState', () => {
expect(state.fontSize.value).toBe(32)
expect(state.lineHeight.value).toBe(0.8)
expect(state.paragraphSpacing.value).toBe(2)
expect(state.maxColumnCount.value).toBe(1)
expect(state.gap.value).toBe(0.5)
expect(state.maxInlineSize.value).toBe(400)
expect(state.maxBlockSize.value).toBe(2400)
state.setFontSize(1)
state.setParagraphSpacing(-1)
expect(state.fontSize.value).toBe(6)
expect(state.paragraphSpacing.value).toBe(0)
})
it('selects default theme when unknown theme name is set', () => {
@@ -132,6 +136,24 @@ describe('useReaderState', () => {
expect(css).toContain('hyphens: none;')
})
it('preserves publisher paragraph margins at the default spacing', () => {
const state = useReaderState()
expect(state.paragraphSpacing.value).toBe(0)
expect(state.generateCSS()).not.toContain('margin-block:')
})
it('generates logical paragraph margins for positive spacing', () => {
const state = useReaderState()
state.setParagraphSpacing(1.6)
const css = state.generateCSS()
expect(css).toContain('margin-block: 0 1.6em !important;')
expect(css).toContain(':is(hgroup, header) p')
expect(css).toContain('margin-block: unset !important;')
})
describe('font style', () => {
it('leaves weight and slant out of the CSS while they sit at their defaults', () => {
const state = useReaderState()
@@ -1,5 +1,13 @@
import { computed, ref } from 'vue'
import { EPUB_FONT_SIZE_MAX, EPUB_FONT_SIZE_MIN, EPUB_READER_DEFAULTS, type EpubReaderSettings, type FontStyle } from '@bookorbit/types'
import {
EPUB_FONT_SIZE_MAX,
EPUB_FONT_SIZE_MIN,
EPUB_PARAGRAPH_SPACING_MAX,
EPUB_PARAGRAPH_SPACING_MIN,
EPUB_READER_DEFAULTS,
type EpubReaderSettings,
type FontStyle,
} from '@bookorbit/types'
import { themes } from '../constants/themes'
import type { Theme, ThemeMode } from '../constants/themes'
import type { FoliateRenderer } from './useFoliate'
@@ -7,6 +15,7 @@ import type { FoliateRenderer } from './useFoliate'
export interface ReaderState {
fontSize: number
lineHeight: number
paragraphSpacing: number
fontFamily: string | null
fontWeight: number
fontStyle: FontStyle
@@ -29,6 +38,7 @@ export interface ApplyReaderStateOptions {
const defaults: ReaderState = {
fontSize: 16,
lineHeight: 1.5,
paragraphSpacing: EPUB_READER_DEFAULTS.paragraphSpacing,
fontFamily: null,
fontWeight: EPUB_READER_DEFAULTS.fontWeight,
fontStyle: EPUB_READER_DEFAULTS.fontStyle,
@@ -47,6 +57,7 @@ const defaults: ReaderState = {
export function useReaderState() {
const fontSize = ref(defaults.fontSize)
const lineHeight = ref(defaults.lineHeight)
const paragraphSpacing = ref(defaults.paragraphSpacing)
const fontFamily = ref<string | null>(defaults.fontFamily)
const fontWeight = ref(defaults.fontWeight)
const fontStyle = ref<FontStyle>(defaults.fontStyle)
@@ -66,6 +77,7 @@ export function useReaderState() {
const state = computed<ReaderState>(() => ({
fontSize: fontSize.value,
lineHeight: lineHeight.value,
paragraphSpacing: paragraphSpacing.value,
fontFamily: fontFamily.value,
fontWeight: fontWeight.value,
fontStyle: fontStyle.value,
@@ -89,7 +101,16 @@ export function useReaderState() {
})
function generateCSS(): string {
const { lineHeight: lh, justify: j, hyphenate: h, fontSize: fs, fontFamily: ff, fontWeight: fw, fontStyle: fst } = state.value
const {
lineHeight: lh,
paragraphSpacing: ps,
justify: j,
hyphenate: h,
fontSize: fs,
fontFamily: ff,
fontWeight: fw,
fontStyle: fst,
} = state.value
const mode = activeMode.value
const theme = currentTheme.value
const lightMode = theme.light
@@ -126,6 +147,16 @@ export function useReaderState() {
: ''
const fontFaceBlock = fontFaceCSS.value
const paragraphSpacingRule =
ps === EPUB_PARAGRAPH_SPACING_MIN
? ''
: `
p {
margin-block: 0 ${ps}em !important;
}
:is(hgroup, header) p {
margin-block: unset !important;
}`
return `
${fontFaceBlock}
@@ -208,6 +239,7 @@ export function useReaderState() {
text-align: ${j ? 'justify' : 'start'} !important;
hyphens: ${h ? 'auto' : 'none'};
}
${paragraphSpacingRule}
::selection {
background-color: rgba(128, 128, 128, 0.3);
}
@@ -242,6 +274,9 @@ export function useReaderState() {
function setLineHeight(v: number) {
lineHeight.value = Math.max(0.8, Math.min(3, Math.round(v * 10) / 10))
}
function setParagraphSpacing(v: number) {
paragraphSpacing.value = Math.max(EPUB_PARAGRAPH_SPACING_MIN, Math.min(EPUB_PARAGRAPH_SPACING_MAX, Math.round(v * 10) / 10))
}
function setFontFamily(v: string | null) {
fontFamily.value = v
}
@@ -290,6 +325,7 @@ export function useReaderState() {
state,
fontSize,
lineHeight,
paragraphSpacing,
fontFamily,
fontWeight,
fontStyle,
@@ -310,6 +346,7 @@ export function useReaderState() {
applyToRenderer,
setFontSize,
setLineHeight,
setParagraphSpacing,
setFontFamily,
setFontWeight,
setFontStyle,
@@ -90,6 +90,17 @@ describe('useReaderSettings - load() with valid localStorage epub delta', () =>
expect(s.bookDelta.value).toEqual({ fontWeight: 350, fontStyle: 'italic' })
})
it('preserves publisher paragraph spacing as an explicit per-book override', async () => {
localStorage.setItem('reader:default:epub', JSON.stringify({ ...READER_GROUP_DEFAULTS.epub, paragraphSpacing: 1.2 }))
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify({ paragraphSpacing: 0 }))
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
await s.load()
expect(s.bookDelta.value).toEqual({ paragraphSpacing: 0 })
expect(s.effective.value).toMatchObject({ paragraphSpacing: 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))
@@ -122,6 +133,16 @@ describe('useReaderSettings - load() with invalid localStorage value', () => {
expect(localStorage.getItem(`reader:book:${BOOK_FILE_ID}`)).toBeNull()
})
it('removes paragraph spacing outside the supported range', async () => {
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify({ paragraphSpacing: 2.1 }))
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'))
@@ -447,6 +468,18 @@ describe('useReaderDefaultSettings - load() with localStorage', () => {
expect(JSON.parse(localStorage.getItem('reader:default:epub') ?? '{}')).toMatchObject({ fixedLayoutSpread: 'auto' })
})
it('fills publisher paragraph spacing for older epub defaults in localStorage', async () => {
const stored = { ...READER_GROUP_DEFAULTS.epub, fontSize: 20 }
delete (stored as Record<string, unknown>).paragraphSpacing
localStorage.setItem('reader:default:epub', JSON.stringify(stored))
const s = useReaderDefaultSettings('epub')
await s.load()
expect(s.effective.value).toMatchObject({ fontSize: 20, paragraphSpacing: 0 })
expect(JSON.parse(localStorage.getItem('reader:default:epub') ?? '{}')).toMatchObject({ paragraphSpacing: 0 })
})
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))
@@ -8,6 +8,8 @@ import {
CBX_READER_DEFAULTS,
EPUB_FONT_SIZE_MAX,
EPUB_FONT_SIZE_MIN,
EPUB_PARAGRAPH_SPACING_MAX,
EPUB_PARAGRAPH_SPACING_MIN,
EPUB_READER_DEFAULTS,
PDF_READER_DEFAULTS,
type CbxReaderSettings,
@@ -84,6 +86,9 @@ function sanitizeEpubPartialSettings(settings: unknown): Partial<EpubReaderSetti
if (isNumberInRange(settings.lineHeight, 0.8, 3)) {
out.lineHeight = settings.lineHeight
}
if (isNumberInRange(settings.paragraphSpacing, EPUB_PARAGRAPH_SPACING_MIN, EPUB_PARAGRAPH_SPACING_MAX)) {
out.paragraphSpacing = settings.paragraphSpacing
}
if (isIntegerInRange(settings.maxColumnCount, 1, 10)) {
out.maxColumnCount = settings.maxColumnCount
}
+46 -2
View File
@@ -1,13 +1,19 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, useId, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { EpubReaderSettings, FontNamedInstance } from '@bookorbit/types'
import { isCustomFontCssFamily } from '@bookorbit/types'
import {
EPUB_PARAGRAPH_SPACING_MAX,
EPUB_PARAGRAPH_SPACING_MIN,
isCustomFontCssFamily,
type EpubReaderSettings,
type FontNamedInstance,
} from '@bookorbit/types'
import { useReaderDefaultSettings } from '@/features/reader/shared/composables/useReaderSettings'
import { useCustomFonts } from '@/features/reader/epub/composables/useCustomFonts'
import { themes } from '@/features/reader/epub/constants/themes'
import { BUILTIN_READER_FONT_OPTIONS } from '@/features/reader/shared/constants/font-options'
import { formatFontFamilyLabel } from '@/features/reader/shared/lib/font-display'
import { formatNumber } from '@/i18n/formatters'
import {
FONT_WEIGHT_LABEL_KEYS,
builtInVariants,
@@ -32,6 +38,7 @@ const props = withDefaults(
const { t } = useI18n()
const fontStyleSelectId = `ebook-font-style-${useId()}`
const paragraphSpacingInputId = `ebook-paragraph-spacing-${useId()}`
const { effective, load, update, reset } = useReaderDefaultSettings<EpubReaderSettings>('epub')
@@ -78,12 +85,23 @@ function fontStyleLabel(variant: FontNamedInstance): string {
}
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 }),
}),
)
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 })
}
function setParagraphSpacing(event: Event) {
update({ paragraphSpacing: Number((event.target as HTMLInputElement).value) })
}
/**
* Switching family also moves the style when the new family lacks the current one, which
* would otherwise leave the style select showing a value it cannot offer.
@@ -501,6 +519,32 @@ function setFixedLayoutSpreadNone() {
/>
</div>
<!-- Paragraph spacing -->
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div class="mb-3">
<div class="flex items-center justify-between gap-3">
<label :for="paragraphSpacingInputId" class="settings-label">
{{ t('settings.reader.ebook.paragraphSpacing') }}
</label>
<span class="settings-value">{{ paragraphSpacingLabel }}</span>
</div>
<p class="settings-hint">
{{ t('settings.reader.ebook.paragraphSpacingHint') }}
</p>
</div>
<input
:id="paragraphSpacingInputId"
type="range"
:min="EPUB_PARAGRAPH_SPACING_MIN"
:max="EPUB_PARAGRAPH_SPACING_MAX"
step="0.1"
class="w-full accent-primary cursor-pointer"
:value="effective.paragraphSpacing"
:aria-valuetext="paragraphSpacingLabel"
@input="setParagraphSpacing"
/>
</div>
<!-- Justify -->
<div class="settings-row">
<div>
@@ -14,6 +14,7 @@ const readerSettingsMock = vi.hoisted(() => ({
fontStyle: 'normal',
fontSize: 16,
lineHeight: 1.5,
paragraphSpacing: 0,
maxColumnCount: 2,
gap: 0.05,
maxInlineSize: 720,
@@ -82,6 +83,18 @@ describe('EbookSettings', () => {
expect(wrapper.get(`label[for="${select.attributes('id')}"]`).text()).toBe('Font style')
})
it('shows and updates the paragraph spacing default', async () => {
const wrapper = mount(EbookSettings, { props: { embedded: true } })
await flushPromises()
const slider = wrapper.get('input[id^="ebook-paragraph-spacing-"]')
expect(wrapper.get(`label[for="${slider.attributes('id')}"]`).text()).toBe('Paragraph spacing')
expect(slider.attributes('aria-valuetext')).toBe('Book default')
await slider.setValue('1.2')
expect(readerSettingsMock.update).toHaveBeenCalledWith({ paragraphSpacing: 1.2 })
})
it('waits for both font catalogs before resetting an unavailable saved family', async () => {
readerSettingsMock.effective.value.fontFamily = fontCssFamilyGroupName('Removed')
readerSettingsMock.effective.value.fontWeight = 1000
+7
View File
@@ -1922,6 +1922,10 @@
"fontSizeHint": "Base text size in pixels",
"lineHeight": "Line height",
"lineHeightHint": "Vertical spacing between lines",
"paragraphSpacing": "Paragraph spacing",
"paragraphSpacingHint": "Space between paragraphs. Book default preserves the publisher's spacing.",
"paragraphSpacingDefault": "Book default",
"paragraphSpacingValue": "{value} em",
"justify": "Justify text",
"justifyHint": "Align text to both margins",
"hyphenation": "Hyphenation",
@@ -8650,6 +8654,9 @@
"fontStyle": "Style",
"fontStyleItalicOf": "{style} Italic",
"lineSpacing": "Line spacing",
"paragraphSpacing": "Paragraph spacing",
"paragraphSpacingDefault": "Default",
"paragraphSpacingValue": "{value} em",
"pageWidth": "Page width",
"pageWidthNarrow": "Narrow",
"pageWidthMedium": "Medium",