mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4,6 +4,8 @@ export type ReaderFormatGroup = "epub" | "pdf" | "cbx" | "audio";
|
||||
|
||||
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 CBX_SPREAD_GAP_MIN = 0;
|
||||
export const CBX_SPREAD_GAP_MAX = 64;
|
||||
|
||||
@@ -71,6 +73,7 @@ export interface EpubReaderSettings {
|
||||
fontStyle: FontStyle;
|
||||
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
|
||||
maxColumnCount: number; // 1-10
|
||||
gap: number; // 0-0.5 (column gap as fraction)
|
||||
maxInlineSize: number; // 400-1600 (max content width in px)
|
||||
@@ -133,6 +136,7 @@ export const EPUB_READER_DEFAULTS: EpubReaderSettings = {
|
||||
fontStyle: "normal",
|
||||
fontSize: 16,
|
||||
lineHeight: 1.5,
|
||||
paragraphSpacing: EPUB_PARAGRAPH_SPACING_MIN,
|
||||
maxColumnCount: 2,
|
||||
gap: 0.05,
|
||||
maxInlineSize: 720,
|
||||
|
||||
@@ -223,6 +223,7 @@ describe('ReaderPreferencesService', () => {
|
||||
fontStyle: 'normal' as const,
|
||||
fontSize: 16,
|
||||
lineHeight: 1.5,
|
||||
paragraphSpacing: 0,
|
||||
maxColumnCount: 2,
|
||||
gap: 0.05,
|
||||
maxInlineSize: 720,
|
||||
@@ -255,6 +256,12 @@ describe('ReaderPreferencesService', () => {
|
||||
await expect(service.upsertDefault(1, 'epub', missingFontStyle)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a full epub default that omits paragraph spacing', async () => {
|
||||
const missingParagraphSpacing = Object.fromEntries(Object.entries(validEpubDefaults).filter(([key]) => key !== 'paragraphSpacing'));
|
||||
|
||||
await expect(service.upsertDefault(1, 'epub', missingParagraphSpacing)).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' });
|
||||
|
||||
@@ -284,6 +291,22 @@ describe('ReaderPreferencesService', () => {
|
||||
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' });
|
||||
|
||||
await service.upsertPreference(user, 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' });
|
||||
|
||||
await expect(service.upsertPreference(user, 27, { paragraphSpacing })).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('accepts partial epub per-book settings with footerDisplayMode', async () => {
|
||||
const user = makeUser();
|
||||
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
CSS_FONT_WEIGHT_MIN,
|
||||
EPUB_FONT_SIZE_MAX,
|
||||
EPUB_FONT_SIZE_MIN,
|
||||
EPUB_PARAGRAPH_SPACING_MAX,
|
||||
EPUB_PARAGRAPH_SPACING_MIN,
|
||||
READER_GROUP_DEFAULTS,
|
||||
getFormatGroup,
|
||||
type ReaderFormatGroup,
|
||||
@@ -28,6 +30,7 @@ const EPUB_SETTINGS_SCHEMA = z
|
||||
fontStyle: z.enum(['normal', 'italic']),
|
||||
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),
|
||||
maxColumnCount: z.number().int().min(1).max(10),
|
||||
gap: z.number().min(0).max(0.5),
|
||||
maxInlineSize: z.number().int().min(400).max(1600),
|
||||
|
||||
Reference in New Issue
Block a user