From 2912429ca63e9a50c8463739e839c05b479999c6 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:15:28 +0000 Subject: [PATCH] feat(reader): add epub typography controls Preserve publisher typography by default while allowing explicit spacing and indentation overrides across reader clients. Ref #1206 --- client/src/features/reader/ReaderView.vue | 36 ++- .../epub/components/ReaderSettingsPanel.vue | 232 ++++++++++++++++-- .../__tests__/ReaderSettingsPanel.spec.ts | 77 +++++- .../__tests__/useReaderState.spec.ts | 57 +++++ .../reader/epub/composables/useReaderState.ts | 64 +++++ .../composables/useReaderSettings.test.ts | 168 +++++++++++-- .../shared/composables/useReaderSettings.ts | 28 ++- .../src/features/settings/EbookSettings.vue | 220 ++++++++++++++++- .../settings/__tests__/EbookSettings.spec.ts | 44 +++- client/src/locales/en.json | 15 ++ packages/types/src/reader-settings.ts | 23 +- .../reader-preferences.service.test.ts | 200 ++++++++++++--- .../reader-preferences.service.ts | 9 + 13 files changed, 1075 insertions(+), 98 deletions(-) diff --git a/client/src/features/reader/ReaderView.vue b/client/src/features/reader/ReaderView.vue index 001d40766..c7b7d28ae 100644 --- a/client/src/features/reader/ReaderView.vue +++ b/client/src/features/reader/ReaderView.vue @@ -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 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(
-

{{ t('reader.loadingBook') }}

+

+ {{ t('reader.loadingBook') }} +

-

{{ t('reader.failedToLoadBook') }}

+

+ {{ t('reader.failedToLoadBook') }} +

{{ error }}

diff --git a/client/src/features/reader/epub/components/ReaderSettingsPanel.vue b/client/src/features/reader/epub/components/ReaderSettingsPanel.vue index 70937c268..de523fc91 100644 --- a/client/src/features/reader/epub/components/ReaderSettingsPanel.vue +++ b/client/src/features/reader/epub/components/ReaderSettingsPanel.vue @@ -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(() => ({ weight: props.state.fontWeight, style: props.state.fontStyle })) +const currentVariant = computed(() => ({ + 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 =