mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(reader): let readers pick the font style for body text
Read variable-font axes and named instances so readers can select real weight and style variants instead of synthesized faces. Preserve the axis default, accept the full CSS weight range, and keep saved choices stable while font catalogs load. Require the new settings fields for complete EPUB defaults, keep per-book patches partial, and widen the persisted font-weight constraints to match CSS. Closes #533
This commit is contained in:
@@ -80,6 +80,8 @@ const {
|
||||
setFontSize,
|
||||
setLineHeight,
|
||||
setFontFamily,
|
||||
setFontWeight,
|
||||
setFontStyle,
|
||||
setMaxColumnCount,
|
||||
setGap,
|
||||
setMaxInlineSize,
|
||||
@@ -336,6 +338,8 @@ const epubSetters: Record<string, (v: unknown) => void> = {
|
||||
fontSize: (v) => setFontSize(v as number),
|
||||
lineHeight: (v) => setLineHeight(v as number),
|
||||
fontFamily: (v) => setFontFamily(v as string | null),
|
||||
fontWeight: (v) => setFontWeight(v as number),
|
||||
fontStyle: (v) => setFontStyle(v as EpubReaderSettings['fontStyle']),
|
||||
maxColumnCount: (v) => setMaxColumnCount(v as number),
|
||||
gap: (v) => setGap(v as number),
|
||||
maxInlineSize: (v) => setMaxInlineSize(v as number),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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 } from '@bookorbit/types'
|
||||
import { EPUB_FONT_SIZE_MAX, EPUB_FONT_SIZE_MIN, isCustomFontCssFamily, type FontNamedInstance, type FontVariant } from '@bookorbit/types'
|
||||
import {
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
@@ -20,6 +20,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 { 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'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
@@ -44,6 +45,7 @@ const COLUMN_MAX = 10
|
||||
const contentRef = ref<HTMLElement | null>(null)
|
||||
const isScrolled = ref(false)
|
||||
|
||||
const fontStyleLabelId = `reader-font-style-${useId()}`
|
||||
const justifyLabelId = `reader-justify-${useId()}`
|
||||
const hyphenationLabelId = `reader-hyphenation-${useId()}`
|
||||
|
||||
@@ -99,7 +101,7 @@ function increaseTextSize() {
|
||||
}
|
||||
|
||||
function selectBuiltInFont(font: ReaderBuiltInFontOption) {
|
||||
emit('update', { fontFamily: font.value })
|
||||
emit('update', { fontFamily: font.value, ...variantUpdateFor(builtInVariants()) })
|
||||
}
|
||||
|
||||
function setLineHeight(value: number) {
|
||||
@@ -184,7 +186,7 @@ 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 })
|
||||
if (cssFamilyName) emit('update', { fontFamily: cssFamilyName, ...variantUpdateFor(familyVariants(family.variants)) })
|
||||
}
|
||||
|
||||
function isCustomFontSelected(family: FontFamily): boolean {
|
||||
@@ -192,6 +194,59 @@ 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 }))
|
||||
|
||||
/**
|
||||
* The styles the selected family offers. Built-in stacks have no file list to read, so
|
||||
* they fall back to the four a system font can always produce.
|
||||
*/
|
||||
const availableVariants = computed<FontNamedInstance[]>(() => {
|
||||
const customFonts = props.customFonts
|
||||
const selected = props.state.fontFamily
|
||||
if (!customFonts || !isCustomFontCssFamily(selected)) return builtInVariants()
|
||||
|
||||
const family = [...customFonts.visibleServerFamilies.value, ...customFonts.families.value].find((candidate) => candidate.cssFamilyName === selected)
|
||||
return family ? familyVariants(family.variants) : builtInVariants()
|
||||
})
|
||||
|
||||
/** A single style is not a choice, so the row only appears once there is one to make. */
|
||||
const showVariantPicker = computed(() => availableVariants.value.length > 1)
|
||||
|
||||
/** Renders each chip in the face it selects. Generic keywords must stay unquoted. */
|
||||
const variantPreviewFamily = computed(() => {
|
||||
const selected = props.state.fontFamily
|
||||
if (!selected) return undefined
|
||||
return isCustomFontCssFamily(selected) ? `'${selected}', sans-serif` : selected
|
||||
})
|
||||
|
||||
function isVariantSelected(variant: FontVariant): boolean {
|
||||
return isSameVariant(variant, currentVariant.value)
|
||||
}
|
||||
|
||||
function selectVariant(variant: FontVariant) {
|
||||
emit('update', { fontWeight: variant.weight, fontStyle: variant.style })
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the chosen style reachable when the family changes. A family that lacks the
|
||||
* current style would otherwise leave the row with nothing selected, so the nearest
|
||||
* style it does offer takes over.
|
||||
*/
|
||||
function variantUpdateFor(variants: FontVariant[]): Partial<ReaderState> {
|
||||
if (variants.some((variant) => isSameVariant(variant, currentVariant.value))) return {}
|
||||
|
||||
const fallback = closestVariant(variants, currentVariant.value)
|
||||
return fallback ? { fontWeight: fallback.weight, fontStyle: fallback.style } : {}
|
||||
}
|
||||
|
||||
function variantLabel(variant: FontNamedInstance): string {
|
||||
const weightKey = FONT_WEIGHT_LABEL_KEYS[variant.weight]
|
||||
const base = variant.name ?? (weightKey ? t(weightKey) : String(variant.weight))
|
||||
// A designer's own name usually says so already; only spell it out when it does not.
|
||||
if (variant.style !== 'italic' || /italic|oblique/i.test(base)) return base
|
||||
return t('reader.settings.fontStyleItalicOf', { style: base })
|
||||
}
|
||||
|
||||
const groupLabelClass = 'mb-2 block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground'
|
||||
const stepperButtonClass =
|
||||
'flex h-10 flex-1 items-center justify-center rounded-lg border border-border font-serif text-foreground transition-colors hover:bg-muted disabled:opacity-40 disabled:hover:bg-transparent'
|
||||
@@ -329,6 +384,30 @@ const cardBaseClass =
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="showVariantPicker">
|
||||
<p :id="fontStyleLabelId" class="mb-1.5 mt-3 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{{ t('reader.settings.fontStyle') }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2" role="group" :aria-labelledby="fontStyleLabelId">
|
||||
<button
|
||||
v-for="variant in availableVariants"
|
||||
:key="`${variant.weight}:${variant.style}`"
|
||||
type="button"
|
||||
class="h-9 rounded-lg border px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/55 focus-visible:ring-offset-1 focus-visible:ring-offset-card"
|
||||
:class="
|
||||
isVariantSelected(variant)
|
||||
? '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 }"
|
||||
:aria-pressed="isVariantSelected(variant)"
|
||||
@click="selectVariant(variant)"
|
||||
>
|
||||
{{ variantLabel(variant) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-border px-4 py-3.5">
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { UserFont } from '@bookorbit/types'
|
||||
import { fontCssFamilyGroupName } from '@bookorbit/types'
|
||||
import ReaderSettingsPanel from '../ReaderSettingsPanel.vue'
|
||||
import type { ReaderState } from '../../composables/useReaderState'
|
||||
import type { FontFamily, useCustomFonts } from '../../composables/useCustomFonts'
|
||||
|
||||
function makeState(overrides: Partial<ReaderState> = {}): ReaderState {
|
||||
return {
|
||||
fontSize: 16,
|
||||
lineHeight: 1.5,
|
||||
fontFamily: null,
|
||||
fontWeight: 400,
|
||||
fontStyle: 'normal',
|
||||
maxColumnCount: 2,
|
||||
gap: 0.05,
|
||||
maxInlineSize: 720,
|
||||
@@ -51,6 +57,59 @@ function buttonByText(wrapper: VueWrapper, text: string) {
|
||||
return wrapper.findAll('button').find((button) => button.text() === text)
|
||||
}
|
||||
|
||||
/** 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) => {
|
||||
const labelId = candidate.attributes('aria-labelledby')
|
||||
return labelId ? wrapper.find(`[id="${labelId}"]`).text() === 'Style' : false
|
||||
})
|
||||
return group ? group.findAll('button') : []
|
||||
}
|
||||
|
||||
function styleChipLabels(wrapper: VueWrapper) {
|
||||
return styleChips(wrapper).map((chip) => chip.text())
|
||||
}
|
||||
|
||||
function styleChip(wrapper: VueWrapper, label: string) {
|
||||
return styleChips(wrapper).find((chip) => chip.text() === label)
|
||||
}
|
||||
|
||||
const userCss = (name: string) => fontCssFamilyGroupName(name, 'user')
|
||||
|
||||
function makeFont(overrides: Partial<UserFont> = {}): UserFont {
|
||||
return {
|
||||
id: 1,
|
||||
familyName: 'Test',
|
||||
originalFileName: 'Test.ttf',
|
||||
format: 'ttf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
weightMin: null,
|
||||
weightMax: null,
|
||||
instances: null,
|
||||
fileSize: 1000,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeFamily(name: string, variants: UserFont[]): FontFamily {
|
||||
return { name, cssFamilyName: userCss(name), scope: 'user', variants }
|
||||
}
|
||||
|
||||
/** The slice of useCustomFonts the panel actually reads. */
|
||||
function makeCustomFonts(families: FontFamily[]) {
|
||||
return {
|
||||
fonts: ref(families.flatMap((family) => family.variants)),
|
||||
serverFonts: ref([] as UserFont[]),
|
||||
families: computed(() => families),
|
||||
visibleServerFamilies: computed(() => [] as FontFamily[]),
|
||||
isFontFamilySelected: (name: string, current: string | null) => current === userCss(name),
|
||||
getCssFamilyForDisplay: (name: string) => userCss(name),
|
||||
generateFontFaceCSS: () => '',
|
||||
} as unknown as ReturnType<typeof useCustomFonts>
|
||||
}
|
||||
|
||||
describe('ReaderSettingsPanel', () => {
|
||||
it('presents every reflowable setting without tab navigation', () => {
|
||||
const wrapper = mountPanel()
|
||||
@@ -189,4 +248,72 @@ describe('ReaderSettingsPanel', () => {
|
||||
expect(wrapper.text()).not.toContain('Page spreads')
|
||||
expect(wrapper.text()).not.toContain('Single page')
|
||||
})
|
||||
|
||||
describe('font style', () => {
|
||||
it('offers the four system styles while a built-in stack is selected', () => {
|
||||
const wrapper = mountPanel({ state: makeState({ fontFamily: 'serif' }) })
|
||||
|
||||
expect(styleChipLabels(wrapper)).toEqual(['Regular', 'Bold', 'Regular Italic', 'Bold Italic'])
|
||||
})
|
||||
|
||||
it('emits the weight and slant of the chosen style', async () => {
|
||||
const wrapper = mountPanel({ state: makeState({ fontFamily: 'serif' }) })
|
||||
|
||||
await styleChip(wrapper, 'Bold Italic')?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update')?.[0]).toEqual([{ fontWeight: 700, fontStyle: 'italic' }])
|
||||
})
|
||||
|
||||
it('marks the style in use as pressed', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
it('names each style the way its designer did for a variable font', () => {
|
||||
const customFonts = makeCustomFonts([
|
||||
makeFamily('Inter', [
|
||||
makeFont({
|
||||
weightMin: 100,
|
||||
weightMax: 900,
|
||||
instances: [
|
||||
{ name: 'Thin', weight: 100, style: 'normal' },
|
||||
{ name: 'Book', weight: 400, style: 'normal' },
|
||||
{ name: 'Heavy', weight: 900, style: 'normal' },
|
||||
],
|
||||
}),
|
||||
]),
|
||||
])
|
||||
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 })
|
||||
|
||||
expect(wrapper.text()).not.toContain('Style')
|
||||
expect(styleChipLabels(wrapper)).toEqual([])
|
||||
})
|
||||
|
||||
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 })
|
||||
|
||||
await buttonByText(wrapper, 'Sparse')?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update')?.[0]).toEqual([{ fontFamily: userCss('Sparse'), fontWeight: 500, fontStyle: 'normal' }])
|
||||
})
|
||||
|
||||
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 })
|
||||
|
||||
await buttonByText(wrapper, 'Full')?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update')?.[0]).toEqual([{ fontFamily: userCss('Full') }])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,9 @@ const mockFonts: UserFont[] = [
|
||||
format: 'ttf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
weightMin: null,
|
||||
weightMax: null,
|
||||
instances: null,
|
||||
fileSize: 50000,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
@@ -23,6 +26,9 @@ const mockFonts: UserFont[] = [
|
||||
format: 'ttf',
|
||||
weight: 700,
|
||||
style: 'normal',
|
||||
weightMin: null,
|
||||
weightMax: null,
|
||||
instances: null,
|
||||
fileSize: 55000,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
@@ -33,6 +39,9 @@ const mockFonts: UserFont[] = [
|
||||
format: 'woff2',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
weightMin: null,
|
||||
weightMax: null,
|
||||
instances: null,
|
||||
fileSize: 30000,
|
||||
createdAt: '2026-01-02T00:00:00.000Z',
|
||||
},
|
||||
@@ -416,6 +425,63 @@ describe('useCustomFonts', () => {
|
||||
expect(css).toContain('format("woff2")')
|
||||
})
|
||||
|
||||
it('declares a variable font as a weight range, so every weight renders from the axis', () => {
|
||||
composable.fonts.value = [
|
||||
{
|
||||
id: 9,
|
||||
familyName: 'Inter',
|
||||
originalFileName: 'Inter-Variable.ttf',
|
||||
format: 'ttf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
weightMin: 100,
|
||||
weightMax: 900,
|
||||
instances: [{ name: 'Bold', weight: 700, style: 'normal' }],
|
||||
fileSize: 60000,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
]
|
||||
|
||||
const css = composable.generateFontFaceCSS()
|
||||
|
||||
expect(css).toContain('font-weight: 100 900;')
|
||||
expect(css).toContain('font-style: normal;')
|
||||
})
|
||||
|
||||
it('registers every style axis exposed by a variable font', () => {
|
||||
composable.fonts.value = [
|
||||
{
|
||||
id: 9,
|
||||
familyName: 'Recursive',
|
||||
originalFileName: 'Recursive-Variable.ttf',
|
||||
format: 'ttf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
weightMin: 300,
|
||||
weightMax: 1000,
|
||||
instances: [
|
||||
{ name: 'Regular', weight: 400, style: 'normal' },
|
||||
{ name: 'Italic', weight: 400, style: 'italic' },
|
||||
],
|
||||
fileSize: 60000,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
]
|
||||
|
||||
const css = composable.generateFontFaceCSS()
|
||||
|
||||
expect(css.match(/@font-face/g)).toHaveLength(2)
|
||||
expect(css).toContain('font-style: normal;')
|
||||
expect(css).toContain('font-style: italic;')
|
||||
expect(css.match(/font-weight: 300 1000;/g)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps a static font pinned to its single weight', () => {
|
||||
composable.fonts.value = [mockFonts[1]!]
|
||||
|
||||
expect(composable.generateFontFaceCSS()).toContain('font-weight: 700;')
|
||||
})
|
||||
|
||||
it('falls back to API URL when blob cache is missing', () => {
|
||||
composable.fonts.value = [...mockFonts]
|
||||
|
||||
@@ -482,6 +548,9 @@ describe('useCustomFonts', () => {
|
||||
format: 'otf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
weightMin: null,
|
||||
weightMax: null,
|
||||
instances: null,
|
||||
fileSize: 60000,
|
||||
createdAt: '2026-01-03T00:00:00.000Z',
|
||||
},
|
||||
@@ -492,6 +561,9 @@ describe('useCustomFonts', () => {
|
||||
format: 'ttf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
weightMin: null,
|
||||
weightMax: null,
|
||||
instances: null,
|
||||
fileSize: 51000,
|
||||
createdAt: '2026-01-03T00:00:00.000Z',
|
||||
},
|
||||
@@ -807,6 +879,9 @@ describe('useCustomFonts', () => {
|
||||
format: 'otf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
weightMin: null,
|
||||
weightMax: null,
|
||||
instances: null,
|
||||
fileSize: 60000,
|
||||
createdAt: '2026-01-03T00:00:00.000Z',
|
||||
},
|
||||
@@ -817,6 +892,9 @@ describe('useCustomFonts', () => {
|
||||
format: 'otf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
weightMin: null,
|
||||
weightMax: null,
|
||||
instances: null,
|
||||
fileSize: 61000,
|
||||
createdAt: '2026-01-03T00:00:00.000Z',
|
||||
},
|
||||
|
||||
@@ -131,4 +131,52 @@ describe('useReaderState', () => {
|
||||
expect(css).toContain('text-align: start !important;')
|
||||
expect(css).toContain('hyphens: none;')
|
||||
})
|
||||
|
||||
describe('font style', () => {
|
||||
it('leaves weight and slant out of the CSS while they sit at their defaults', () => {
|
||||
const state = useReaderState()
|
||||
state.setFontFamily('serif')
|
||||
|
||||
const css = state.generateCSS()
|
||||
|
||||
expect(css).not.toContain('font-weight:')
|
||||
expect(css).not.toContain('font-style:')
|
||||
})
|
||||
|
||||
it('applies a chosen weight and slant to the body', () => {
|
||||
const state = useReaderState()
|
||||
state.setFontFamily('serif')
|
||||
state.setFontWeight(700)
|
||||
state.setFontStyle('italic')
|
||||
|
||||
const css = state.generateCSS()
|
||||
|
||||
expect(css).toContain('font-weight: 700 !important;')
|
||||
expect(css).toContain('font-style: italic !important;')
|
||||
})
|
||||
|
||||
it("never pushes weight or slant onto descendants, which would flatten the book's emphasis", () => {
|
||||
const state = useReaderState()
|
||||
state.setFontFamily('serif')
|
||||
state.setFontWeight(700)
|
||||
state.setFontStyle('italic')
|
||||
|
||||
const descendantRule = state.generateCSS().match(/body \*\s*\{[^}]*\}/)?.[0] ?? ''
|
||||
|
||||
expect(descendantRule).toContain('font-family: inherit !important;')
|
||||
expect(descendantRule).not.toContain('font-weight')
|
||||
expect(descendantRule).not.toContain('font-style')
|
||||
})
|
||||
|
||||
it("applies a style to the book's own font when no family is chosen", () => {
|
||||
const state = useReaderState()
|
||||
state.setFontWeight(300)
|
||||
|
||||
const css = state.generateCSS()
|
||||
|
||||
expect(css).toContain('font-weight: 300 !important;')
|
||||
expect(css).not.toContain('font-family:')
|
||||
expect(css).not.toContain('body *')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { computed, getCurrentInstance, onUnmounted, ref, type ComputedRef, type Ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { UserFont, FontFormat, FontScope, FontUploadResult, ServerFontPreferences } from '@bookorbit/types'
|
||||
import { FONT_FORMAT_CSS_FORMAT, fontCssFamilyGroupName, fontScopeFromCssFamily, maxFontsForScope } from '@bookorbit/types'
|
||||
import { FONT_FORMAT_CSS_FORMAT, fontCssFamilyGroupName, fontScopeFromCssFamily, isVariableFont, maxFontsForScope } from '@bookorbit/types'
|
||||
|
||||
const ACCEPTED_EXTENSIONS = '.ttf,.otf,.woff,.woff2'
|
||||
|
||||
@@ -285,16 +285,25 @@ export function useCustomFonts() {
|
||||
/** The server families this reader has kept. Drives every font picker. */
|
||||
const visibleServerFamilies = computed<FontFamily[]>(() => serverFamilies.value.filter((family) => !isServerFamilyHidden(family.name)))
|
||||
|
||||
function fontFaceRule({ scope, font }: ScopedFont): string {
|
||||
function fontFaceRules({ scope, font }: ScopedFont): string {
|
||||
const cssFormat = FONT_FORMAT_CSS_FORMAT[font.format as FontFormat]
|
||||
const src = fontBlobUrls.get(blobKey(scope, font.id)) ?? `${ENDPOINTS[scope]}/${font.id}/file`
|
||||
return `@font-face {
|
||||
// A variable font declares the span of its weight axis, which is what tells the
|
||||
// browser to interpolate a real face for every weight in it rather than synthesising
|
||||
// one from the default instance.
|
||||
const weight = isVariableFont(font) ? `${font.weightMin} ${font.weightMax}` : `${font.weight}`
|
||||
const styles = new Set([font.style, ...(isVariableFont(font) ? (font.instances ?? []).map((instance) => instance.style) : [])])
|
||||
return [...styles]
|
||||
.map(
|
||||
(style) => `@font-face {
|
||||
font-family: "${fontCssFamilyGroupName(font.familyName, scope)}";
|
||||
src: url("${src}") format("${cssFormat}");
|
||||
font-weight: ${font.weight};
|
||||
font-style: ${font.style};
|
||||
font-weight: ${weight};
|
||||
font-style: ${style};
|
||||
font-display: swap;
|
||||
}`
|
||||
}`,
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,12 +317,12 @@ export function useCustomFonts() {
|
||||
* neither overrides the other.
|
||||
*/
|
||||
function generateFontFaceCSS(): string {
|
||||
return allScopedFonts().map(fontFaceRule).join('\n')
|
||||
return allScopedFonts().map(fontFaceRules).join('\n')
|
||||
}
|
||||
|
||||
function generateScopeFontFaceCSS(scope: FontScope): string {
|
||||
return listFor(scope)
|
||||
.value.map((font) => fontFaceRule({ scope, font }))
|
||||
.value.map((font) => fontFaceRules({ scope, font }))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { EPUB_FONT_SIZE_MAX, EPUB_FONT_SIZE_MIN, type EpubReaderSettings } from '@bookorbit/types'
|
||||
import { EPUB_FONT_SIZE_MAX, EPUB_FONT_SIZE_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'
|
||||
@@ -8,6 +8,8 @@ export interface ReaderState {
|
||||
fontSize: number
|
||||
lineHeight: number
|
||||
fontFamily: string | null
|
||||
fontWeight: number
|
||||
fontStyle: FontStyle
|
||||
maxColumnCount: number
|
||||
gap: number
|
||||
maxInlineSize: number
|
||||
@@ -28,6 +30,8 @@ const defaults: ReaderState = {
|
||||
fontSize: 16,
|
||||
lineHeight: 1.5,
|
||||
fontFamily: null,
|
||||
fontWeight: EPUB_READER_DEFAULTS.fontWeight,
|
||||
fontStyle: EPUB_READER_DEFAULTS.fontStyle,
|
||||
maxColumnCount: 2,
|
||||
gap: 0.05,
|
||||
maxInlineSize: 720,
|
||||
@@ -44,6 +48,8 @@ export function useReaderState() {
|
||||
const fontSize = ref(defaults.fontSize)
|
||||
const lineHeight = ref(defaults.lineHeight)
|
||||
const fontFamily = ref<string | null>(defaults.fontFamily)
|
||||
const fontWeight = ref(defaults.fontWeight)
|
||||
const fontStyle = ref<FontStyle>(defaults.fontStyle)
|
||||
const maxColumnCount = ref(defaults.maxColumnCount)
|
||||
const gap = ref(defaults.gap)
|
||||
const maxInlineSize = ref(defaults.maxInlineSize)
|
||||
@@ -61,6 +67,8 @@ export function useReaderState() {
|
||||
fontSize: fontSize.value,
|
||||
lineHeight: lineHeight.value,
|
||||
fontFamily: fontFamily.value,
|
||||
fontWeight: fontWeight.value,
|
||||
fontStyle: fontStyle.value,
|
||||
maxColumnCount: maxColumnCount.value,
|
||||
gap: gap.value,
|
||||
maxInlineSize: maxInlineSize.value,
|
||||
@@ -81,7 +89,7 @@ export function useReaderState() {
|
||||
})
|
||||
|
||||
function generateCSS(): string {
|
||||
const { lineHeight: lh, justify: j, hyphenate: h, fontSize: fs, fontFamily: ff } = state.value
|
||||
const { lineHeight: lh, 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
|
||||
@@ -93,16 +101,30 @@ export function useReaderState() {
|
||||
// iOS in Dark Mode always matches prefers-color-scheme:dark and ignores the app setting.
|
||||
const forceBg = dark || lightMode.bg !== '#ffffff'
|
||||
|
||||
const fontFamilyRule = ff
|
||||
// Weight and slant are set on body alone. Descendants inherit the family so bold and
|
||||
// italic runs render from the chosen typeface, but keep their own weight and slant, so
|
||||
// a bold or italic base style does not flatten the emphasis the book marked up.
|
||||
// Both are emitted only when moved off the default, leaving untouched books as they were.
|
||||
const bodyDeclarations = [
|
||||
ff ? `font-family: ${ff} !important;` : '',
|
||||
fw === defaults.fontWeight ? '' : `font-weight: ${fw} !important;`,
|
||||
fst === defaults.fontStyle ? '' : `font-style: ${fst} !important;`,
|
||||
].filter(Boolean)
|
||||
|
||||
const inheritFamilyRule = ff
|
||||
? `
|
||||
body {
|
||||
font-family: ${ff} !important;
|
||||
}
|
||||
body * {
|
||||
font-family: inherit !important;
|
||||
}`
|
||||
: ''
|
||||
|
||||
const bodyFontRule = bodyDeclarations.length
|
||||
? `
|
||||
body {
|
||||
${bodyDeclarations.join('\n ')}
|
||||
}${inheritFamilyRule}`
|
||||
: ''
|
||||
|
||||
const fontFaceBlock = fontFaceCSS.value
|
||||
|
||||
return `
|
||||
@@ -120,7 +142,7 @@ export function useReaderState() {
|
||||
color-scheme: ${dark ? 'dark' : 'light'};
|
||||
color: ${mode.fg};
|
||||
font-size: ${fs}px;
|
||||
}${fontFamilyRule}
|
||||
}${bodyFontRule}
|
||||
a:any-link {
|
||||
color: ${mode.link};
|
||||
text-decoration-color: light-dark(
|
||||
@@ -223,6 +245,12 @@ export function useReaderState() {
|
||||
function setFontFamily(v: string | null) {
|
||||
fontFamily.value = v
|
||||
}
|
||||
function setFontWeight(v: number) {
|
||||
fontWeight.value = v
|
||||
}
|
||||
function setFontStyle(v: FontStyle) {
|
||||
fontStyle.value = v
|
||||
}
|
||||
function setMaxColumnCount(v: number) {
|
||||
maxColumnCount.value = Math.max(1, Math.min(10, v))
|
||||
}
|
||||
@@ -263,6 +291,8 @@ export function useReaderState() {
|
||||
fontSize,
|
||||
lineHeight,
|
||||
fontFamily,
|
||||
fontWeight,
|
||||
fontStyle,
|
||||
maxColumnCount,
|
||||
gap,
|
||||
maxInlineSize,
|
||||
@@ -281,6 +311,8 @@ export function useReaderState() {
|
||||
setFontSize,
|
||||
setLineHeight,
|
||||
setFontFamily,
|
||||
setFontWeight,
|
||||
setFontStyle,
|
||||
setMaxColumnCount,
|
||||
setGap,
|
||||
setMaxInlineSize,
|
||||
|
||||
@@ -81,6 +81,15 @@ describe('useReaderSettings - load() with valid localStorage epub delta', () =>
|
||||
expect(s.bookDelta.value).toEqual({ fontSize: 6 })
|
||||
})
|
||||
|
||||
it('preserves variable-font weights across the full CSS range', async () => {
|
||||
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify({ fontWeight: 350, fontStyle: 'italic' }))
|
||||
|
||||
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
|
||||
await s.load()
|
||||
|
||||
expect(s.bookDelta.value).toEqual({ fontWeight: 350, fontStyle: 'italic' })
|
||||
})
|
||||
|
||||
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))
|
||||
@@ -94,6 +103,15 @@ describe('useReaderSettings - load() with valid localStorage epub delta', () =>
|
||||
})
|
||||
|
||||
describe('useReaderSettings - load() with invalid localStorage value', () => {
|
||||
it('removes a font weight outside the CSS range', async () => {
|
||||
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify({ fontWeight: 1001 }))
|
||||
|
||||
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
|
||||
await s.load()
|
||||
|
||||
expect(s.bookDelta.value).toBeNull()
|
||||
})
|
||||
|
||||
it('removes an EPUB font size below the supported minimum', async () => {
|
||||
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify({ fontSize: 5 }))
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type ReaderSettings,
|
||||
READER_GROUP_DEFAULTS,
|
||||
getFormatGroup,
|
||||
isCssFontWeight,
|
||||
} from '@bookorbit/types'
|
||||
|
||||
// -- Shared localStorage helpers --
|
||||
@@ -71,6 +72,12 @@ function sanitizeEpubPartialSettings(settings: unknown): Partial<EpubReaderSetti
|
||||
if ((typeof settings.fontFamily === 'string' && settings.fontFamily.length > 0) || settings.fontFamily === null) {
|
||||
out.fontFamily = settings.fontFamily
|
||||
}
|
||||
if (isCssFontWeight(settings.fontWeight)) {
|
||||
out.fontWeight = settings.fontWeight
|
||||
}
|
||||
if (settings.fontStyle === 'normal' || settings.fontStyle === 'italic') {
|
||||
out.fontStyle = settings.fontStyle
|
||||
}
|
||||
if (isNumberInRange(settings.fontSize, EPUB_FONT_SIZE_MIN, EPUB_FONT_SIZE_MAX)) {
|
||||
out.fontSize = settings.fontSize
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FontNamedInstance, UserFont } from '@bookorbit/types'
|
||||
import { builtInVariants, closestVariant, familyVariants, isSameVariant, variantKey } from '../font-variants'
|
||||
|
||||
function makeFont(overrides: Partial<UserFont> = {}): UserFont {
|
||||
return {
|
||||
id: 1,
|
||||
familyName: 'Literata',
|
||||
originalFileName: 'Literata.ttf',
|
||||
format: 'ttf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
weightMin: null,
|
||||
weightMax: null,
|
||||
instances: null,
|
||||
fileSize: 1000,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function variable(instances: FontNamedInstance[], overrides: Partial<UserFont> = {}): UserFont {
|
||||
return makeFont({ weightMin: 100, weightMax: 900, instances, ...overrides })
|
||||
}
|
||||
|
||||
describe('familyVariants', () => {
|
||||
it('offers one variant per uploaded static file', () => {
|
||||
const variants = familyVariants([makeFont({ id: 1, weight: 400 }), makeFont({ id: 2, weight: 700 })])
|
||||
|
||||
expect(variants).toEqual([
|
||||
{ name: null, weight: 400, style: 'normal' },
|
||||
{ name: null, weight: 700, style: 'normal' },
|
||||
])
|
||||
})
|
||||
|
||||
it('offers a variable file every style its designer named', () => {
|
||||
const variants = familyVariants([
|
||||
variable([
|
||||
{ name: 'Light', weight: 300, style: 'normal' },
|
||||
{ name: 'Bold', weight: 700, style: 'normal' },
|
||||
]),
|
||||
])
|
||||
|
||||
expect(variants).toEqual([
|
||||
{ name: 'Light', weight: 300, style: 'normal' },
|
||||
{ name: null, weight: 400, style: 'normal' },
|
||||
{ name: 'Bold', weight: 700, style: 'normal' },
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the declared weight for a variable file that named nothing', () => {
|
||||
const variants = familyVariants([variable([], { weight: 500 })])
|
||||
|
||||
expect(variants).toEqual([{ name: null, weight: 500, style: 'normal' }])
|
||||
})
|
||||
|
||||
it('merges a variable upright with a static italic, as families are often shipped', () => {
|
||||
const variants = familyVariants([
|
||||
variable([
|
||||
{ name: 'Regular', weight: 400, style: 'normal' },
|
||||
{ name: 'Bold', weight: 700, style: 'normal' },
|
||||
]),
|
||||
makeFont({ id: 2, weight: 400, style: 'italic' }),
|
||||
])
|
||||
|
||||
expect(variants).toEqual([
|
||||
{ name: 'Regular', weight: 400, style: 'normal' },
|
||||
{ name: 'Bold', weight: 700, style: 'normal' },
|
||||
{ name: null, weight: 400, style: 'italic' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the named copy when two files offer the same style', () => {
|
||||
const variants = familyVariants([makeFont({ id: 2, weight: 700 }), variable([{ name: 'Bold', weight: 700, style: 'normal' }], { weight: 700 })])
|
||||
|
||||
expect(variants).toEqual([{ name: 'Bold', weight: 700, style: 'normal' }])
|
||||
})
|
||||
|
||||
it('keeps the default face when the font does not name that instance', () => {
|
||||
const variants = familyVariants([variable([{ name: 'Bold', weight: 700, style: 'normal' }], { weight: 350 })])
|
||||
|
||||
expect(variants).toEqual([
|
||||
{ name: null, weight: 350, style: 'normal' },
|
||||
{ name: 'Bold', weight: 700, style: 'normal' },
|
||||
])
|
||||
})
|
||||
|
||||
it('sorts upright before italic and light before heavy', () => {
|
||||
const variants = familyVariants([
|
||||
makeFont({ id: 1, weight: 700, style: 'italic' }),
|
||||
makeFont({ id: 2, weight: 700 }),
|
||||
makeFont({ id: 3, weight: 300, style: 'italic' }),
|
||||
makeFont({ id: 4, weight: 300 }),
|
||||
])
|
||||
|
||||
expect(variants.map(variantKey)).toEqual(['300:normal', '700:normal', '300:italic', '700:italic'])
|
||||
})
|
||||
|
||||
it('ignores a weight range that spans nothing', () => {
|
||||
const variants = familyVariants([makeFont({ weightMin: 400, weightMax: 400, instances: [{ name: 'Regular', weight: 400, style: 'normal' }] })])
|
||||
|
||||
expect(variants).toEqual([{ name: null, weight: 400, style: 'normal' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('builtInVariants', () => {
|
||||
it('offers the four styles a system stack can always produce', () => {
|
||||
expect(builtInVariants().map(variantKey)).toEqual(['400:normal', '700:normal', '400:italic', '700:italic'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('closestVariant', () => {
|
||||
const variants = [
|
||||
{ weight: 300, style: 'normal' as const },
|
||||
{ weight: 700, style: 'normal' as const },
|
||||
{ weight: 400, style: 'italic' as const },
|
||||
]
|
||||
|
||||
it('keeps the style and moves to the nearest weight', () => {
|
||||
expect(closestVariant(variants, { weight: 500, style: 'normal' })).toEqual({ weight: 300, style: 'normal' })
|
||||
})
|
||||
|
||||
it('prefers a matching style over a closer weight', () => {
|
||||
expect(closestVariant(variants, { weight: 700, style: 'italic' })).toEqual({ weight: 400, style: 'italic' })
|
||||
})
|
||||
|
||||
it('crosses styles only when the wanted one is absent', () => {
|
||||
expect(closestVariant([{ weight: 700, style: 'normal' }], { weight: 400, style: 'italic' })).toEqual({ weight: 700, style: 'normal' })
|
||||
})
|
||||
|
||||
it('returns null when there is nothing to fall back to', () => {
|
||||
expect(closestVariant([], { weight: 400, style: 'normal' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSameVariant', () => {
|
||||
it('matches on weight and style together', () => {
|
||||
expect(isSameVariant({ weight: 400, style: 'normal' }, { weight: 400, style: 'normal' })).toBe(true)
|
||||
expect(isSameVariant({ weight: 400, style: 'normal' }, { weight: 400, style: 'italic' })).toBe(false)
|
||||
expect(isSameVariant({ weight: 400, style: 'normal' }, { weight: 700, style: 'normal' })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { FontNamedInstance, FontVariant, UserFont } from '@bookorbit/types'
|
||||
import { BUILT_IN_FONT_VARIANTS, compareFontVariants, isVariableFont } from '@bookorbit/types'
|
||||
|
||||
/** Translation keys for the standard weight names, keyed by the CSS weight they describe. */
|
||||
export const FONT_WEIGHT_LABEL_KEYS: Record<number, string> = {
|
||||
100: 'settings.reader.fonts.weightThin',
|
||||
200: 'settings.reader.fonts.weightExtraLight',
|
||||
300: 'settings.reader.fonts.weightLight',
|
||||
400: 'settings.reader.fonts.weightRegular',
|
||||
500: 'settings.reader.fonts.weightMedium',
|
||||
600: 'settings.reader.fonts.weightSemiBold',
|
||||
700: 'settings.reader.fonts.weightBold',
|
||||
800: 'settings.reader.fonts.weightExtraBold',
|
||||
900: 'settings.reader.fonts.weightBlack',
|
||||
}
|
||||
|
||||
/**
|
||||
* The styles one family offers.
|
||||
*
|
||||
* A static family offers whatever files were uploaded for it. A variable file offers the
|
||||
* instances its designer named, so a single upload can still present Light through Black.
|
||||
* A family can be both at once - a variable upright paired with a static italic is a
|
||||
* common way to ship a typeface - so the two are merged rather than chosen between.
|
||||
*/
|
||||
export function familyVariants(fonts: readonly UserFont[]): FontNamedInstance[] {
|
||||
const byKey = new Map<string, FontNamedInstance>()
|
||||
|
||||
for (const font of fonts) {
|
||||
const defaultVariant: FontNamedInstance = { name: null, weight: font.weight, style: font.style }
|
||||
const offered: FontNamedInstance[] = isVariableFont(font) ? [defaultVariant, ...(font.instances ?? [])] : [defaultVariant]
|
||||
|
||||
for (const variant of offered) {
|
||||
const key = variantKey(variant)
|
||||
const existing = byKey.get(key)
|
||||
if (!existing || (existing.name === null && variant.name !== null)) byKey.set(key, variant)
|
||||
}
|
||||
}
|
||||
|
||||
return [...byKey.values()].sort(compareFontVariants)
|
||||
}
|
||||
|
||||
/** The styles a built-in stack offers, in the same shape as an uploaded family's. */
|
||||
export function builtInVariants(): FontNamedInstance[] {
|
||||
return BUILT_IN_FONT_VARIANTS.map((variant) => ({ ...variant, name: null }))
|
||||
}
|
||||
|
||||
export function variantKey(variant: FontVariant): string {
|
||||
return `${variant.weight}:${variant.style}`
|
||||
}
|
||||
|
||||
export function isSameVariant(a: FontVariant, b: FontVariant): boolean {
|
||||
return a.weight === b.weight && a.style === b.style
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the variant to fall back to when the current one is not on offer, which happens
|
||||
* whenever the reader switches to a family that lacks it. Preferring the same style keeps
|
||||
* an italic selection italic across a switch; the nearest weight keeps a bold one bold.
|
||||
*/
|
||||
export function closestVariant(variants: readonly FontVariant[], target: FontVariant): FontVariant | null {
|
||||
if (variants.length === 0) return null
|
||||
|
||||
const sameStyle = variants.filter((variant) => variant.style === target.style)
|
||||
const candidates = sameStyle.length > 0 ? sameStyle : variants
|
||||
|
||||
return candidates.reduce((best, variant) => (Math.abs(variant.weight - target.weight) < Math.abs(best.weight - target.weight) ? variant : best))
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, useId, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { EpubReaderSettings } from '@bookorbit/types'
|
||||
import type { EpubReaderSettings, FontNamedInstance } from '@bookorbit/types'
|
||||
import { isCustomFontCssFamily } 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 {
|
||||
FONT_WEIGHT_LABEL_KEYS,
|
||||
builtInVariants,
|
||||
closestVariant,
|
||||
familyVariants,
|
||||
isSameVariant,
|
||||
variantKey,
|
||||
} from '@/features/reader/shared/lib/font-variants'
|
||||
import { Check } from '@lucide/vue'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
@@ -23,6 +31,7 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const fontStyleSelectId = `ebook-font-style-${useId()}`
|
||||
|
||||
const { effective, load, update, reset } = useReaderDefaultSettings<EpubReaderSettings>('epub')
|
||||
|
||||
@@ -42,7 +51,59 @@ const serverFontOptions = computed(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
/** The styles a family offers, falling back to the system four for a built-in stack. */
|
||||
function variantsForFamily(cssFamilyName: string | null): FontNamedInstance[] {
|
||||
if (!isCustomFontCssFamily(cssFamilyName)) return builtInVariants()
|
||||
|
||||
const family = [...customFonts.visibleServerFamilies.value, ...customFonts.families.value].find(
|
||||
(candidate) => candidate.cssFamilyName === cssFamilyName,
|
||||
)
|
||||
const variants = familyVariants(family?.variants ?? [])
|
||||
return variants.length > 0 ? variants : builtInVariants()
|
||||
}
|
||||
|
||||
const fontStyleOptions = computed(() =>
|
||||
variantsForFamily(effective.value.fontFamily).map((variant) => ({
|
||||
value: variantKey(variant),
|
||||
label: fontStyleLabel(variant),
|
||||
variant,
|
||||
})),
|
||||
)
|
||||
|
||||
function fontStyleLabel(variant: FontNamedInstance): string {
|
||||
const weightKey = FONT_WEIGHT_LABEL_KEYS[variant.weight]
|
||||
const base = variant.name ?? (weightKey ? t(weightKey) : String(variant.weight))
|
||||
if (variant.style !== 'italic' || /italic|oblique/i.test(base)) return base
|
||||
return t('settings.reader.fonts.weightItalicOf', { weight: base })
|
||||
}
|
||||
|
||||
const selectedFontStyle = computed(() => variantKey({ weight: effective.value.fontWeight, style: effective.value.fontStyle }))
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 }
|
||||
|
||||
if (variants.some((variant) => isSameVariant(variant, current))) {
|
||||
update({ fontFamily })
|
||||
return
|
||||
}
|
||||
|
||||
const fallback = closestVariant(variants, current)
|
||||
update(fallback ? { fontFamily, fontWeight: fallback.weight, fontStyle: fallback.style } : { fontFamily })
|
||||
}
|
||||
|
||||
const previewStyleEl = ref<HTMLStyleElement | null>(null)
|
||||
const fontCatalogLoaded = ref(false)
|
||||
|
||||
function injectPreviewStyles(css: string) {
|
||||
if (previewStyleEl.value) {
|
||||
@@ -57,17 +118,24 @@ function injectPreviewStyles(css: string) {
|
||||
previewStyleEl.value = el
|
||||
}
|
||||
|
||||
function reconcileSavedFont() {
|
||||
if (!fontCatalogLoaded.value) return
|
||||
|
||||
const saved = effective.value.fontFamily
|
||||
if (isCustomFontCssFamily(saved) && !customFonts.cssFamilyAvailable(saved)) {
|
||||
const fallback = closestVariant(builtInVariants(), {
|
||||
weight: effective.value.fontWeight,
|
||||
style: effective.value.fontStyle,
|
||||
})
|
||||
update(fallback ? { fontFamily: null, fontWeight: fallback.weight, fontStyle: fallback.style } : { fontFamily: null })
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [customFonts.fonts.value, customFonts.serverFonts.value, customFonts.hiddenServerFamilies.value],
|
||||
() => {
|
||||
injectPreviewStyles(customFonts.generateFontFaceCSS())
|
||||
|
||||
// Reset a saved font that is no longer on offer: deleted by its owner, removed by an
|
||||
// administrator, or a server font this reader has since hidden.
|
||||
const saved = effective.value.fontFamily
|
||||
if (isCustomFontCssFamily(saved) && !customFonts.cssFamilyAvailable(saved)) {
|
||||
update({ fontFamily: null })
|
||||
}
|
||||
reconcileSavedFont()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -75,6 +143,8 @@ watch(
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
await customFonts.fetchAllFonts()
|
||||
fontCatalogLoaded.value = true
|
||||
reconcileSavedFont()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -335,11 +405,7 @@ function setFixedLayoutSpreadNone() {
|
||||
<select
|
||||
class="text-xs border border-border rounded-md px-2 py-2 md:py-1.5 bg-card text-foreground focus:outline-none focus:ring-1 focus:ring-primary self-start min-w-40"
|
||||
:value="effective.fontFamily ?? ''"
|
||||
@change="
|
||||
update({
|
||||
fontFamily: ($event.target as HTMLSelectElement).value || null,
|
||||
})
|
||||
"
|
||||
@change="selectFontFamily"
|
||||
>
|
||||
<optgroup :label="t('settings.reader.ebook.builtInFonts')">
|
||||
<option v-for="f in BUILTIN_READER_FONT_OPTIONS" :key="String(f.value)" :value="f.value ?? ''">
|
||||
@@ -359,6 +425,26 @@ function setFixedLayoutSpreadNone() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Font style -->
|
||||
<div class="settings-row">
|
||||
<div>
|
||||
<label :for="fontStyleSelectId" class="settings-label">{{ t('settings.reader.ebook.fontStyle') }}</label>
|
||||
<p class="settings-hint">
|
||||
{{ t('settings.reader.ebook.fontStyleHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
:id="fontStyleSelectId"
|
||||
class="text-xs border border-border rounded-md px-2 py-2 md:py-1.5 bg-card text-foreground focus:outline-none focus:ring-1 focus:ring-primary self-start min-w-40"
|
||||
:value="selectedFontStyle"
|
||||
@change="selectFontStyle"
|
||||
>
|
||||
<option v-for="option in fontStyleOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Font size -->
|
||||
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
|
||||
<div class="mb-3">
|
||||
|
||||
@@ -5,7 +5,9 @@ import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Pencil, Trash2, Type, Upload, X, Check, ChevronDown, ChevronRight } from '@lucide/vue'
|
||||
import type { UserFont } from '@bookorbit/types'
|
||||
import { isVariableFont } from '@bookorbit/types'
|
||||
import type { FontScopeStore } from '@/features/reader/epub/composables/useCustomFonts'
|
||||
import { FONT_WEIGHT_LABEL_KEYS, familyVariants } from '@/features/reader/shared/lib/font-variants'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -22,17 +24,26 @@ const props = withDefaults(
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const WEIGHT_LABELS = computed<Record<number, string>>(() => ({
|
||||
100: t('settings.reader.fonts.weightThin'),
|
||||
200: t('settings.reader.fonts.weightExtraLight'),
|
||||
300: t('settings.reader.fonts.weightLight'),
|
||||
400: t('settings.reader.fonts.weightRegular'),
|
||||
500: t('settings.reader.fonts.weightMedium'),
|
||||
600: t('settings.reader.fonts.weightSemiBold'),
|
||||
700: t('settings.reader.fonts.weightBold'),
|
||||
800: t('settings.reader.fonts.weightExtraBold'),
|
||||
900: t('settings.reader.fonts.weightBlack'),
|
||||
}))
|
||||
const WEIGHT_LABELS = computed<Record<number, string>>(() =>
|
||||
Object.fromEntries(Object.entries(FONT_WEIGHT_LABEL_KEYS).map(([weight, key]) => [weight, t(key)])),
|
||||
)
|
||||
|
||||
/**
|
||||
* How one uploaded file describes itself. A variable file spans a range rather than
|
||||
* sitting at one weight, so it reports the span and how many styles it carries.
|
||||
*/
|
||||
function variantLabel(variant: UserFont): string {
|
||||
if (isVariableFont(variant)) {
|
||||
return t('settings.reader.fonts.variableRange', { min: variant.weightMin, max: variant.weightMax })
|
||||
}
|
||||
|
||||
const weight = WEIGHT_LABELS.value[variant.weight] ?? String(variant.weight)
|
||||
return variant.style === 'italic' ? t('settings.reader.fonts.weightItalicOf', { weight }) : weight
|
||||
}
|
||||
|
||||
function variantStyleCount(variant: UserFont): number {
|
||||
return isVariableFont(variant) ? familyVariants([variant]).length : 0
|
||||
}
|
||||
|
||||
const isServerScope = computed(() => props.store.scope === 'server')
|
||||
|
||||
@@ -406,9 +417,9 @@ function dismissError(index: number) {
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-xs text-foreground">
|
||||
{{ WEIGHT_LABELS[variant.weight] ?? variant.weight }}
|
||||
{{ variant.style === 'italic' ? '· ' + t('settings.reader.fonts.styleItalic') : '' }}
|
||||
<span class="text-xs text-foreground">{{ variantLabel(variant) }}</span>
|
||||
<span v-if="variantStyleCount(variant) > 0" class="ml-2 text-xs text-muted-foreground">
|
||||
{{ t('settings.reader.fonts.variableStyles', { count: variantStyleCount(variant) }) }}
|
||||
</span>
|
||||
<span class="ml-2 text-xs text-muted-foreground uppercase">{{ variant.format }}</span>
|
||||
<span class="ml-2 text-xs text-muted-foreground">{{ formatBytes(variant.fileSize) }}</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fontCssFamilyGroupName, type EpubReaderSettings } from '@bookorbit/types'
|
||||
import EbookSettings from '../EbookSettings.vue'
|
||||
|
||||
const readerSettingsMock = vi.hoisted(() => ({
|
||||
@@ -9,6 +10,8 @@ const readerSettingsMock = vi.hoisted(() => ({
|
||||
themeName: 'default',
|
||||
isDark: false,
|
||||
fontFamily: null,
|
||||
fontWeight: 400,
|
||||
fontStyle: 'normal',
|
||||
fontSize: 16,
|
||||
lineHeight: 1.5,
|
||||
maxColumnCount: 2,
|
||||
@@ -21,7 +24,7 @@ const readerSettingsMock = vi.hoisted(() => ({
|
||||
overrideBookFormatting: true,
|
||||
footerDisplayMode: 0,
|
||||
fixedLayoutSpread: 'auto',
|
||||
},
|
||||
} as EpubReaderSettings,
|
||||
},
|
||||
load: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
update: vi.fn<() => void>(),
|
||||
@@ -48,6 +51,15 @@ vi.mock('@/features/reader/epub/composables/useCustomFonts', () => ({
|
||||
}))
|
||||
|
||||
describe('EbookSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
readerSettingsMock.effective.value.fontFamily = null
|
||||
readerSettingsMock.effective.value.fontWeight = 400
|
||||
readerSettingsMock.effective.value.fontStyle = 'normal'
|
||||
customFontsMock.cssFamilyAvailable.mockReturnValue(true)
|
||||
customFontsMock.fetchAllFonts.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('places one reset action at the end of the settings form', async () => {
|
||||
const wrapper = mount(EbookSettings, { props: { embedded: true } })
|
||||
await flushPromises()
|
||||
@@ -61,4 +73,27 @@ describe('EbookSettings', () => {
|
||||
await resetButtons[0]!.trigger('click')
|
||||
expect(readerSettingsMock.reset).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('associates the new font style select with its visible label', async () => {
|
||||
const wrapper = mount(EbookSettings, { props: { embedded: true } })
|
||||
await flushPromises()
|
||||
|
||||
const select = wrapper.get('select[id^="ebook-font-style-"]')
|
||||
expect(wrapper.get(`label[for="${select.attributes('id')}"]`).text()).toBe('Font style')
|
||||
})
|
||||
|
||||
it('waits for both font catalogs before resetting an unavailable saved family', async () => {
|
||||
readerSettingsMock.effective.value.fontFamily = fontCssFamilyGroupName('Removed')
|
||||
readerSettingsMock.effective.value.fontWeight = 1000
|
||||
readerSettingsMock.effective.value.fontStyle = 'italic'
|
||||
customFontsMock.cssFamilyAvailable.mockReturnValue(false)
|
||||
customFontsMock.fetchAllFonts.mockImplementation(async () => {
|
||||
expect(readerSettingsMock.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
mount(EbookSettings, { props: { embedded: true } })
|
||||
await flushPromises()
|
||||
|
||||
expect(readerSettingsMock.update).toHaveBeenCalledWith({ fontFamily: null, fontWeight: 700, fontStyle: 'italic' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,13 @@ import { vi } from 'vitest'
|
||||
import type { FontScope, UserFont } from '@bookorbit/types'
|
||||
import { fontCssFamilyGroupName, maxFontsForScope } from '@bookorbit/types'
|
||||
|
||||
export function mockFont(id: number, familyName: string, weight = 400, style: 'normal' | 'italic' = 'normal'): UserFont {
|
||||
export function mockFont(
|
||||
id: number,
|
||||
familyName: string,
|
||||
weight = 400,
|
||||
style: 'normal' | 'italic' = 'normal',
|
||||
variable?: Pick<UserFont, 'weightMin' | 'weightMax' | 'instances'>,
|
||||
): UserFont {
|
||||
return {
|
||||
id,
|
||||
familyName,
|
||||
@@ -11,6 +17,9 @@ export function mockFont(id: number, familyName: string, weight = 400, style: 'n
|
||||
format: 'ttf',
|
||||
weight,
|
||||
style,
|
||||
weightMin: variable?.weightMin ?? null,
|
||||
weightMax: variable?.weightMax ?? null,
|
||||
instances: variable?.instances ?? null,
|
||||
fileSize: 50000,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
|
||||
@@ -1610,6 +1610,8 @@
|
||||
"typography": "Typography",
|
||||
"font": "Font",
|
||||
"fontHint": "Typeface used for body text",
|
||||
"fontStyle": "Font style",
|
||||
"fontStyleHint": "Weight and slant of body text. Bold and italic in the book stay relative to it.",
|
||||
"builtInFonts": "Built-in fonts",
|
||||
"yourFonts": "Your Fonts",
|
||||
"fontSize": "Font size",
|
||||
@@ -1716,7 +1718,7 @@
|
||||
"dropFilesHere": "Drop files here",
|
||||
"dragFontsPrefix": "Drag font files here, or",
|
||||
"browse": "browse",
|
||||
"fileTypesHint": "TTF, OTF, WOFF, WOFF2 - max 10 MB each",
|
||||
"fileTypesHint": "TTF, OTF, WOFF, WOFF2 - max 50 MB each",
|
||||
"yourFonts": "Your Fonts",
|
||||
"fontsUsed": "{count} / {max} used",
|
||||
"noFontsYet": "No fonts uploaded yet",
|
||||
@@ -1729,6 +1731,9 @@
|
||||
"deleteVariant": "Delete variant",
|
||||
"styleNormal": "Normal",
|
||||
"styleItalic": "Italic",
|
||||
"weightItalicOf": "{weight} Italic",
|
||||
"variableRange": "Variable {min}-{max}",
|
||||
"variableStyles": "{count, plural, one {# style} other {# styles}}",
|
||||
"weightThin": "Thin",
|
||||
"weightExtraLight": "Extra Light",
|
||||
"weightLight": "Light",
|
||||
@@ -6376,6 +6381,8 @@
|
||||
"fontBuiltIn": "Built-in",
|
||||
"fontServer": "Server fonts",
|
||||
"fontYours": "Your fonts",
|
||||
"fontStyle": "Style",
|
||||
"fontStyleItalicOf": "{style} Italic",
|
||||
"lineSpacing": "Line spacing",
|
||||
"pageWidth": "Page width",
|
||||
"pageWidthNarrow": "Narrow",
|
||||
|
||||
@@ -9,13 +9,41 @@ export type FontScope = "user" | "server";
|
||||
|
||||
export const FONT_SCOPES: readonly FontScope[] = ["user", "server"];
|
||||
|
||||
/** One selectable style within a family: a weight paired with an upright/italic axis. */
|
||||
export interface FontVariant {
|
||||
weight: number;
|
||||
style: FontStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* A style a variable font advertises through its `fvar` named instances, carrying the
|
||||
* designer's own name for it ("SemiBold Italic") rather than one derived from the weight.
|
||||
*/
|
||||
export interface FontNamedInstance extends FontVariant {
|
||||
/**
|
||||
* The designer's own name for the instance. Null when the font did not name it, which
|
||||
* leaves the reader to label it from the weight in the reader's own language.
|
||||
*/
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export interface UserFont {
|
||||
id: number;
|
||||
familyName: string;
|
||||
originalFileName: string;
|
||||
format: FontFormat;
|
||||
/** The face this file renders by default. For a variable font, its default instance. */
|
||||
weight: number;
|
||||
style: FontStyle;
|
||||
/**
|
||||
* Bounds of the file's `wght` variation axis, or null for a static face. A variable
|
||||
* font declares these in `@font-face` so every weight in the range renders from the
|
||||
* real axis instead of being synthesised from the default instance.
|
||||
*/
|
||||
weightMin: number | null;
|
||||
weightMax: number | null;
|
||||
/** Styles a variable file advertises by name. Null for a static face. */
|
||||
instances: FontNamedInstance[] | null;
|
||||
fileSize: number;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -68,6 +96,13 @@ export function maxFontsForScope(scope: FontScope): number {
|
||||
|
||||
export const FONT_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900] as const;
|
||||
|
||||
export const CSS_FONT_WEIGHT_MIN = 1;
|
||||
export const CSS_FONT_WEIGHT_MAX = 1000;
|
||||
|
||||
export function isCssFontWeight(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= CSS_FONT_WEIGHT_MIN && value <= CSS_FONT_WEIGHT_MAX;
|
||||
}
|
||||
|
||||
export const FONT_FAMILY_NAME_MAX_LENGTH = 200;
|
||||
|
||||
/**
|
||||
@@ -95,10 +130,6 @@ export const FONT_CSS_FAMILY_PREFIXES: Record<FontScope, string> = {
|
||||
server: "__serverfont_",
|
||||
};
|
||||
|
||||
export function fontCssFamilyName(fontId: number): string {
|
||||
return `${FONT_CSS_FAMILY_PREFIXES.user}${fontId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a stable, CSS-safe font-family name shared by all variants of a family.
|
||||
* Using one name per family (differentiated by font-weight/font-style) lets the
|
||||
@@ -118,9 +149,7 @@ export function fontScopeFromCssFamily(cssFamilyName: string | null | undefined)
|
||||
if (!cssFamilyName) return null;
|
||||
// Checked longest-prefix-first so the scopes stay distinguishable if a future
|
||||
// prefix is ever added that extends another.
|
||||
const byLength = [...FONT_SCOPES].sort(
|
||||
(a, b) => FONT_CSS_FAMILY_PREFIXES[b].length - FONT_CSS_FAMILY_PREFIXES[a].length,
|
||||
);
|
||||
const byLength = [...FONT_SCOPES].sort((a, b) => FONT_CSS_FAMILY_PREFIXES[b].length - FONT_CSS_FAMILY_PREFIXES[a].length);
|
||||
return byLength.find((scope) => cssFamilyName.startsWith(FONT_CSS_FAMILY_PREFIXES[scope])) ?? null;
|
||||
}
|
||||
|
||||
@@ -128,3 +157,26 @@ export function fontScopeFromCssFamily(cssFamilyName: string | null | undefined)
|
||||
export function isCustomFontCssFamily(cssFamilyName: string | null | undefined): boolean {
|
||||
return fontScopeFromCssFamily(cssFamilyName) !== null;
|
||||
}
|
||||
|
||||
/** True when the file carries a `wght` variation axis rather than a single static weight. */
|
||||
export function isVariableFont(font: Pick<UserFont, "weightMin" | "weightMax">): boolean {
|
||||
return font.weightMin !== null && font.weightMax !== null && font.weightMax > font.weightMin;
|
||||
}
|
||||
|
||||
/**
|
||||
* The styles a built-in font stack offers. Unlike an uploaded family there is no file
|
||||
* list to read, so the reader offers the four the browser can always produce from a
|
||||
* system stack.
|
||||
*/
|
||||
export const BUILT_IN_FONT_VARIANTS: readonly FontVariant[] = [
|
||||
{ weight: 400, style: "normal" },
|
||||
{ weight: 700, style: "normal" },
|
||||
{ weight: 400, style: "italic" },
|
||||
{ weight: 700, style: "italic" },
|
||||
];
|
||||
|
||||
/** Orders variants the way a font picker reads: upright before italic, light before heavy. */
|
||||
export function compareFontVariants(a: FontVariant, b: FontVariant): number {
|
||||
if (a.style !== b.style) return a.style === "normal" ? -1 : 1;
|
||||
return a.weight - b.weight;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { FontStyle } from "./font";
|
||||
|
||||
export type ReaderFormatGroup = "epub" | "pdf" | "cbx" | "audio";
|
||||
|
||||
export const EPUB_FONT_SIZE_MIN = 6;
|
||||
@@ -56,6 +58,10 @@ export interface EpubReaderSettings {
|
||||
themeName: string; // matches one of the reader's built-in theme names
|
||||
isDark: boolean;
|
||||
fontFamily: string | null; // null = use the book's embedded font
|
||||
// Base style for body text. The book's own bold and italic runs still resolve relative
|
||||
// to this, so a bold base leaves emphasis rendered at the same weight.
|
||||
fontWeight: number; // CSS font-weight: integer from 1-1000
|
||||
fontStyle: FontStyle;
|
||||
fontSize: number; // EPUB_FONT_SIZE_MIN-EPUB_FONT_SIZE_MAX
|
||||
lineHeight: number; // 0.8-3.0
|
||||
maxColumnCount: number; // 1-10
|
||||
@@ -114,6 +120,8 @@ export const EPUB_READER_DEFAULTS: EpubReaderSettings = {
|
||||
themeName: "default",
|
||||
isDark: false,
|
||||
fontFamily: null,
|
||||
fontWeight: 400,
|
||||
fontStyle: "normal",
|
||||
fontSize: 16,
|
||||
lineHeight: 1.5,
|
||||
maxColumnCount: 2,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE "server_fonts" ADD COLUMN "weight_min" integer;--> statement-breakpoint
|
||||
ALTER TABLE "server_fonts" ADD COLUMN "weight_max" integer;--> statement-breakpoint
|
||||
ALTER TABLE "server_fonts" ADD COLUMN "instances" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "user_fonts" ADD COLUMN "weight_min" integer;--> statement-breakpoint
|
||||
ALTER TABLE "user_fonts" ADD COLUMN "weight_max" integer;--> statement-breakpoint
|
||||
ALTER TABLE "user_fonts" ADD COLUMN "instances" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "server_fonts" ADD CONSTRAINT "server_fonts_weight_range_chk" CHECK (("server_fonts"."weight_min" is null) = ("server_fonts"."weight_max" is null) and ("server_fonts"."weight_min" is null or ("server_fonts"."weight_min" >= 1 and "server_fonts"."weight_max" <= 1000 and "server_fonts"."weight_min" < "server_fonts"."weight_max")));--> statement-breakpoint
|
||||
ALTER TABLE "user_fonts" ADD CONSTRAINT "user_fonts_weight_range_chk" CHECK (("user_fonts"."weight_min" is null) = ("user_fonts"."weight_max" is null) and ("user_fonts"."weight_min" is null or ("user_fonts"."weight_min" >= 1 and "user_fonts"."weight_max" <= 1000 and "user_fonts"."weight_min" < "user_fonts"."weight_max")));
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "server_fonts" DROP CONSTRAINT "server_fonts_weight_chk";--> statement-breakpoint
|
||||
ALTER TABLE "user_fonts" DROP CONSTRAINT "user_fonts_weight_chk";--> statement-breakpoint
|
||||
ALTER TABLE "server_fonts" ADD CONSTRAINT "server_fonts_weight_chk" CHECK ("server_fonts"."weight" >= 1 and "server_fonts"."weight" <= 1000);--> statement-breakpoint
|
||||
ALTER TABLE "user_fonts" ADD CONSTRAINT "user_fonts_weight_chk" CHECK ("user_fonts"."weight" >= 1 and "user_fonts"."weight" <= 1000);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -505,6 +505,20 @@
|
||||
"when": 1787086030151,
|
||||
"tag": "0071_add_koreader_progress_reset_devices",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 72,
|
||||
"version": "7",
|
||||
"when": 1787372069937,
|
||||
"tag": "0072_add_variable_font_axes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 73,
|
||||
"version": "7",
|
||||
"when": 1787378660967,
|
||||
"tag": "0073_widen_font_weights",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { check, integer, pgTable, serial, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
import { check, integer, jsonb, pgTable, serial, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
import type { FontNamedInstance } from '@bookorbit/types';
|
||||
|
||||
import { users } from './auth';
|
||||
|
||||
/**
|
||||
* Columns describing a variable font's `wght` axis, null on a static face. `weight` still
|
||||
* records the default instance, so the per-family uniqueness rules are unaffected: a
|
||||
* variable file occupies exactly one weight/style slot like any other upload.
|
||||
*/
|
||||
const variableFontColumns = {
|
||||
weightMin: integer('weight_min'),
|
||||
weightMax: integer('weight_max'),
|
||||
instances: jsonb('instances').$type<FontNamedInstance[]>(),
|
||||
};
|
||||
|
||||
export const userFonts = pgTable(
|
||||
'user_fonts',
|
||||
{
|
||||
@@ -16,6 +28,7 @@ export const userFonts = pgTable(
|
||||
format: varchar('format', { length: 10 }).$type<'ttf' | 'otf' | 'woff' | 'woff2'>().notNull(),
|
||||
weight: integer('weight').notNull().default(400),
|
||||
style: varchar('style', { length: 10 }).$type<'normal' | 'italic'>().notNull().default('normal'),
|
||||
...variableFontColumns,
|
||||
fileSize: integer('file_size').notNull(),
|
||||
fileHash: varchar('file_hash', { length: 64 }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
@@ -24,8 +37,12 @@ export const userFonts = pgTable(
|
||||
uniqueIndex('uf_user_hash_uidx').on(t.userId, t.fileHash),
|
||||
uniqueIndex('uf_user_family_weight_style_uidx').on(t.userId, t.familyName, t.weight, t.style),
|
||||
check('user_fonts_format_chk', sql`${t.format} in ('ttf', 'otf', 'woff', 'woff2')`),
|
||||
check('user_fonts_weight_chk', sql`${t.weight} >= 100 and ${t.weight} <= 900 and ${t.weight} % 100 = 0`),
|
||||
check('user_fonts_weight_chk', sql`${t.weight} >= 1 and ${t.weight} <= 1000`),
|
||||
check('user_fonts_style_chk', sql`${t.style} in ('normal', 'italic')`),
|
||||
check(
|
||||
'user_fonts_weight_range_chk',
|
||||
sql`(${t.weightMin} is null) = (${t.weightMax} is null) and (${t.weightMin} is null or (${t.weightMin} >= 1 and ${t.weightMax} <= 1000 and ${t.weightMin} < ${t.weightMax}))`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -50,6 +67,7 @@ export const serverFonts = pgTable(
|
||||
format: varchar('format', { length: 10 }).$type<'ttf' | 'otf' | 'woff' | 'woff2'>().notNull(),
|
||||
weight: integer('weight').notNull().default(400),
|
||||
style: varchar('style', { length: 10 }).$type<'normal' | 'italic'>().notNull().default('normal'),
|
||||
...variableFontColumns,
|
||||
fileSize: integer('file_size').notNull(),
|
||||
fileHash: varchar('file_hash', { length: 64 }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
@@ -58,8 +76,12 @@ export const serverFonts = pgTable(
|
||||
uniqueIndex('sf_hash_uidx').on(t.fileHash),
|
||||
uniqueIndex('sf_family_weight_style_uidx').on(t.familyName, t.weight, t.style),
|
||||
check('server_fonts_format_chk', sql`${t.format} in ('ttf', 'otf', 'woff', 'woff2')`),
|
||||
check('server_fonts_weight_chk', sql`${t.weight} >= 100 and ${t.weight} <= 900 and ${t.weight} % 100 = 0`),
|
||||
check('server_fonts_weight_chk', sql`${t.weight} >= 1 and ${t.weight} <= 1000`),
|
||||
check('server_fonts_style_chk', sql`${t.style} in ('normal', 'italic')`),
|
||||
check(
|
||||
'server_fonts_weight_range_chk',
|
||||
sql`(${t.weightMin} is null) = (${t.weightMax} is null) and (${t.weightMin} is null or (${t.weightMin} >= 1 and ${t.weightMax} <= 1000 and ${t.weightMin} < ${t.weightMax}))`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ServerFontRow, UserFontRow } from '../../../db/schema';
|
||||
/** The columns user and server font rows have in common, which is all the wire shape needs. */
|
||||
export type FontRowLike = Pick<
|
||||
UserFontRow & ServerFontRow,
|
||||
'id' | 'familyName' | 'originalFileName' | 'format' | 'weight' | 'style' | 'fileSize' | 'createdAt'
|
||||
'id' | 'familyName' | 'originalFileName' | 'format' | 'weight' | 'style' | 'weightMin' | 'weightMax' | 'instances' | 'fileSize' | 'createdAt'
|
||||
>;
|
||||
|
||||
export function toFontResponse(row: FontRowLike): UserFont {
|
||||
@@ -15,6 +15,9 @@ export function toFontResponse(row: FontRowLike): UserFont {
|
||||
format: row.format,
|
||||
weight: row.weight,
|
||||
style: row.style as 'normal' | 'italic',
|
||||
weightMin: row.weightMin,
|
||||
weightMax: row.weightMax,
|
||||
instances: row.instances ?? null,
|
||||
fileSize: row.fileSize,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
|
||||
import { FONT_WEIGHTS, type FontStyle } from '@bookorbit/types';
|
||||
import { CSS_FONT_WEIGHT_MAX, CSS_FONT_WEIGHT_MIN, type FontStyle } from '@bookorbit/types';
|
||||
|
||||
export class UpdateFontDto {
|
||||
@IsOptional()
|
||||
@@ -10,9 +10,8 @@ export class UpdateFontDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(100)
|
||||
@Max(900)
|
||||
@IsIn([...FONT_WEIGHTS])
|
||||
@Min(CSS_FONT_WEIGHT_MIN)
|
||||
@Max(CSS_FONT_WEIGHT_MAX)
|
||||
weight?: number;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
LANG_WINDOWS_EN_US,
|
||||
LANG_WINDOWS_JAPANESE,
|
||||
PLATFORM_WINDOWS,
|
||||
buildFvarTable,
|
||||
buildNameTable,
|
||||
buildOs2Table,
|
||||
buildSfnt,
|
||||
buildWoff,
|
||||
buildWoff2,
|
||||
macName,
|
||||
windowsName,
|
||||
} from './font-test-fixtures';
|
||||
@@ -25,6 +27,8 @@ describe('parseFontMetadata', () => {
|
||||
subfamilyName: 'Bold',
|
||||
usWeightClass: 700,
|
||||
fsSelection: 32,
|
||||
axes: [],
|
||||
instances: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,10 +41,10 @@ describe('parseFontMetadata', () => {
|
||||
expect(parseFontMetadata(font).familyName).toBe('Iosevka');
|
||||
});
|
||||
|
||||
it('rejects WOFF2, which needs a brotli decompressor', () => {
|
||||
const font = Buffer.alloc(64);
|
||||
it('rejects a truncated WOFF2 header', () => {
|
||||
const font = Buffer.alloc(32);
|
||||
font.write('wOF2', 0, 4, 'latin1');
|
||||
expect(() => parseFontMetadata(font)).toThrow(/WOFF2/);
|
||||
expect(() => parseFontMetadata(font)).toThrow(/WOFF2 header is truncated/);
|
||||
});
|
||||
|
||||
it('rejects an unrecognized signature', () => {
|
||||
@@ -220,6 +224,8 @@ describe('parseFontMetadata', () => {
|
||||
subfamilyName: 'Italic',
|
||||
usWeightClass: 300,
|
||||
fsSelection: 1,
|
||||
axes: [],
|
||||
instances: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -301,10 +307,173 @@ describe('parseFontMetadata', () => {
|
||||
subfamilyName: 'Bold',
|
||||
usWeightClass: 700,
|
||||
fsSelection: 32,
|
||||
axes: [],
|
||||
instances: [],
|
||||
});
|
||||
// A full parse of a font this size cost hundreds of megabytes and hundreds of ms.
|
||||
expect(heapGrowthMb).toBeLessThan(25);
|
||||
expect(elapsedMs).toBeLessThan(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe('variation axes', () => {
|
||||
const variableFont = (fvar: Buffer, names = [windowsName(1, 'Inter'), windowsName(256, 'Light'), windowsName(257, 'Bold')]) =>
|
||||
buildSfnt([
|
||||
{ tag: 'name', data: buildNameTable(names) },
|
||||
{ tag: 'OS/2', data: buildOs2Table({ usWeightClass: 400 }) },
|
||||
{ tag: 'fvar', data: fvar },
|
||||
]);
|
||||
|
||||
it('reads the weight axis range', () => {
|
||||
const font = variableFont(buildFvarTable([{ tag: 'wght', min: 100, default: 400, max: 900 }]));
|
||||
|
||||
expect(parseFontMetadata(font).axes).toEqual([{ tag: 'wght', min: 100, default: 400, max: 900 }]);
|
||||
});
|
||||
|
||||
it('reads every axis, not just weight', () => {
|
||||
const font = variableFont(
|
||||
buildFvarTable([
|
||||
{ tag: 'wght', min: 200, default: 400, max: 800 },
|
||||
{ tag: 'wdth', min: 75, default: 100, max: 125 },
|
||||
{ tag: 'slnt', min: -10, default: 0, max: 0 },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(parseFontMetadata(font).axes.map((axis) => axis.tag)).toEqual(['wght', 'wdth', 'slnt']);
|
||||
});
|
||||
|
||||
it('resolves named instances against the name table', () => {
|
||||
const font = variableFont(
|
||||
buildFvarTable(
|
||||
[{ tag: 'wght', min: 100, default: 400, max: 900 }],
|
||||
[
|
||||
{ subfamilyNameID: 256, coordinates: [300] },
|
||||
{ subfamilyNameID: 257, coordinates: [700] },
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(parseFontMetadata(font).instances).toEqual([
|
||||
{ name: 'Light', coordinates: { wght: 300 } },
|
||||
{ name: 'Bold', coordinates: { wght: 700 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports an instance whose name id is missing rather than dropping it', () => {
|
||||
const font = variableFont(buildFvarTable([{ tag: 'wght', min: 100, default: 400, max: 900 }], [{ subfamilyNameID: 999, coordinates: [500] }]));
|
||||
|
||||
expect(parseFontMetadata(font).instances).toEqual([{ name: null, coordinates: { wght: 500 } }]);
|
||||
});
|
||||
|
||||
it('reads instances that carry the optional PostScript name id', () => {
|
||||
const font = variableFont(
|
||||
buildFvarTable([{ tag: 'wght', min: 100, default: 400, max: 900 }], [{ subfamilyNameID: 256, coordinates: [300], postScriptNameID: 300 }]),
|
||||
);
|
||||
|
||||
expect(parseFontMetadata(font).instances).toEqual([{ name: 'Light', coordinates: { wght: 300 } }]);
|
||||
});
|
||||
|
||||
it('reads coordinates across multiple axes', () => {
|
||||
const font = variableFont(
|
||||
buildFvarTable(
|
||||
[
|
||||
{ tag: 'wght', min: 100, default: 400, max: 900 },
|
||||
{ tag: 'wdth', min: 75, default: 100, max: 125 },
|
||||
],
|
||||
[{ subfamilyNameID: 257, coordinates: [700, 125] }],
|
||||
),
|
||||
);
|
||||
|
||||
expect(parseFontMetadata(font).instances).toEqual([{ name: 'Bold', coordinates: { wght: 700, wdth: 125 } }]);
|
||||
});
|
||||
|
||||
it('reports no axes for a static font', () => {
|
||||
const font = buildSfnt([{ tag: 'name', data: buildNameTable([windowsName(1, 'Literata')]) }]);
|
||||
|
||||
expect(parseFontMetadata(font)).toMatchObject({ axes: [], instances: [] });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an axis count of zero', buildFvarTable([{ tag: 'wght', min: 100, default: 400, max: 900 }], [], { axisCount: 0 })],
|
||||
['an implausible axis count', buildFvarTable([{ tag: 'wght', min: 100, default: 400, max: 900 }], [], { axisCount: 5000 })],
|
||||
['an axis size below the minimum', buildFvarTable([{ tag: 'wght', min: 100, default: 400, max: 900 }], [], { axisSize: 8 })],
|
||||
[
|
||||
'an axis array running past the table',
|
||||
buildFvarTable([{ tag: 'wght', min: 100, default: 400, max: 900 }], [], { declaredAxesArrayOffset: 4096 }),
|
||||
],
|
||||
])('ignores a malformed fvar table declaring %s', (_label, fvar) => {
|
||||
expect(parseFontMetadata(variableFont(fvar))).toMatchObject({ axes: [], instances: [], familyName: 'Inter' });
|
||||
});
|
||||
|
||||
it('stops at the last instance the table can hold', () => {
|
||||
const fvar = buildFvarTable([{ tag: 'wght', min: 100, default: 400, max: 900 }], [{ subfamilyNameID: 256, coordinates: [300] }], {
|
||||
instanceCount: 50,
|
||||
});
|
||||
|
||||
expect(parseFontMetadata(variableFont(fvar)).instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores instance records too short to hold a coordinate per axis', () => {
|
||||
const fvar = buildFvarTable([{ tag: 'wght', min: 100, default: 400, max: 900 }], [{ subfamilyNameID: 256, coordinates: [300] }], {
|
||||
instanceSize: 4,
|
||||
});
|
||||
|
||||
expect(parseFontMetadata(variableFont(fvar))).toMatchObject({ axes: [{ tag: 'wght', min: 100, default: 400, max: 900 }], instances: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('WOFF2', () => {
|
||||
it('reads metadata out of the compressed stream', () => {
|
||||
const font = buildWoff2([
|
||||
{ tag: 'name', data: buildNameTable([windowsName(1, 'Recursive'), windowsName(2, 'Italic')]) },
|
||||
{ tag: 'OS/2', data: buildOs2Table({ usWeightClass: 300, fsSelection: 1 }) },
|
||||
]);
|
||||
|
||||
expect(parseFontMetadata(font)).toMatchObject({
|
||||
familyName: 'Recursive',
|
||||
subfamilyName: 'Italic',
|
||||
usWeightClass: 300,
|
||||
fsSelection: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads fvar past a transformed glyf table, whose stored length differs from its own', () => {
|
||||
const font = buildWoff2([
|
||||
{ tag: 'name', data: buildNameTable([windowsName(1, 'Recursive'), windowsName(256, 'SemiBold')]) },
|
||||
{ tag: 'glyf', data: Buffer.alloc(2048, 0x11), transformed: true },
|
||||
{ tag: 'fvar', data: buildFvarTable([{ tag: 'wght', min: 300, default: 400, max: 1000 }], [{ subfamilyNameID: 256, coordinates: [600] }]) },
|
||||
]);
|
||||
|
||||
expect(parseFontMetadata(font)).toMatchObject({
|
||||
familyName: 'Recursive',
|
||||
axes: [{ tag: 'wght', min: 300, default: 400, max: 1000 }],
|
||||
instances: [{ name: 'SemiBold', coordinates: { wght: 600 } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a font declaring an expansion beyond the limit', () => {
|
||||
const font = buildWoff2([{ tag: 'name', data: buildNameTable([windowsName(1, 'Huge')]) }], { totalSfntSize: 64 * 1024 * 1024 });
|
||||
|
||||
expect(() => parseFontMetadata(font)).toThrow(/too large to expand/);
|
||||
});
|
||||
|
||||
it('rejects a font collection, which carries more than one face', () => {
|
||||
const font = buildWoff2([{ tag: 'name', data: buildNameTable([windowsName(1, 'Collection')]) }]);
|
||||
font.write('ttcf', 4, 4, 'latin1');
|
||||
|
||||
expect(() => parseFontMetadata(font)).toThrow(/collections are not supported/);
|
||||
});
|
||||
|
||||
it('rejects a truncated table directory', () => {
|
||||
const font = buildWoff2([{ tag: 'name', data: buildNameTable([windowsName(1, 'Truncated')]) }], { numTables: 40 });
|
||||
|
||||
expect(() => parseFontMetadata(font)).toThrow(/WOFF2/);
|
||||
});
|
||||
|
||||
it('skips the brotli stream entirely when no table of interest is present', () => {
|
||||
const font = buildWoff2([{ tag: 'cmap', data: Buffer.alloc(64, 0x22) }]);
|
||||
|
||||
expect(parseFontMetadata(font)).toMatchObject({ familyName: null, axes: [], instances: [] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,72 @@
|
||||
import { inflateSync } from 'zlib';
|
||||
import { brotliDecompressSync, inflateSync } from 'zlib';
|
||||
|
||||
/** A variation axis a font exposes, with its range in user coordinates. */
|
||||
export interface FontVariationAxis {
|
||||
tag: string;
|
||||
min: number;
|
||||
default: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/** A position on the variation axes that the designer gave a name to. */
|
||||
export interface FontVariationInstance {
|
||||
/** Resolved from the `name` table; null when the record is missing or unreadable. */
|
||||
name: string | null;
|
||||
coordinates: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface RawFontMetadata {
|
||||
familyName: string | null;
|
||||
subfamilyName: string | null;
|
||||
usWeightClass: number | undefined;
|
||||
fsSelection: number | undefined;
|
||||
axes: FontVariationAxis[];
|
||||
instances: FontVariationInstance[];
|
||||
}
|
||||
|
||||
const SFNT_TABLE_DIRECTORY_OFFSET = 12;
|
||||
const SFNT_TABLE_ENTRY_SIZE = 16;
|
||||
const WOFF_TABLE_DIRECTORY_OFFSET = 44;
|
||||
const WOFF_TABLE_ENTRY_SIZE = 20;
|
||||
const WOFF2_HEADER_SIZE = 48;
|
||||
|
||||
// A font may legitimately carry a few dozen tables. Anything beyond this is malformed
|
||||
// or crafted, and the count controls how much we read, so bound it before allocating.
|
||||
const MAX_TABLE_COUNT = 512;
|
||||
|
||||
// `name` and `OS/2` are metadata tables measured in kilobytes. A larger declared size
|
||||
// means the file is malformed or hostile, so refuse to allocate or inflate for it.
|
||||
// `name`, `OS/2` and `fvar` are metadata tables measured in kilobytes. A larger declared
|
||||
// size means the file is malformed or hostile, so refuse to allocate or inflate for it.
|
||||
const MAX_METADATA_TABLE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* WOFF2 stores every table in one Brotli stream, so unlike sfnt and WOFF there is no way
|
||||
* to reach `fvar` without expanding the glyphs that precede it. Fonts that would expand
|
||||
* past this keep the filename fallback rather than costing hundreds of megabytes for a
|
||||
* family name and a weight range; in practice only large CJK faces reach it.
|
||||
*/
|
||||
const MAX_WOFF2_DECOMPRESSED_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
const TABLES_OF_INTEREST = new Set(['name', 'OS/2', 'fvar']);
|
||||
|
||||
// Table tags a WOFF2 directory can reference by index instead of spelling out. Order is
|
||||
// fixed by the WOFF2 specification; index 63 means an explicit 4-byte tag follows.
|
||||
// prettier-ignore
|
||||
const WOFF2_KNOWN_TAGS = [
|
||||
'cmap', 'head', 'hhea', 'hmtx', 'maxp', 'name', 'OS/2', 'post',
|
||||
'cvt ', 'fpgm', 'glyf', 'loca', 'prep', 'CFF ', 'VORG', 'EBDT',
|
||||
'EBLC', 'gasp', 'hdmx', 'kern', 'LTSH', 'PCLT', 'VDMX', 'vhea',
|
||||
'vmtx', 'BASE', 'GDEF', 'GPOS', 'GSUB', 'EBSC', 'JSTF', 'MATH',
|
||||
'CBDT', 'CBLC', 'COLR', 'CPAL', 'SVG ', 'sbix', 'acnt', 'avar',
|
||||
'bdat', 'bloc', 'bsln', 'cvar', 'fdsc', 'feat', 'fmtx', 'fvar',
|
||||
'gvar', 'hsty', 'just', 'lcar', 'mort', 'morx', 'opbd', 'prop',
|
||||
'trak', 'Zapf', 'Silf', 'Glat', 'Gloc', 'Feat', 'Sill',
|
||||
];
|
||||
|
||||
const WOFF2_ARBITRARY_TAG = 63;
|
||||
// Only `glyf` and `loca` invert the meaning of the transform version, using 3 for "not
|
||||
// transformed" where every other table uses 0.
|
||||
const WOFF2_NULL_TRANSFORM_GLYF_LOCA = 3;
|
||||
|
||||
const NAME_ID_FAMILY = 1;
|
||||
const NAME_ID_SUBFAMILY = 2;
|
||||
const NAME_ID_TYPOGRAPHIC_FAMILY = 16;
|
||||
@@ -39,24 +86,21 @@ const OS2_FS_SELECTION_OFFSET = 62;
|
||||
const NAME_RECORD_SIZE = 12;
|
||||
const NAME_TABLE_HEADER_SIZE = 6;
|
||||
|
||||
interface TableEntry {
|
||||
offset: number;
|
||||
compLength: number;
|
||||
origLength: number;
|
||||
}
|
||||
|
||||
interface TableDirectory {
|
||||
entries: Map<string, TableEntry>;
|
||||
isWoff: boolean;
|
||||
}
|
||||
const FVAR_HEADER_SIZE = 16;
|
||||
const FVAR_MIN_AXIS_SIZE = 20;
|
||||
// A real font carries a handful of axes and at most a few dozen named instances. The
|
||||
// counts drive how much of the table is walked, so bound them before iterating.
|
||||
const MAX_VARIATION_AXES = 64;
|
||||
const MAX_NAMED_INSTANCES = 256;
|
||||
|
||||
/**
|
||||
* Reads font metadata by locating only the `name` and `OS/2` tables.
|
||||
* Reads font metadata by locating only the `name`, `OS/2` and `fvar` tables.
|
||||
*
|
||||
* A full font parse materialises an object per glyph, which for a CJK font of tens of
|
||||
* thousands of glyphs costs hundreds of megabytes to obtain a family name and two
|
||||
* integers. Reading the two metadata tables directly keeps cost independent of both
|
||||
* file size and glyph count.
|
||||
* thousands of glyphs costs hundreds of megabytes to obtain a family name and a few
|
||||
* integers. Reading the metadata tables directly keeps cost independent of both file
|
||||
* size and glyph count, except for WOFF2, whose single compressed stream forces a bounded
|
||||
* whole-font expansion.
|
||||
*
|
||||
* Throws when the container cannot be read at all; callers fall back to filename
|
||||
* heuristics. Individual tables that are absent or unreadable yield null/undefined
|
||||
@@ -64,15 +108,20 @@ interface TableDirectory {
|
||||
* whatever `OS/2` provides.
|
||||
*/
|
||||
export function parseFontMetadata(buffer: Buffer): RawFontMetadata {
|
||||
const { entries, isWoff } = readTableDirectory(buffer);
|
||||
const tables = readTables(buffer);
|
||||
|
||||
const nameTable = readTable(buffer, entries.get('name'), isWoff);
|
||||
const os2Table = readTable(buffer, entries.get('OS/2'), isWoff);
|
||||
const names = parseNameTable(tables.get('name') ?? null);
|
||||
const variations = parseFvarTable(tables.get('fvar') ?? null, names);
|
||||
|
||||
return { ...parseNameTable(nameTable), ...parseOs2Table(os2Table) };
|
||||
return {
|
||||
familyName: names.get(NAME_ID_TYPOGRAPHIC_FAMILY) ?? names.get(NAME_ID_FAMILY) ?? null,
|
||||
subfamilyName: names.get(NAME_ID_TYPOGRAPHIC_SUBFAMILY) ?? names.get(NAME_ID_SUBFAMILY) ?? null,
|
||||
...parseOs2Table(tables.get('OS/2') ?? null),
|
||||
...variations,
|
||||
};
|
||||
}
|
||||
|
||||
function readTableDirectory(buffer: Buffer): TableDirectory {
|
||||
function readTables(buffer: Buffer): Map<string, Buffer> {
|
||||
if (buffer.length < SFNT_TABLE_DIRECTORY_OFFSET) {
|
||||
throw new Error('Font is too small to contain a table directory');
|
||||
}
|
||||
@@ -80,75 +129,205 @@ function readTableDirectory(buffer: Buffer): TableDirectory {
|
||||
const signature = buffer.toString('latin1', 0, 4);
|
||||
const isTrueTypeVersion1 = buffer.readUInt32BE(0) === 0x00010000;
|
||||
|
||||
let numTables: number;
|
||||
let directoryOffset: number;
|
||||
let entrySize: number;
|
||||
let isWoff: boolean;
|
||||
|
||||
if (isTrueTypeVersion1 || signature === 'true' || signature === 'typ1' || signature === 'OTTO') {
|
||||
numTables = buffer.readUInt16BE(4);
|
||||
directoryOffset = SFNT_TABLE_DIRECTORY_OFFSET;
|
||||
entrySize = SFNT_TABLE_ENTRY_SIZE;
|
||||
isWoff = false;
|
||||
} else if (signature === 'wOFF') {
|
||||
if (buffer.length < WOFF_TABLE_DIRECTORY_OFFSET) {
|
||||
throw new Error('WOFF header is truncated');
|
||||
}
|
||||
numTables = buffer.readUInt16BE(12);
|
||||
directoryOffset = WOFF_TABLE_DIRECTORY_OFFSET;
|
||||
entrySize = WOFF_TABLE_ENTRY_SIZE;
|
||||
isWoff = true;
|
||||
} else if (signature === 'wOF2') {
|
||||
throw new Error('WOFF2 metadata extraction is not supported');
|
||||
} else {
|
||||
throw new Error('Unrecognized font signature');
|
||||
return readSfntTables(buffer);
|
||||
}
|
||||
if (signature === 'wOFF') {
|
||||
return readWoffTables(buffer);
|
||||
}
|
||||
if (signature === 'wOF2') {
|
||||
return readWoff2Tables(buffer);
|
||||
}
|
||||
throw new Error('Unrecognized font signature');
|
||||
}
|
||||
|
||||
function checkTableCount(numTables: number): void {
|
||||
if (numTables === 0 || numTables > MAX_TABLE_COUNT) {
|
||||
throw new Error('Implausible table count');
|
||||
}
|
||||
if (directoryOffset + numTables * entrySize > buffer.length) {
|
||||
}
|
||||
|
||||
/** Slices a table out of its container, refusing implausible sizes and short reads. */
|
||||
function sliceTable(buffer: Buffer, offset: number, length: number): Buffer | null {
|
||||
if (length > MAX_METADATA_TABLE_BYTES) return null;
|
||||
if (offset + length > buffer.length) return null;
|
||||
return buffer.subarray(offset, offset + length);
|
||||
}
|
||||
|
||||
function readSfntTables(buffer: Buffer): Map<string, Buffer> {
|
||||
const numTables = buffer.readUInt16BE(4);
|
||||
checkTableCount(numTables);
|
||||
if (SFNT_TABLE_DIRECTORY_OFFSET + numTables * SFNT_TABLE_ENTRY_SIZE > buffer.length) {
|
||||
throw new Error('Table directory extends past end of font');
|
||||
}
|
||||
|
||||
const entries = new Map<string, TableEntry>();
|
||||
const tables = new Map<string, Buffer>();
|
||||
for (let i = 0; i < numTables; i += 1) {
|
||||
const record = directoryOffset + i * entrySize;
|
||||
const record = SFNT_TABLE_DIRECTORY_OFFSET + i * SFNT_TABLE_ENTRY_SIZE;
|
||||
const tag = buffer.toString('latin1', record, record + 4);
|
||||
if (tag !== 'name' && tag !== 'OS/2') continue;
|
||||
if (!TABLES_OF_INTEREST.has(tag)) continue;
|
||||
|
||||
entries.set(
|
||||
tag,
|
||||
isWoff
|
||||
? {
|
||||
offset: buffer.readUInt32BE(record + 4),
|
||||
compLength: buffer.readUInt32BE(record + 8),
|
||||
origLength: buffer.readUInt32BE(record + 12),
|
||||
}
|
||||
: {
|
||||
offset: buffer.readUInt32BE(record + 8),
|
||||
compLength: buffer.readUInt32BE(record + 12),
|
||||
origLength: buffer.readUInt32BE(record + 12),
|
||||
},
|
||||
);
|
||||
const table = sliceTable(buffer, buffer.readUInt32BE(record + 8), buffer.readUInt32BE(record + 12));
|
||||
if (table) tables.set(tag, table);
|
||||
}
|
||||
|
||||
return { entries, isWoff };
|
||||
return tables;
|
||||
}
|
||||
|
||||
function readTable(buffer: Buffer, entry: TableEntry | undefined, isWoff: boolean): Buffer | null {
|
||||
if (!entry) return null;
|
||||
if (entry.compLength > MAX_METADATA_TABLE_BYTES || entry.origLength > MAX_METADATA_TABLE_BYTES) return null;
|
||||
if (entry.offset + entry.compLength > buffer.length) return null;
|
||||
|
||||
const raw = buffer.subarray(entry.offset, entry.offset + entry.compLength);
|
||||
if (!isWoff || entry.compLength === entry.origLength) return raw;
|
||||
|
||||
try {
|
||||
return inflateSync(raw, { maxOutputLength: MAX_METADATA_TABLE_BYTES });
|
||||
} catch {
|
||||
return null;
|
||||
function readWoffTables(buffer: Buffer): Map<string, Buffer> {
|
||||
if (buffer.length < WOFF_TABLE_DIRECTORY_OFFSET) {
|
||||
throw new Error('WOFF header is truncated');
|
||||
}
|
||||
|
||||
const numTables = buffer.readUInt16BE(12);
|
||||
checkTableCount(numTables);
|
||||
if (WOFF_TABLE_DIRECTORY_OFFSET + numTables * WOFF_TABLE_ENTRY_SIZE > buffer.length) {
|
||||
throw new Error('Table directory extends past end of font');
|
||||
}
|
||||
|
||||
const tables = new Map<string, Buffer>();
|
||||
for (let i = 0; i < numTables; i += 1) {
|
||||
const record = WOFF_TABLE_DIRECTORY_OFFSET + i * WOFF_TABLE_ENTRY_SIZE;
|
||||
const tag = buffer.toString('latin1', record, record + 4);
|
||||
if (!TABLES_OF_INTEREST.has(tag)) continue;
|
||||
|
||||
const compLength = buffer.readUInt32BE(record + 8);
|
||||
const origLength = buffer.readUInt32BE(record + 12);
|
||||
if (origLength > MAX_METADATA_TABLE_BYTES) continue;
|
||||
|
||||
const raw = sliceTable(buffer, buffer.readUInt32BE(record + 4), compLength);
|
||||
if (!raw) continue;
|
||||
|
||||
if (compLength === origLength) {
|
||||
tables.set(tag, raw);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
tables.set(tag, inflateSync(raw, { maxOutputLength: MAX_METADATA_TABLE_BYTES }));
|
||||
} catch {
|
||||
// A damaged table is skipped; whatever else the font carries still reports.
|
||||
}
|
||||
}
|
||||
return tables;
|
||||
}
|
||||
|
||||
interface Woff2DirectoryEntry {
|
||||
tag: string;
|
||||
offset: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands a WOFF2 font far enough to read its metadata tables.
|
||||
*
|
||||
* Every table lives in one Brotli stream whose layout is described by a directory of
|
||||
* variable-length records, so the directory has to be walked in full - including the
|
||||
* transformed `glyf`/`loca` entries we do not want - just to learn where the tables we
|
||||
* do want begin.
|
||||
*/
|
||||
function readWoff2Tables(buffer: Buffer): Map<string, Buffer> {
|
||||
if (buffer.length < WOFF2_HEADER_SIZE) {
|
||||
throw new Error('WOFF2 header is truncated');
|
||||
}
|
||||
if (buffer.toString('latin1', 4, 8) === 'ttcf') {
|
||||
throw new Error('WOFF2 font collections are not supported');
|
||||
}
|
||||
|
||||
const numTables = buffer.readUInt16BE(12);
|
||||
checkTableCount(numTables);
|
||||
|
||||
const totalSfntSize = buffer.readUInt32BE(16);
|
||||
if (totalSfntSize > MAX_WOFF2_DECOMPRESSED_BYTES) {
|
||||
throw new Error('WOFF2 font is too large to expand for metadata');
|
||||
}
|
||||
const totalCompressedSize = buffer.readUInt32BE(20);
|
||||
|
||||
const { entries, end } = readWoff2Directory(buffer, numTables);
|
||||
if (!entries.some((entry) => TABLES_OF_INTEREST.has(entry.tag))) return new Map();
|
||||
if (end + totalCompressedSize > buffer.length) {
|
||||
throw new Error('WOFF2 compressed stream extends past end of font');
|
||||
}
|
||||
|
||||
const expanded = brotliDecompressSync(buffer.subarray(end, end + totalCompressedSize), {
|
||||
maxOutputLength: MAX_WOFF2_DECOMPRESSED_BYTES,
|
||||
});
|
||||
|
||||
const tables = new Map<string, Buffer>();
|
||||
for (const entry of entries) {
|
||||
if (!TABLES_OF_INTEREST.has(entry.tag)) continue;
|
||||
const table = sliceTable(expanded, entry.offset, entry.length);
|
||||
if (table) tables.set(entry.tag, table);
|
||||
}
|
||||
return tables;
|
||||
}
|
||||
|
||||
function readWoff2Directory(buffer: Buffer, numTables: number): { entries: Woff2DirectoryEntry[]; end: number } {
|
||||
const entries: Woff2DirectoryEntry[] = [];
|
||||
let cursor = WOFF2_HEADER_SIZE;
|
||||
let offset = 0;
|
||||
|
||||
for (let i = 0; i < numTables; i += 1) {
|
||||
if (cursor >= buffer.length) throw new Error('WOFF2 table directory is truncated');
|
||||
|
||||
const flags = buffer.readUInt8(cursor);
|
||||
cursor += 1;
|
||||
|
||||
const tagIndex = flags & 0x3f;
|
||||
let tag: string;
|
||||
if (tagIndex === WOFF2_ARBITRARY_TAG) {
|
||||
if (cursor + 4 > buffer.length) throw new Error('WOFF2 table directory is truncated');
|
||||
tag = buffer.toString('latin1', cursor, cursor + 4);
|
||||
cursor += 4;
|
||||
} else {
|
||||
const known = WOFF2_KNOWN_TAGS[tagIndex];
|
||||
if (!known) throw new Error('WOFF2 table directory references an unknown tag index');
|
||||
tag = known;
|
||||
}
|
||||
|
||||
const origLength = readUIntBase128(buffer, cursor);
|
||||
cursor = origLength.next;
|
||||
|
||||
const transformVersion = (flags >> 6) & 0x03;
|
||||
const isGlyfOrLoca = tag === 'glyf' || tag === 'loca';
|
||||
const transformed = isGlyfOrLoca ? transformVersion !== WOFF2_NULL_TRANSFORM_GLYF_LOCA : transformVersion !== 0;
|
||||
|
||||
let length = origLength.value;
|
||||
if (transformed) {
|
||||
const transformLength = readUIntBase128(buffer, cursor);
|
||||
cursor = transformLength.next;
|
||||
length = transformLength.value;
|
||||
}
|
||||
|
||||
if (offset + length > MAX_WOFF2_DECOMPRESSED_BYTES) {
|
||||
throw new Error('WOFF2 tables declare more data than the expansion limit allows');
|
||||
}
|
||||
|
||||
entries.push({ tag, offset, length });
|
||||
offset += length;
|
||||
}
|
||||
|
||||
return { entries, end: cursor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads WOFF2's variable-length integer: seven bits per byte, most significant first,
|
||||
* with the high bit marking continuation. The specification forbids both leading zeroes
|
||||
* and values wider than 32 bits, and rejecting them keeps a crafted directory from
|
||||
* running the cursor somewhere it should not go.
|
||||
*/
|
||||
function readUIntBase128(buffer: Buffer, start: number): { value: number; next: number } {
|
||||
let value = 0;
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const at = start + i;
|
||||
if (at >= buffer.length) throw new Error('WOFF2 integer is truncated');
|
||||
|
||||
const byte = buffer.readUInt8(at);
|
||||
if (i === 0 && byte === 0x80) throw new Error('WOFF2 integer has a leading zero');
|
||||
|
||||
value = value * 128 + (byte & 0x7f);
|
||||
if (value > 0xffffffff) throw new Error('WOFF2 integer overflows 32 bits');
|
||||
if ((byte & 0x80) === 0) return { value, next: at + 1 };
|
||||
}
|
||||
throw new Error('WOFF2 integer is too long');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,21 +350,21 @@ function decodeNameValue(raw: Buffer, platformID: number): string | null {
|
||||
return raw.toString('latin1');
|
||||
}
|
||||
|
||||
interface NameCandidate {
|
||||
value: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
function parseNameTable(table: Buffer | null): Pick<RawFontMetadata, 'familyName' | 'subfamilyName'> {
|
||||
if (!table || table.length < NAME_TABLE_HEADER_SIZE) {
|
||||
return { familyName: null, subfamilyName: null };
|
||||
}
|
||||
/**
|
||||
* Indexes the best English string for every name id in the table.
|
||||
*
|
||||
* Every id is kept rather than only the family and subfamily, because a variable font's
|
||||
* named instances point at ids the table alone cannot predict. Typographic names
|
||||
* (ids 16/17) group weight and style variants under one family ("Source Han Sans" rather
|
||||
* than "Source Han Sans Light"), so callers prefer them where both exist.
|
||||
*/
|
||||
function parseNameTable(table: Buffer | null): Map<number, string> {
|
||||
const names = new Map<number, string>();
|
||||
if (!table || table.length < NAME_TABLE_HEADER_SIZE) return names;
|
||||
|
||||
const count = table.readUInt16BE(2);
|
||||
const storageOffset = table.readUInt16BE(4);
|
||||
|
||||
let family: NameCandidate | null = null;
|
||||
let subfamily: NameCandidate | null = null;
|
||||
const scores = new Map<number, number>();
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const record = NAME_TABLE_HEADER_SIZE + i * NAME_RECORD_SIZE;
|
||||
@@ -195,21 +374,11 @@ function parseNameTable(table: Buffer | null): Pick<RawFontMetadata, 'familyName
|
||||
const languageID = table.readUInt16BE(record + 4);
|
||||
const nameID = table.readUInt16BE(record + 6);
|
||||
|
||||
// Typographic names group weight/style variants under one family ("Source Han Sans"
|
||||
// rather than "Source Han Sans Light"), which is what the reader needs to let the
|
||||
// browser pick variants, so prefer them over the legacy names when both exist.
|
||||
const isFamily = nameID === NAME_ID_FAMILY || nameID === NAME_ID_TYPOGRAPHIC_FAMILY;
|
||||
const isSubfamily = nameID === NAME_ID_SUBFAMILY || nameID === NAME_ID_TYPOGRAPHIC_SUBFAMILY;
|
||||
if (!isFamily && !isSubfamily) continue;
|
||||
const score = englishScore(platformID, languageID);
|
||||
if (score === 0) continue;
|
||||
|
||||
const language = englishScore(platformID, languageID);
|
||||
if (language === 0) continue;
|
||||
|
||||
const isTypographic = nameID === NAME_ID_TYPOGRAPHIC_FAMILY || nameID === NAME_ID_TYPOGRAPHIC_SUBFAMILY;
|
||||
const score = (isTypographic ? 10 : 0) + language;
|
||||
|
||||
const current = isFamily ? family : subfamily;
|
||||
if (current && current.score >= score) continue;
|
||||
const best = scores.get(nameID);
|
||||
if (best !== undefined && best >= score) continue;
|
||||
|
||||
const length = table.readUInt16BE(record + 8);
|
||||
const offset = table.readUInt16BE(record + 10);
|
||||
@@ -219,11 +388,11 @@ function parseNameTable(table: Buffer | null): Pick<RawFontMetadata, 'familyName
|
||||
const value = decodeNameValue(table.subarray(start, start + length), platformID)?.trim();
|
||||
if (!value) continue;
|
||||
|
||||
if (isFamily) family = { value, score };
|
||||
else subfamily = { value, score };
|
||||
names.set(nameID, value);
|
||||
scores.set(nameID, score);
|
||||
}
|
||||
|
||||
return { familyName: family?.value ?? null, subfamilyName: subfamily?.value ?? null };
|
||||
return names;
|
||||
}
|
||||
|
||||
function parseOs2Table(table: Buffer | null): Pick<RawFontMetadata, 'usWeightClass' | 'fsSelection'> {
|
||||
@@ -234,3 +403,69 @@ function parseOs2Table(table: Buffer | null): Pick<RawFontMetadata, 'usWeightCla
|
||||
fsSelection: table.length >= OS2_FS_SELECTION_OFFSET + 2 ? table.readUInt16BE(OS2_FS_SELECTION_OFFSET) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads a 16.16 fixed-point number, the format every fvar coordinate uses. */
|
||||
function readFixed(buffer: Buffer, offset: number): number {
|
||||
return buffer.readInt32BE(offset) / 65536;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the variation axes and the instances the designer named along them. A static
|
||||
* font has no `fvar` table and yields empty lists.
|
||||
*/
|
||||
function parseFvarTable(table: Buffer | null, names: Map<number, string>): Pick<RawFontMetadata, 'axes' | 'instances'> {
|
||||
const empty = { axes: [], instances: [] };
|
||||
if (!table || table.length < FVAR_HEADER_SIZE) return empty;
|
||||
|
||||
const axesArrayOffset = table.readUInt16BE(4);
|
||||
const axisCount = table.readUInt16BE(8);
|
||||
const axisSize = table.readUInt16BE(10);
|
||||
const instanceCount = table.readUInt16BE(12);
|
||||
const instanceSize = table.readUInt16BE(14);
|
||||
|
||||
if (axisCount === 0 || axisCount > MAX_VARIATION_AXES) return empty;
|
||||
if (axisSize < FVAR_MIN_AXIS_SIZE) return empty;
|
||||
if (axesArrayOffset + axisCount * axisSize > table.length) return empty;
|
||||
|
||||
const axes: FontVariationAxis[] = [];
|
||||
for (let i = 0; i < axisCount; i += 1) {
|
||||
const at = axesArrayOffset + i * axisSize;
|
||||
axes.push({
|
||||
tag: table.toString('latin1', at, at + 4),
|
||||
min: readFixed(table, at + 4),
|
||||
default: readFixed(table, at + 8),
|
||||
max: readFixed(table, at + 12),
|
||||
});
|
||||
}
|
||||
|
||||
return { axes, instances: parseFvarInstances(table, names, axes, axesArrayOffset + axisCount * axisSize, instanceCount, instanceSize) };
|
||||
}
|
||||
|
||||
function parseFvarInstances(
|
||||
table: Buffer,
|
||||
names: Map<number, string>,
|
||||
axes: FontVariationAxis[],
|
||||
start: number,
|
||||
instanceCount: number,
|
||||
instanceSize: number,
|
||||
): FontVariationInstance[] {
|
||||
// Instances carry a coordinate per axis after two identifiers, plus an optional
|
||||
// PostScript name id. Anything shorter cannot describe a position on the axes.
|
||||
if (instanceSize < 4 + axes.length * 4) return [];
|
||||
|
||||
const instances: FontVariationInstance[] = [];
|
||||
const limit = Math.min(instanceCount, MAX_NAMED_INSTANCES);
|
||||
for (let i = 0; i < limit; i += 1) {
|
||||
const at = start + i * instanceSize;
|
||||
if (at + instanceSize > table.length) break;
|
||||
|
||||
const coordinates: Record<string, number> = {};
|
||||
axes.forEach((axis, axisIndex) => {
|
||||
coordinates[axis.tag] = readFixed(table, at + 4 + axisIndex * 4);
|
||||
});
|
||||
|
||||
instances.push({ name: names.get(table.readUInt16BE(at)) ?? null, coordinates });
|
||||
}
|
||||
|
||||
return instances;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { deflateSync } from 'zlib';
|
||||
import { brotliCompressSync, deflateSync } from 'zlib';
|
||||
|
||||
/**
|
||||
* Builders for synthetic font binaries, so metadata tests exercise real byte layouts
|
||||
@@ -134,6 +134,157 @@ export function buildWoff(tables: WoffTableInput[]): Buffer {
|
||||
return Buffer.concat([header, directory, ...body]);
|
||||
}
|
||||
|
||||
export interface FvarAxisInput {
|
||||
tag: string;
|
||||
min: number;
|
||||
default: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface FvarInstanceInput {
|
||||
subfamilyNameID: number;
|
||||
/** One coordinate per axis, in axis order. */
|
||||
coordinates: number[];
|
||||
/** Emits the optional PostScript name id, widening every instance record by two bytes. */
|
||||
postScriptNameID?: number;
|
||||
}
|
||||
|
||||
export interface FvarOverrides {
|
||||
axisCount?: number;
|
||||
axisSize?: number;
|
||||
instanceCount?: number;
|
||||
instanceSize?: number;
|
||||
axesArrayOffset?: number;
|
||||
/** Declares an axis array somewhere the table does not reach. */
|
||||
declaredAxesArrayOffset?: number;
|
||||
}
|
||||
|
||||
function writeFixed(buffer: Buffer, value: number, offset: number): void {
|
||||
buffer.writeInt32BE(Math.round(value * 65536), offset);
|
||||
}
|
||||
|
||||
export function buildFvarTable(axes: FvarAxisInput[], instances: FvarInstanceInput[] = [], overrides: FvarOverrides = {}): Buffer {
|
||||
const axisSize = overrides.axisSize ?? 20;
|
||||
const withPostScriptName = instances.some((instance) => instance.postScriptNameID !== undefined);
|
||||
const instanceSize = overrides.instanceSize ?? 4 + axes.length * 4 + (withPostScriptName ? 2 : 0);
|
||||
const axesArrayOffset = overrides.axesArrayOffset ?? 16;
|
||||
|
||||
const header = Buffer.alloc(16);
|
||||
header.writeUInt16BE(1, 0);
|
||||
header.writeUInt16BE(0, 2);
|
||||
header.writeUInt16BE(overrides.declaredAxesArrayOffset ?? axesArrayOffset, 4);
|
||||
header.writeUInt16BE(2, 6);
|
||||
header.writeUInt16BE(overrides.axisCount ?? axes.length, 8);
|
||||
header.writeUInt16BE(axisSize, 10);
|
||||
header.writeUInt16BE(overrides.instanceCount ?? instances.length, 12);
|
||||
header.writeUInt16BE(instanceSize, 14);
|
||||
|
||||
// A declared size below the real record length is a malformed-input case, so lay the
|
||||
// records out at their true width and let the header disagree.
|
||||
const axisStride = Math.max(axisSize, 20);
|
||||
const axisBytes = Buffer.alloc(axes.length * axisStride);
|
||||
axes.forEach((axis, i) => {
|
||||
const at = i * axisStride;
|
||||
axisBytes.write(axis.tag, at, 4, 'latin1');
|
||||
writeFixed(axisBytes, axis.min, at + 4);
|
||||
writeFixed(axisBytes, axis.default, at + 8);
|
||||
writeFixed(axisBytes, axis.max, at + 12);
|
||||
axisBytes.writeUInt16BE(0, at + 16);
|
||||
axisBytes.writeUInt16BE(256 + i, at + 18);
|
||||
});
|
||||
|
||||
const instanceStride = Math.max(instanceSize, 4 + axes.length * 4 + (withPostScriptName ? 2 : 0));
|
||||
const instanceBytes = Buffer.alloc(instances.length * instanceStride);
|
||||
instances.forEach((instance, i) => {
|
||||
const at = i * instanceStride;
|
||||
instanceBytes.writeUInt16BE(instance.subfamilyNameID, at);
|
||||
instanceBytes.writeUInt16BE(0, at + 2);
|
||||
instance.coordinates.forEach((coordinate, axisIndex) => {
|
||||
writeFixed(instanceBytes, coordinate, at + 4 + axisIndex * 4);
|
||||
});
|
||||
if (instance.postScriptNameID !== undefined) {
|
||||
instanceBytes.writeUInt16BE(instance.postScriptNameID, at + 4 + instance.coordinates.length * 4);
|
||||
}
|
||||
});
|
||||
|
||||
const padding = Buffer.alloc(Math.max(0, axesArrayOffset - header.length));
|
||||
return Buffer.concat([header, padding, axisBytes, instanceBytes]);
|
||||
}
|
||||
|
||||
export interface Woff2TableInput {
|
||||
tag: string;
|
||||
data: Buffer;
|
||||
/**
|
||||
* Emits the entry as a transformed table, so a transform length precedes it and the
|
||||
* declared original length no longer describes the bytes in the compressed stream.
|
||||
*/
|
||||
transformed?: boolean;
|
||||
}
|
||||
|
||||
// Mirrors the specification's known-tag list; the fixture only needs the tags its tests use.
|
||||
const WOFF2_KNOWN_TAGS = ['cmap', 'head', 'hhea', 'hmtx', 'maxp', 'name', 'OS/2', 'post', 'cvt ', 'fpgm', 'glyf', 'loca'];
|
||||
const WOFF2_FVAR_TAG_INDEX = 47;
|
||||
|
||||
function writeUIntBase128(value: number): Buffer {
|
||||
const bytes: number[] = [];
|
||||
let remaining = value;
|
||||
do {
|
||||
bytes.unshift(remaining & 0x7f);
|
||||
remaining = Math.floor(remaining / 128);
|
||||
} while (remaining > 0);
|
||||
|
||||
for (let i = 0; i < bytes.length - 1; i += 1) bytes[i]! |= 0x80;
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function woff2TagIndex(tag: string): number {
|
||||
if (tag === 'fvar') return WOFF2_FVAR_TAG_INDEX;
|
||||
const known = WOFF2_KNOWN_TAGS.indexOf(tag);
|
||||
return known === -1 ? 63 : known;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a WOFF2 font: a directory of variable-length records followed by one Brotli
|
||||
* stream holding every table back to back.
|
||||
*/
|
||||
export function buildWoff2(tables: Woff2TableInput[], overrides: { totalSfntSize?: number; numTables?: number } = {}): Buffer {
|
||||
const records: Buffer[] = [];
|
||||
let totalSfntSize = 12 + tables.length * 16;
|
||||
|
||||
for (const table of tables) {
|
||||
const tagIndex = woff2TagIndex(table.tag);
|
||||
const isGlyfOrLoca = table.tag === 'glyf' || table.tag === 'loca';
|
||||
// glyf and loca invert the convention: 3 means untransformed where 0 means it elsewhere.
|
||||
const transformVersion = table.transformed ? (isGlyfOrLoca ? 0 : 1) : isGlyfOrLoca ? 3 : 0;
|
||||
|
||||
const parts: Buffer[] = [Buffer.from([tagIndex | (transformVersion << 6)])];
|
||||
if (tagIndex === 63) parts.push(Buffer.from(table.tag.padEnd(4, ' '), 'latin1'));
|
||||
|
||||
// A transformed table is stored shorter than it expands to, so declare a larger
|
||||
// original length and let the transform length describe the bytes in the stream.
|
||||
const origLength = table.transformed ? table.data.length * 2 : table.data.length;
|
||||
parts.push(writeUIntBase128(origLength));
|
||||
if (table.transformed) parts.push(writeUIntBase128(table.data.length));
|
||||
|
||||
records.push(Buffer.concat(parts));
|
||||
totalSfntSize += origLength;
|
||||
}
|
||||
|
||||
const compressed = brotliCompressSync(Buffer.concat(tables.map((table) => table.data)));
|
||||
const directory = Buffer.concat(records);
|
||||
|
||||
const header = Buffer.alloc(48);
|
||||
header.write('wOF2', 0, 4, 'latin1');
|
||||
header.writeUInt32BE(0x00010000, 4);
|
||||
header.writeUInt32BE(48 + directory.length + compressed.length, 8);
|
||||
header.writeUInt16BE(overrides.numTables ?? tables.length, 12);
|
||||
header.writeUInt32BE(overrides.totalSfntSize ?? totalSfntSize, 16);
|
||||
header.writeUInt32BE(compressed.length, 20);
|
||||
header.writeUInt16BE(1, 24);
|
||||
|
||||
return Buffer.concat([header, directory, compressed]);
|
||||
}
|
||||
|
||||
export function windowsName(nameID: number, value: string, languageID = LANG_WINDOWS_EN_US): NameRecord {
|
||||
return { platformID: PLATFORM_WINDOWS, languageID, nameID, value };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { createHash } from 'crypto';
|
||||
import { FONT_FORMATS, MAX_FONT_FILE_SIZE, type FontFormat, type FontStyle } from '@bookorbit/types';
|
||||
import { FONT_FORMATS, MAX_FONT_FILE_SIZE, type FontFormat, type FontNamedInstance, type FontStyle } from '@bookorbit/types';
|
||||
|
||||
import { familyNameFromFilename, type FontValidationService } from './font.validation.service';
|
||||
|
||||
@@ -15,6 +15,9 @@ export interface FontIdentity {
|
||||
familyName: string;
|
||||
weight: number;
|
||||
style: FontStyle;
|
||||
weightMin: number | null;
|
||||
weightMax: number | null;
|
||||
instances: FontNamedInstance[] | null;
|
||||
/** Family name read from the font's own tables, or null when it had to be inferred. */
|
||||
suggestedFamilyName: string | null;
|
||||
}
|
||||
@@ -53,6 +56,9 @@ export function resolveFontIdentity(validation: FontValidationService, buffer: B
|
||||
familyName: metadata.familyName ?? familyNameFromFilename(filename) ?? FALLBACK_FAMILY_NAME,
|
||||
weight: metadata.weight,
|
||||
style: metadata.style,
|
||||
weightMin: metadata.weightMin,
|
||||
weightMax: metadata.weightMax,
|
||||
instances: metadata.instances,
|
||||
suggestedFamilyName: metadata.familyName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ function makeFontRow(overrides: Partial<UserFontRow> = {}): UserFontRow {
|
||||
format: 'ttf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
weightMin: null,
|
||||
weightMax: null,
|
||||
instances: null,
|
||||
fileSize: 50000,
|
||||
fileHash: 'abc123hash',
|
||||
createdAt: new Date(),
|
||||
@@ -105,6 +108,9 @@ describe('FontService', () => {
|
||||
format: 'ttf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
weightMin: null,
|
||||
weightMax: null,
|
||||
instances: null,
|
||||
fileSize: 50000,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
@@ -43,7 +43,11 @@ export class FontService {
|
||||
throw new BadRequestException(`Maximum of ${MAX_FONTS_PER_USER} fonts per user reached`);
|
||||
}
|
||||
|
||||
const { familyName, weight, style, suggestedFamilyName } = resolveFontIdentity(this.validation, buffer, originalFilename);
|
||||
const { familyName, weight, style, weightMin, weightMax, instances, suggestedFamilyName } = resolveFontIdentity(
|
||||
this.validation,
|
||||
buffer,
|
||||
originalFilename,
|
||||
);
|
||||
|
||||
const storedFileName = await this.storage.save(user.id, format, buffer);
|
||||
|
||||
@@ -57,6 +61,9 @@ export class FontService {
|
||||
format,
|
||||
weight,
|
||||
style,
|
||||
weightMin,
|
||||
weightMax,
|
||||
instances,
|
||||
fileSize: buffer.length,
|
||||
fileHash,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { FontValidationService } from './font.validation.service';
|
||||
import { buildFontWithMetadata } from './font-test-fixtures';
|
||||
import { buildFontWithMetadata, buildFvarTable, buildNameTable, buildOs2Table, buildSfnt, windowsName } from './font-test-fixtures';
|
||||
|
||||
vi.mock('./font-metadata.parser', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./font-metadata.parser')>();
|
||||
@@ -73,7 +73,14 @@ describe('FontValidationService', () => {
|
||||
|
||||
describe('extractMetadata', () => {
|
||||
it('extracts family name, weight, and style from font metadata', () => {
|
||||
parseMock.mockReturnValue({ familyName: 'Literata', subfamilyName: 'Bold Italic', usWeightClass: 700, fsSelection: 1 });
|
||||
parseMock.mockReturnValue({
|
||||
familyName: 'Literata',
|
||||
subfamilyName: 'Bold Italic',
|
||||
usWeightClass: 700,
|
||||
fsSelection: 1,
|
||||
axes: [],
|
||||
instances: [],
|
||||
});
|
||||
|
||||
const result = service.extractMetadata(STUB_BUFFER, 'Literata-BoldItalic.ttf');
|
||||
expect(result.familyName).toBe('Literata');
|
||||
@@ -81,10 +88,10 @@ describe('FontValidationService', () => {
|
||||
expect(result.style).toBe('italic');
|
||||
});
|
||||
|
||||
it('normalizes weight to nearest hundred', () => {
|
||||
parseMock.mockReturnValue({ familyName: 'Test', subfamilyName: 'Regular', usWeightClass: 350, fsSelection: 0 });
|
||||
it('preserves a valid nonstandard CSS weight', () => {
|
||||
parseMock.mockReturnValue({ familyName: 'Test', subfamilyName: 'Regular', usWeightClass: 350, fsSelection: 0, axes: [], instances: [] });
|
||||
|
||||
expect(service.extractMetadata(STUB_BUFFER, 'Test.ttf').weight).toBe(400);
|
||||
expect(service.extractMetadata(STUB_BUFFER, 'Test.ttf').weight).toBe(350);
|
||||
});
|
||||
|
||||
it('falls back to filename heuristics when parsing fails', () => {
|
||||
@@ -99,19 +106,33 @@ describe('FontValidationService', () => {
|
||||
});
|
||||
|
||||
it('detects weight from filename when metadata lacks weight', () => {
|
||||
parseMock.mockReturnValue({ familyName: 'Test', subfamilyName: null, usWeightClass: undefined, fsSelection: undefined });
|
||||
parseMock.mockReturnValue({
|
||||
familyName: 'Test',
|
||||
subfamilyName: null,
|
||||
usWeightClass: undefined,
|
||||
fsSelection: undefined,
|
||||
axes: [],
|
||||
instances: [],
|
||||
});
|
||||
|
||||
expect(service.extractMetadata(STUB_BUFFER, 'Test-Light.ttf').weight).toBe(300);
|
||||
});
|
||||
|
||||
it('detects italic from subfamily name', () => {
|
||||
parseMock.mockReturnValue({ familyName: 'Test', subfamilyName: 'Italic', usWeightClass: 400, fsSelection: 0 });
|
||||
parseMock.mockReturnValue({ familyName: 'Test', subfamilyName: 'Italic', usWeightClass: 400, fsSelection: 0, axes: [], instances: [] });
|
||||
|
||||
expect(service.extractMetadata(STUB_BUFFER, 'Test-Italic.ttf').style).toBe('italic');
|
||||
});
|
||||
|
||||
it('defaults to weight 400 and style normal when no metadata or heuristics match', () => {
|
||||
parseMock.mockReturnValue({ familyName: 'Test', subfamilyName: null, usWeightClass: undefined, fsSelection: undefined });
|
||||
parseMock.mockReturnValue({
|
||||
familyName: 'Test',
|
||||
subfamilyName: null,
|
||||
usWeightClass: undefined,
|
||||
fsSelection: undefined,
|
||||
axes: [],
|
||||
instances: [],
|
||||
});
|
||||
|
||||
const result = service.extractMetadata(STUB_BUFFER, 'Test.ttf');
|
||||
expect(result.weight).toBe(400);
|
||||
@@ -119,13 +140,13 @@ describe('FontValidationService', () => {
|
||||
});
|
||||
|
||||
it('returns null family name when the font carries no usable name', () => {
|
||||
parseMock.mockReturnValue({ familyName: null, subfamilyName: null, usWeightClass: 400, fsSelection: 0 });
|
||||
parseMock.mockReturnValue({ familyName: null, subfamilyName: null, usWeightClass: 400, fsSelection: 0, axes: [], instances: [] });
|
||||
|
||||
expect(service.extractMetadata(STUB_BUFFER, 'TestFont.ttf').familyName).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores an out-of-range usWeightClass and falls back to the filename', () => {
|
||||
parseMock.mockReturnValue({ familyName: 'Test', subfamilyName: null, usWeightClass: 5000, fsSelection: 0 });
|
||||
parseMock.mockReturnValue({ familyName: 'Test', subfamilyName: null, usWeightClass: 5000, fsSelection: 0, axes: [], instances: [] });
|
||||
|
||||
expect(service.extractMetadata(STUB_BUFFER, 'Test-Bold.ttf').weight).toBe(700);
|
||||
});
|
||||
@@ -175,7 +196,7 @@ describe('FontValidationService', () => {
|
||||
const font = buildFontWithMetadata({ family: 'Iosevka', subfamily: 'Bold', usWeightClass: 700, fsSelection: 32 });
|
||||
|
||||
const result = service.extractMetadata(font, 'whatever-the-file-was-called.ttf');
|
||||
expect(result).toEqual({ familyName: 'Iosevka', weight: 700, style: 'normal' });
|
||||
expect(result).toEqual({ familyName: 'Iosevka', weight: 700, style: 'normal', weightMin: null, weightMax: null, instances: null });
|
||||
});
|
||||
|
||||
it('reads the italic bit out of fsSelection', () => {
|
||||
@@ -213,4 +234,132 @@ describe('FontValidationService', () => {
|
||||
expect(result.weight).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractMetadata for variable fonts', () => {
|
||||
const variableFont = (
|
||||
axes: { tag: string; min: number; default: number; max: number }[],
|
||||
instances: { subfamilyNameID: number; coordinates: number[] }[] = [],
|
||||
names = [windowsName(1, 'Inter'), windowsName(256, 'Light'), windowsName(257, 'Bold')],
|
||||
os2: { usWeightClass?: number; fsSelection?: number } = { usWeightClass: 400 },
|
||||
) =>
|
||||
buildSfnt([
|
||||
{ tag: 'name', data: buildNameTable(names) },
|
||||
{ tag: 'OS/2', data: buildOs2Table(os2) },
|
||||
{ tag: 'fvar', data: buildFvarTable(axes, instances) },
|
||||
]);
|
||||
|
||||
it("reports the weight axis as the file's range", () => {
|
||||
const font = variableFont([{ tag: 'wght', min: 100, default: 400, max: 900 }]);
|
||||
|
||||
expect(service.extractMetadata(font, 'Inter.ttf')).toMatchObject({ weight: 400, weightMin: 100, weightMax: 900 });
|
||||
});
|
||||
|
||||
it('uses the weight axis default even when it is not a standard hundred', () => {
|
||||
const font = variableFont([{ tag: 'wght', min: 300, default: 350, max: 1000 }]);
|
||||
|
||||
expect(service.extractMetadata(font, 'Inter.ttf')).toMatchObject({ weight: 350, weightMin: 300, weightMax: 1000 });
|
||||
});
|
||||
|
||||
it('offers the named instances as selectable styles', () => {
|
||||
const font = variableFont(
|
||||
[{ tag: 'wght', min: 100, default: 400, max: 900 }],
|
||||
[
|
||||
{ subfamilyNameID: 257, coordinates: [700] },
|
||||
{ subfamilyNameID: 256, coordinates: [300] },
|
||||
],
|
||||
);
|
||||
|
||||
expect(service.extractMetadata(font, 'Inter.ttf').instances).toEqual([
|
||||
{ name: 'Light', weight: 300, style: 'normal' },
|
||||
{ name: 'Bold', weight: 700, style: 'normal' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads italic instances off the ital axis', () => {
|
||||
const font = variableFont(
|
||||
[
|
||||
{ tag: 'wght', min: 100, default: 400, max: 900 },
|
||||
{ tag: 'ital', min: 0, default: 0, max: 1 },
|
||||
],
|
||||
[
|
||||
{ subfamilyNameID: 256, coordinates: [300, 0] },
|
||||
{ subfamilyNameID: 257, coordinates: [700, 1] },
|
||||
],
|
||||
);
|
||||
|
||||
expect(service.extractMetadata(font, 'Inter.ttf').instances).toEqual([
|
||||
{ name: 'Light', weight: 300, style: 'normal' },
|
||||
{ name: 'Bold', weight: 700, style: 'italic' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats a slanted instance as italic', () => {
|
||||
const font = variableFont(
|
||||
[
|
||||
{ tag: 'wght', min: 100, default: 400, max: 900 },
|
||||
{ tag: 'slnt', min: -10, default: 0, max: 0 },
|
||||
],
|
||||
[{ subfamilyNameID: 257, coordinates: [700, -10] }],
|
||||
);
|
||||
|
||||
expect(service.extractMetadata(font, 'Inter.ttf').instances).toEqual([{ name: 'Bold', weight: 700, style: 'italic' }]);
|
||||
});
|
||||
|
||||
it("keeps the file's own style for instances that name no italic axis", () => {
|
||||
const font = variableFont(
|
||||
[{ tag: 'wght', min: 100, default: 400, max: 900 }],
|
||||
[{ subfamilyNameID: 256, coordinates: [300] }],
|
||||
[windowsName(1, 'Inter'), windowsName(256, 'Light')],
|
||||
{ usWeightClass: 400, fsSelection: 1 },
|
||||
);
|
||||
|
||||
expect(service.extractMetadata(font, 'Inter-Italic.ttf').instances).toEqual([{ name: 'Light', weight: 300, style: 'italic' }]);
|
||||
});
|
||||
|
||||
it('stands in the standard weights when the font names no usable instance', () => {
|
||||
const font = variableFont([{ tag: 'wght', min: 300, default: 400, max: 500 }]);
|
||||
|
||||
expect(service.extractMetadata(font, 'Inter.ttf').instances).toEqual([
|
||||
{ name: null, weight: 300, style: 'normal' },
|
||||
{ name: null, weight: 400, style: 'normal' },
|
||||
{ name: null, weight: 500, style: 'normal' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a narrow axis reachable when it contains no standard weight', () => {
|
||||
const font = variableFont([{ tag: 'wght', min: 20, default: 35, max: 50 }]);
|
||||
|
||||
expect(service.extractMetadata(font, 'Inter.ttf').instances).toEqual([{ name: null, weight: 35, style: 'normal' }]);
|
||||
});
|
||||
|
||||
it('reports no range for a font whose only axis is not weight', () => {
|
||||
const font = variableFont([{ tag: 'wdth', min: 75, default: 100, max: 125 }]);
|
||||
|
||||
expect(service.extractMetadata(font, 'Inter.ttf')).toMatchObject({ weightMin: null, weightMax: null, instances: null });
|
||||
});
|
||||
|
||||
it('reports no range for a weight axis that spans a single value', () => {
|
||||
const font = variableFont([{ tag: 'wght', min: 400, default: 400, max: 400 }]);
|
||||
|
||||
expect(service.extractMetadata(font, 'Inter.ttf')).toMatchObject({ weightMin: null, weightMax: null, instances: null });
|
||||
});
|
||||
|
||||
it('clamps an axis declaring weights outside what CSS accepts', () => {
|
||||
const font = variableFont([{ tag: 'wght', min: -50, default: 400, max: 4000 }]);
|
||||
|
||||
expect(service.extractMetadata(font, 'Inter.ttf')).toMatchObject({ weightMin: 1, weightMax: 1000 });
|
||||
});
|
||||
|
||||
it('drops a duplicate instance rather than offering the same style twice', () => {
|
||||
const font = variableFont(
|
||||
[{ tag: 'wght', min: 100, default: 400, max: 900 }],
|
||||
[
|
||||
{ subfamilyNameID: 256, coordinates: [300] },
|
||||
{ subfamilyNameID: 257, coordinates: [300] },
|
||||
],
|
||||
);
|
||||
|
||||
expect(service.extractMetadata(font, 'Inter.ttf').instances).toEqual([{ name: 'Light', weight: 300, style: 'normal' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import type { FontFormat, FontStyle } from '@bookorbit/types';
|
||||
import { FONT_FORMATS } from '@bookorbit/types';
|
||||
import type { FontFormat, FontNamedInstance, FontStyle } from '@bookorbit/types';
|
||||
import { CSS_FONT_WEIGHT_MAX, CSS_FONT_WEIGHT_MIN, FONT_FORMATS, FONT_WEIGHTS, compareFontVariants, isCssFontWeight } from '@bookorbit/types';
|
||||
|
||||
import { parseFontMetadata } from './font-metadata.parser';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { parseFontMetadata, type FontVariationAxis, type FontVariationInstance } from './font-metadata.parser';
|
||||
|
||||
export interface FontMetadata {
|
||||
familyName: string | null;
|
||||
weight: number;
|
||||
style: FontStyle;
|
||||
weightMin: number | null;
|
||||
weightMax: number | null;
|
||||
instances: FontNamedInstance[] | null;
|
||||
}
|
||||
|
||||
const WEIGHT_AXIS = 'wght';
|
||||
const ITALIC_AXIS = 'ital';
|
||||
const SLANT_AXIS = 'slnt';
|
||||
|
||||
// Halfway along a 0-1 axis is the conventional cut between upright and italic.
|
||||
const ITALIC_AXIS_THRESHOLD = 0.5;
|
||||
|
||||
const MAGIC_BYTES: Record<string, FontFormat[]> = {
|
||||
// TTF: version 1.0
|
||||
'00010000': ['ttf'],
|
||||
@@ -78,26 +89,38 @@ export class FontValidationService {
|
||||
}
|
||||
|
||||
extractMetadata(buffer: Buffer, filename: string): FontMetadata {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const metadata = parseFontMetadata(buffer);
|
||||
const subfamilyName = (metadata.subfamilyName ?? '').toLowerCase();
|
||||
|
||||
const weight = this.resolveWeight(metadata.usWeightClass, subfamilyName, filename);
|
||||
const range = this.resolveWeightRange(metadata.axes);
|
||||
const weightAxis = metadata.axes.find((axis) => axis.tag === WEIGHT_AXIS);
|
||||
const weight =
|
||||
range && weightAxis
|
||||
? this.clampWeightToRange(weightAxis.default, range)
|
||||
: this.resolveWeight(metadata.usWeightClass, subfamilyName, filename);
|
||||
const style = this.resolveStyle(metadata.fsSelection, subfamilyName, filename);
|
||||
|
||||
return { familyName: metadata.familyName, weight, style };
|
||||
return {
|
||||
familyName: metadata.familyName,
|
||||
weight,
|
||||
style,
|
||||
weightMin: range?.min ?? null,
|
||||
weightMax: range?.max ?? null,
|
||||
instances: range ? this.resolveInstances(metadata.instances, range, weight, style) : null,
|
||||
};
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
this.logger.debug(
|
||||
`[font.extract_metadata] [fail] filename=${filename} error="${(err as Error).message}" - falling back to filename heuristics`,
|
||||
`[font.extract_metadata] [fail] filename="${sanitizeLogValue(filename)}" durationMs=${Date.now() - startedAt} errorClass=${error.name} error="${sanitizeLogValue(error.message)}" - falling back to filename heuristics`,
|
||||
);
|
||||
return this.extractFromFilename(filename);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveWeight(os2Weight: number | undefined, subfamily: string, filename: string): number {
|
||||
if (os2Weight && os2Weight >= 100 && os2Weight <= 900) {
|
||||
return Math.round(os2Weight / 100) * 100;
|
||||
}
|
||||
if (isCssFontWeight(os2Weight)) return os2Weight;
|
||||
|
||||
return this.weightFromString(subfamily) ?? this.weightFromString(this.normalizeFilename(filename)) ?? 400;
|
||||
}
|
||||
@@ -115,6 +138,88 @@ export class FontValidationService {
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the `wght` axis bounds. A file whose axis collapses to a single value varies
|
||||
* along some other axis only, so it renders as a static weight and reports no range.
|
||||
*/
|
||||
private resolveWeightRange(axes: FontVariationAxis[]): { min: number; max: number } | null {
|
||||
const axis = axes.find((candidate) => candidate.tag === WEIGHT_AXIS);
|
||||
if (!axis) return null;
|
||||
|
||||
const min = this.clampAxisWeight(axis.min);
|
||||
const max = this.clampAxisWeight(axis.max);
|
||||
return max > min ? { min, max } : null;
|
||||
}
|
||||
|
||||
private clampAxisWeight(value: number): number {
|
||||
if (!Number.isFinite(value)) return CSS_FONT_WEIGHT_MIN;
|
||||
return Math.min(CSS_FONT_WEIGHT_MAX, Math.max(CSS_FONT_WEIGHT_MIN, Math.round(value)));
|
||||
}
|
||||
|
||||
private clampWeightToRange(value: number, range: { min: number; max: number }): number {
|
||||
return Math.min(range.max, Math.max(range.min, this.clampAxisWeight(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the positions the designer named into the styles the reader offers.
|
||||
*
|
||||
* A variable font is required to name its instances but not all of them do, and an
|
||||
* unnamed one is still selectable: the reader labels it from its weight. When a font
|
||||
* names nothing usable at all, the standard weights inside the axis range stand in, so
|
||||
* the range is never advertised without a way to reach it.
|
||||
*/
|
||||
private resolveInstances(
|
||||
instances: FontVariationInstance[],
|
||||
range: { min: number; max: number },
|
||||
defaultWeight: number,
|
||||
fileStyle: FontStyle,
|
||||
): FontNamedInstance[] {
|
||||
const seen = new Set<string>();
|
||||
const resolved: FontNamedInstance[] = [];
|
||||
|
||||
for (const instance of instances) {
|
||||
const coordinate = instance.coordinates[WEIGHT_AXIS];
|
||||
if (coordinate === undefined) continue;
|
||||
|
||||
const weight = this.clampWeightToRange(coordinate, range);
|
||||
const style = this.instanceStyle(instance, fileStyle);
|
||||
|
||||
const key = `${weight}:${style}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
resolved.push({ name: instance.name, weight, style });
|
||||
}
|
||||
|
||||
if (resolved.length === 0) {
|
||||
const weights: number[] = FONT_WEIGHTS.filter((weight) => weight >= range.min && weight <= range.max);
|
||||
if (!weights.includes(defaultWeight)) weights.push(defaultWeight);
|
||||
return weights
|
||||
.sort((a, b) => a - b)
|
||||
.map((weight) => ({
|
||||
name: null,
|
||||
weight,
|
||||
style: fileStyle,
|
||||
}));
|
||||
}
|
||||
|
||||
return resolved.sort(compareFontVariants);
|
||||
}
|
||||
|
||||
/**
|
||||
* An instance is italic when it sits on the italic side of an `ital` or `slnt` axis.
|
||||
* With neither axis present the file's own style applies, since an italic-only variable
|
||||
* font names its instances "Light", "Bold" and so on without repeating "Italic".
|
||||
*/
|
||||
private instanceStyle(instance: FontVariationInstance, fileStyle: FontStyle): FontStyle {
|
||||
const italic = instance.coordinates[ITALIC_AXIS];
|
||||
if (italic !== undefined) return italic >= ITALIC_AXIS_THRESHOLD ? 'italic' : 'normal';
|
||||
|
||||
const slant = instance.coordinates[SLANT_AXIS];
|
||||
if (slant !== undefined) return slant !== 0 ? 'italic' : 'normal';
|
||||
|
||||
return fileStyle;
|
||||
}
|
||||
|
||||
private weightFromString(str: string): number | null {
|
||||
const lower = str.toLowerCase();
|
||||
for (const [pattern, weight] of WEIGHT_PATTERNS) {
|
||||
@@ -129,13 +234,13 @@ export class FontValidationService {
|
||||
familyName: familyNameFromFilename(filename),
|
||||
weight: this.weightFromString(normalized) ?? 400,
|
||||
style: /\bitalic\b/i.test(normalized) || /\boblique\b/i.test(normalized) ? 'italic' : 'normal',
|
||||
// A filename cannot say what a variation axis spans, so the font reads as static.
|
||||
weightMin: null,
|
||||
weightMax: null,
|
||||
instances: null,
|
||||
};
|
||||
}
|
||||
|
||||
private familyNameFromFilename(filename: string): string | null {
|
||||
return familyNameFromFilename(filename);
|
||||
}
|
||||
|
||||
private normalizeFilename(filename: string): string {
|
||||
return filename.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
|
||||
}
|
||||
|
||||
@@ -219,6 +219,8 @@ describe('ReaderPreferencesService', () => {
|
||||
themeName: 'default',
|
||||
isDark: false,
|
||||
fontFamily: null,
|
||||
fontWeight: 400,
|
||||
fontStyle: 'normal' as const,
|
||||
fontSize: 16,
|
||||
lineHeight: 1.5,
|
||||
maxColumnCount: 2,
|
||||
@@ -247,6 +249,41 @@ describe('ReaderPreferencesService', () => {
|
||||
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 () => {
|
||||
const missingFontStyle = Object.fromEntries(Object.entries(validEpubDefaults).filter(([key]) => key !== 'fontWeight' && key !== 'fontStyle'));
|
||||
|
||||
await expect(service.upsertDefault(1, 'epub', missingFontStyle)).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' });
|
||||
|
||||
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 });
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('rejects an unsupported font style', async () => {
|
||||
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' });
|
||||
|
||||
await service.upsertPreference(user, 26, { fontWeight: 300, fontStyle: 'italic' });
|
||||
|
||||
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 26, { fontWeight: 300, fontStyle: 'italic' });
|
||||
});
|
||||
|
||||
it('accepts partial epub per-book settings with footerDisplayMode', async () => {
|
||||
const user = makeUser();
|
||||
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
|
||||
|
||||
@@ -2,6 +2,8 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
CBX_SPREAD_GAP_MAX,
|
||||
CBX_SPREAD_GAP_MIN,
|
||||
CSS_FONT_WEIGHT_MAX,
|
||||
CSS_FONT_WEIGHT_MIN,
|
||||
EPUB_FONT_SIZE_MAX,
|
||||
EPUB_FONT_SIZE_MIN,
|
||||
READER_GROUP_DEFAULTS,
|
||||
@@ -22,6 +24,8 @@ const EPUB_SETTINGS_SCHEMA = z
|
||||
themeName: z.string().min(1),
|
||||
isDark: z.boolean(),
|
||||
fontFamily: z.string().min(1).nullable(),
|
||||
fontWeight: z.number().int().min(CSS_FONT_WEIGHT_MIN).max(CSS_FONT_WEIGHT_MAX),
|
||||
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),
|
||||
maxColumnCount: z.number().int().min(1).max(10),
|
||||
|
||||
@@ -38,6 +38,9 @@ function makeFontRow(overrides: Partial<ServerFontRow> = {}): ServerFontRow {
|
||||
format: 'otf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
weightMin: null,
|
||||
weightMax: null,
|
||||
instances: null,
|
||||
fileSize: 50000,
|
||||
fileHash: 'abc123hash',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
@@ -103,6 +106,9 @@ describe('ServerFontService', () => {
|
||||
format: 'otf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
weightMin: null,
|
||||
weightMax: null,
|
||||
instances: null,
|
||||
fileSize: 50000,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
|
||||
@@ -62,7 +62,11 @@ export class ServerFontService {
|
||||
throw new BadRequestException(`Maximum of ${MAX_SERVER_FONTS} server fonts reached`);
|
||||
}
|
||||
|
||||
const { familyName, weight, style, suggestedFamilyName } = resolveFontIdentity(this.validation, buffer, originalFilename);
|
||||
const { familyName, weight, style, weightMin, weightMax, instances, suggestedFamilyName } = resolveFontIdentity(
|
||||
this.validation,
|
||||
buffer,
|
||||
originalFilename,
|
||||
);
|
||||
|
||||
const storedFileName = await this.storage.save(format, buffer);
|
||||
|
||||
@@ -76,6 +80,9 @@ export class ServerFontService {
|
||||
format,
|
||||
weight,
|
||||
style,
|
||||
weightMin,
|
||||
weightMax,
|
||||
instances,
|
||||
fileSize: buffer.length,
|
||||
fileHash,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user