mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(settings): add custom font upload with family grouping
This commit is contained in:
@@ -6,6 +6,7 @@ import { useReaderProgress } from './shared/composables/useReaderProgress'
|
||||
import { useReadingSession } from './shared/composables/useReadingSession'
|
||||
import { useReaderState } from './epub/composables/useReaderState'
|
||||
import { useReaderSettings } from './shared/composables/useReaderSettings'
|
||||
import { useCustomFonts } from './epub/composables/useCustomFonts'
|
||||
import { useVisibility } from './shared/composables/useVisibility'
|
||||
import { useBookmarks } from './epub/composables/useBookmarks'
|
||||
import { useAnnotations } from './epub/composables/useAnnotations'
|
||||
@@ -67,8 +68,11 @@ const {
|
||||
setIsDark,
|
||||
setThemeName,
|
||||
setFlow,
|
||||
setFontFaceCSS,
|
||||
} = readerState
|
||||
|
||||
const customFonts = useCustomFonts()
|
||||
|
||||
const progress = useReaderProgress(bookId, fileId)
|
||||
const { cfi, chapterTitle, sectionIndex, totalSections, fraction, updateHeadsFeet } = progress
|
||||
|
||||
@@ -142,6 +146,9 @@ onMounted(async () => {
|
||||
// Specialized readers own their own progress/settings/loading lifecycle.
|
||||
if (isAudioFormat || isPdfFormat || isComicFormat) return
|
||||
|
||||
await customFonts.fetchFonts()
|
||||
setFontFaceCSS(customFonts.generateFontFaceCSS())
|
||||
|
||||
await progress.load()
|
||||
|
||||
await bookSettings.load()
|
||||
@@ -216,6 +223,15 @@ watch(showSettings, (open) => {
|
||||
setVisibilityLock(open)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => customFonts.fonts.value,
|
||||
() => {
|
||||
setFontFaceCSS(customFonts.generateFontFaceCSS())
|
||||
const renderer = getRenderer()
|
||||
if (renderer && shouldApplyStyles.value) applyToRenderer(renderer)
|
||||
},
|
||||
)
|
||||
|
||||
async function handleHighlight(color: string, style: string, note?: string) {
|
||||
const annotationCfi = selection.cfi.value
|
||||
if (!selection.text.value || !annotationCfi) return
|
||||
@@ -311,7 +327,7 @@ function closeSearch() {
|
||||
@toggleFullscreen="toggleFullscreen"
|
||||
>
|
||||
<template #settingsPanel>
|
||||
<ReaderSettingsPanel :state="state" @update="applyUpdate" />
|
||||
<ReaderSettingsPanel :state="state" :customFonts="customFonts" @update="applyUpdate" />
|
||||
</template>
|
||||
</ReaderHeader>
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { BookOpen, LayoutGrid, Moon, Palette, ScrollText, Sun, Type } from 'lucide-vue-next'
|
||||
import type { ReaderState } from '../composables/useReaderState'
|
||||
import type { useCustomFonts } from '../composables/useCustomFonts'
|
||||
import { themes } from '../constants/themes'
|
||||
import { BUILTIN_READER_FONT_OPTIONS } from '@/features/reader/shared/constants/font-options'
|
||||
import { formatFontFamilyLabel } from '@/features/reader/shared/lib/font-display'
|
||||
|
||||
const props = defineProps<{
|
||||
state: ReaderState
|
||||
customFonts?: ReturnType<typeof useCustomFonts>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -29,14 +33,6 @@ const tabs: { id: Tab; icon: typeof Palette; label: string }[] = [
|
||||
{ id: 'layout', icon: LayoutGrid, label: 'Layout' },
|
||||
]
|
||||
|
||||
const fontFamilies = [
|
||||
{ name: "Publisher's", value: null },
|
||||
{ name: 'Serif', value: 'serif' },
|
||||
{ name: 'Sans-Serif', value: 'sans-serif' },
|
||||
{ name: 'Monospace', value: 'monospace' },
|
||||
{ name: 'Cursive', value: 'cursive' },
|
||||
]
|
||||
|
||||
const stepperButtonClass = 'size-8 rounded-lg border border-border text-lg font-light text-foreground transition-colors hover:bg-muted'
|
||||
const stepperGroupClass = 'flex min-w-[11rem] items-center justify-end gap-2'
|
||||
const stepperValueClass = 'w-[4rem] text-center text-sm font-mono font-medium tabular-nums text-foreground'
|
||||
@@ -79,6 +75,46 @@ onMounted(() => {
|
||||
contentRef.value.scrollTop = scrollMemory.value[activeTab.value] ?? 0
|
||||
hasTabBarShadow.value = contentRef.value.scrollTop > 0
|
||||
})
|
||||
|
||||
onUnmounted(removePreviewStyles)
|
||||
|
||||
const previewStyleEl = ref<HTMLStyleElement | null>(null)
|
||||
|
||||
function injectPreviewStyles(css: string) {
|
||||
removePreviewStyles()
|
||||
if (!css) return
|
||||
const el = document.createElement('style')
|
||||
el.setAttribute('data-reader-font-preview', '')
|
||||
el.textContent = css
|
||||
document.head.appendChild(el)
|
||||
previewStyleEl.value = el
|
||||
}
|
||||
|
||||
function removePreviewStyles() {
|
||||
if (previewStyleEl.value) {
|
||||
previewStyleEl.value.remove()
|
||||
previewStyleEl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.customFonts?.fonts.value,
|
||||
() => {
|
||||
injectPreviewStyles(props.customFonts?.generateFontFaceCSS() ?? '')
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function selectCustomFont(familyName: string) {
|
||||
if (!props.customFonts) return
|
||||
const cssFamilyName = props.customFonts.getCssFamilyForDisplay(familyName)
|
||||
if (cssFamilyName) emit('update', { fontFamily: cssFamilyName })
|
||||
}
|
||||
|
||||
function isCustomFontSelected(familyName: string): boolean {
|
||||
if (!props.customFonts) return false
|
||||
return props.customFonts.isFontFamilySelected(familyName, props.state.fontFamily)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -196,24 +232,50 @@ onMounted(() => {
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium leading-tight">Font family</p>
|
||||
<p class="text-xs leading-tight text-muted-foreground">Switch between publisher default and common reader fonts.</p>
|
||||
<p class="text-xs leading-tight text-muted-foreground">Choose built-in or uploaded fonts for body text.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="font in fontFamilies"
|
||||
:key="String(font.value)"
|
||||
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="
|
||||
state.fontFamily === font.value
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border hover:border-muted-foreground/40 hover:bg-muted'
|
||||
"
|
||||
:style="font.value ? { fontFamily: font.value } : {}"
|
||||
@click="emit('update', { fontFamily: font.value })"
|
||||
>
|
||||
{{ font.name }}
|
||||
</button>
|
||||
<div class="space-y-2">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Built-in</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="font in BUILTIN_READER_FONT_OPTIONS"
|
||||
:key="String(font.value)"
|
||||
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="
|
||||
state.fontFamily === font.value
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border hover:border-muted-foreground/40 hover:bg-muted'
|
||||
"
|
||||
:style="font.value ? { fontFamily: font.value } : {}"
|
||||
@click="emit('update', { fontFamily: font.value })"
|
||||
>
|
||||
{{ font.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="customFonts && customFonts.families.value.length > 0">
|
||||
<div class="h-px bg-border/70" />
|
||||
<div class="space-y-2">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Your fonts</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="family in customFonts.families.value"
|
||||
:key="family.name"
|
||||
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="
|
||||
isCustomFontSelected(family.name)
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border hover:border-muted-foreground/40 hover:bg-muted'
|
||||
"
|
||||
:style="{ fontFamily: `'${family.cssFamilyName}', sans-serif` }"
|
||||
@click="selectCustomFont(family.name)"
|
||||
>
|
||||
{{ formatFontFamilyLabel(family.name) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { useCustomFonts } from '../useCustomFonts'
|
||||
import type { UserFont, FontUploadResult } from '@bookorbit/types'
|
||||
import { fontCssFamilyGroupName } from '@bookorbit/types'
|
||||
|
||||
const mockFonts: UserFont[] = [
|
||||
{
|
||||
id: 1,
|
||||
familyName: 'Literata',
|
||||
originalFileName: 'Literata-Regular.ttf',
|
||||
format: 'ttf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
fileSize: 50000,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
familyName: 'Literata',
|
||||
originalFileName: 'Literata-Bold.ttf',
|
||||
format: 'ttf',
|
||||
weight: 700,
|
||||
style: 'normal',
|
||||
fileSize: 55000,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
familyName: 'Georgia Pro',
|
||||
originalFileName: 'GeorgiaPro.woff2',
|
||||
format: 'woff2',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
fileSize: 30000,
|
||||
createdAt: '2026-01-02T00:00:00.000Z',
|
||||
},
|
||||
]
|
||||
|
||||
const fetchMock = vi.fn<(...args: unknown[]) => unknown>()
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: (...args: unknown[]) => fetchMock(...args),
|
||||
}))
|
||||
|
||||
function setupFetchMock(fonts: UserFont[]) {
|
||||
fetchMock.mockImplementation((url: unknown) => {
|
||||
if (url === '/api/v1/fonts') {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(fonts) })
|
||||
}
|
||||
if (typeof url === 'string' && /\/api\/v1\/fonts\/(\d+)\/file/.test(url)) {
|
||||
const id = Number(url.match(/\/api\/v1\/fonts\/(\d+)\/file/)![1])
|
||||
return Promise.resolve({ ok: true, blob: () => Promise.resolve(new Blob([`font-data-${id}`])) })
|
||||
}
|
||||
return Promise.resolve({ ok: false })
|
||||
})
|
||||
}
|
||||
|
||||
describe('useCustomFonts', () => {
|
||||
let composable: ReturnType<typeof useCustomFonts>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
let blobCounter = 0
|
||||
URL.createObjectURL = vi.fn<(obj: Blob | MediaSource) => string>().mockImplementation(() => `blob:test/font-${++blobCounter}`)
|
||||
URL.revokeObjectURL = vi.fn<(url: string) => void>()
|
||||
composable = useCustomFonts()
|
||||
})
|
||||
|
||||
describe('fetchFonts', () => {
|
||||
it('fetches fonts and pre-caches blob URLs for all font files', async () => {
|
||||
setupFetchMock(mockFonts)
|
||||
|
||||
await composable.fetchFonts()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/fonts')
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/fonts/1/file')
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/fonts/2/file')
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/fonts/3/file')
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(3)
|
||||
expect(composable.fonts.value).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('handles failed list fetch gracefully', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false })
|
||||
|
||||
await composable.fetchFonts()
|
||||
|
||||
expect(composable.fonts.value).toHaveLength(0)
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('continues when individual font file fetch fails', async () => {
|
||||
fetchMock.mockImplementation((url: unknown) => {
|
||||
if (url === '/api/v1/fonts') {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockFonts.slice(0, 1)) })
|
||||
}
|
||||
return Promise.resolve({ ok: false })
|
||||
})
|
||||
|
||||
await composable.fetchFonts()
|
||||
|
||||
expect(composable.fonts.value).toHaveLength(1)
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets loading state correctly', async () => {
|
||||
let resolveList!: () => void
|
||||
fetchMock.mockReturnValue(
|
||||
new Promise<{ ok: boolean; json: () => Promise<UserFont[]> }>((resolve) => {
|
||||
resolveList = () => resolve({ ok: true, json: () => Promise.resolve([]) })
|
||||
}),
|
||||
)
|
||||
|
||||
const promise = composable.fetchFonts()
|
||||
expect(composable.loading.value).toBe(true)
|
||||
|
||||
resolveList()
|
||||
await promise
|
||||
|
||||
expect(composable.loading.value).toBe(false)
|
||||
})
|
||||
it('revokes blob URLs for fonts removed between fetches', async () => {
|
||||
setupFetchMock(mockFonts)
|
||||
await composable.fetchFonts()
|
||||
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Second fetch returns only font 3 — fonts 1 and 2 have been removed on the server
|
||||
fetchMock.mockImplementation((url: unknown) => {
|
||||
if (url === '/api/v1/fonts') {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve([mockFonts[2]]) })
|
||||
}
|
||||
if (typeof url === 'string' && /\/api\/v1\/fonts\/(\d+)\/file/.test(url)) {
|
||||
return Promise.resolve({ ok: true, blob: () => Promise.resolve(new Blob(['font'])) })
|
||||
}
|
||||
return Promise.resolve({ ok: false })
|
||||
})
|
||||
|
||||
await composable.fetchFonts()
|
||||
|
||||
// Blob URLs for fonts 1 and 2 are revoked for removal; font 3's URL is also
|
||||
// revoked and replaced because cacheFontBlobUrl always revokes before re-caching.
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(3)
|
||||
expect(composable.fonts.value).toHaveLength(1)
|
||||
expect(composable.fonts.value[0]!.id).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadFont', () => {
|
||||
it('uploads a font, adds it to the list, and caches its blob URL', async () => {
|
||||
const uploadResult: FontUploadResult = {
|
||||
font: mockFonts[0]!,
|
||||
suggestedFamilyName: 'Literata',
|
||||
suggestedWeight: 400,
|
||||
suggestedStyle: 'normal',
|
||||
}
|
||||
|
||||
fetchMock.mockImplementation((url: unknown) => {
|
||||
if (url === '/api/v1/fonts/upload') {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(uploadResult) })
|
||||
}
|
||||
if (url === '/api/v1/fonts/1/file') {
|
||||
return Promise.resolve({ ok: true, blob: () => Promise.resolve(new Blob(['font'])) })
|
||||
}
|
||||
return Promise.resolve({ ok: false })
|
||||
})
|
||||
|
||||
const file = new File(['font data'], 'Literata-Regular.ttf')
|
||||
const result = await composable.uploadFont(file)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/fonts/upload', expect.objectContaining({ method: 'POST' }))
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/fonts/1/file')
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual(uploadResult)
|
||||
expect(composable.fonts.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('throws on upload failure', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ message: 'Too large' }),
|
||||
})
|
||||
|
||||
const file = new File(['font data'], 'big.ttf')
|
||||
await expect(composable.uploadFont(file)).rejects.toThrow('Too large')
|
||||
})
|
||||
|
||||
it('sets uploading state correctly', async () => {
|
||||
let resolvePromise!: () => void
|
||||
fetchMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolvePromise = () =>
|
||||
resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ font: mockFonts[0], suggestedFamilyName: 'Test', suggestedWeight: 400, suggestedStyle: 'normal' }),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const promise = composable.uploadFont(new File(['data'], 'test.ttf'))
|
||||
expect(composable.uploading.value).toBe(true)
|
||||
|
||||
resolvePromise()
|
||||
await promise
|
||||
|
||||
expect(composable.uploading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteFont', () => {
|
||||
it('removes a font from the list and revokes its blob URL on success', async () => {
|
||||
setupFetchMock(mockFonts)
|
||||
await composable.fetchFonts()
|
||||
vi.clearAllMocks()
|
||||
|
||||
fetchMock.mockResolvedValue({ ok: true })
|
||||
|
||||
const result = await composable.deleteFont(1)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledOnce()
|
||||
expect(composable.fonts.value).toHaveLength(2)
|
||||
expect(composable.fonts.value.find((f) => f.id === 1)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns false on failure without modifying list or revoking blob URL', async () => {
|
||||
composable.fonts.value = [...mockFonts]
|
||||
fetchMock.mockResolvedValue({ ok: false })
|
||||
|
||||
const result = await composable.deleteFont(1)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
|
||||
expect(composable.fonts.value).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateFont', () => {
|
||||
it('updates a font in the list', async () => {
|
||||
composable.fonts.value = [...mockFonts]
|
||||
const updated = { ...mockFonts[0]!, familyName: 'Updated' }
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(updated),
|
||||
})
|
||||
|
||||
const result = await composable.updateFont(1, { familyName: 'Updated' })
|
||||
|
||||
expect(result).toEqual(updated)
|
||||
expect(composable.fonts.value.find((f) => f.id === 1)?.familyName).toBe('Updated')
|
||||
})
|
||||
|
||||
it('returns null on failure', async () => {
|
||||
composable.fonts.value = [...mockFonts]
|
||||
fetchMock.mockResolvedValue({ ok: false })
|
||||
|
||||
const result = await composable.updateFont(1, { familyName: 'Fail' })
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('families', () => {
|
||||
it('groups fonts by family name', () => {
|
||||
composable.fonts.value = [...mockFonts]
|
||||
|
||||
const families = composable.families.value
|
||||
|
||||
expect(families).toHaveLength(2)
|
||||
expect(families[0]!.name).toBe('Literata')
|
||||
expect(families[0]!.cssFamilyName).toBe(fontCssFamilyGroupName('Literata'))
|
||||
expect(families[0]!.variants).toHaveLength(2)
|
||||
expect(families[1]!.name).toBe('Georgia Pro')
|
||||
expect(families[1]!.cssFamilyName).toBe(fontCssFamilyGroupName('Georgia Pro'))
|
||||
expect(families[1]!.variants).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns empty array when no fonts', () => {
|
||||
expect(composable.families.value).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateFontFaceCSS', () => {
|
||||
it('uses blob URLs when available after fetchFonts', async () => {
|
||||
setupFetchMock(mockFonts)
|
||||
await composable.fetchFonts()
|
||||
|
||||
const css = composable.generateFontFaceCSS()
|
||||
|
||||
expect(css).toContain('@font-face')
|
||||
expect(css).toContain(`"${fontCssFamilyGroupName('Literata')}"`)
|
||||
expect(css).toContain(`"${fontCssFamilyGroupName('Georgia Pro')}"`)
|
||||
// Blob URLs should be used instead of API URLs
|
||||
expect(css).toContain('blob:test/')
|
||||
expect(css).not.toContain('/api/v1/fonts/1/file')
|
||||
expect(css).toContain('font-weight: 400')
|
||||
expect(css).toContain('font-weight: 700')
|
||||
expect(css).toContain('format("truetype")')
|
||||
expect(css).toContain('format("woff2")')
|
||||
})
|
||||
|
||||
it('falls back to API URL when blob cache is missing', () => {
|
||||
composable.fonts.value = [...mockFonts]
|
||||
|
||||
const css = composable.generateFontFaceCSS()
|
||||
|
||||
expect(css).toContain('/api/v1/fonts/1/file')
|
||||
expect(css).toContain('/api/v1/fonts/2/file')
|
||||
expect(css).toContain('/api/v1/fonts/3/file')
|
||||
expect(css).not.toContain('blob:')
|
||||
})
|
||||
|
||||
it('generates @font-face rules using family-level CSS names shared across variants', () => {
|
||||
composable.fonts.value = [...mockFonts]
|
||||
const css = composable.generateFontFaceCSS()
|
||||
|
||||
expect(css).toContain('@font-face')
|
||||
expect(css).toContain(`"${fontCssFamilyGroupName('Literata')}"`)
|
||||
expect(css).toContain(`"${fontCssFamilyGroupName('Georgia Pro')}"`)
|
||||
expect(css).not.toContain('"__userfont_1"')
|
||||
expect(css).not.toContain('"__userfont_2"')
|
||||
})
|
||||
|
||||
it('returns empty string when no fonts', () => {
|
||||
expect(composable.generateFontFaceCSS()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isFontFamilySelected', () => {
|
||||
it('returns true when currentFontFamily matches the family group CSS name', () => {
|
||||
composable.fonts.value = [...mockFonts]
|
||||
expect(composable.isFontFamilySelected('Literata', fontCssFamilyGroupName('Literata'))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when currentFontFamily is a different family', () => {
|
||||
composable.fonts.value = [...mockFonts]
|
||||
expect(composable.isFontFamilySelected('Literata', fontCssFamilyGroupName('Georgia Pro'))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when currentFontFamily is null', () => {
|
||||
composable.fonts.value = [...mockFonts]
|
||||
expect(composable.isFontFamilySelected('Literata', null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCssFamilyForDisplay', () => {
|
||||
it('returns the group CSS family name for a known family', () => {
|
||||
composable.fonts.value = [...mockFonts]
|
||||
expect(composable.getCssFamilyForDisplay('Literata')).toBe(fontCssFamilyGroupName('Literata'))
|
||||
expect(composable.getCssFamilyForDisplay('Georgia Pro')).toBe(fontCssFamilyGroupName('Georgia Pro'))
|
||||
})
|
||||
|
||||
it('returns null when family not found', () => {
|
||||
composable.fonts.value = [...mockFonts]
|
||||
expect(composable.getCssFamilyForDisplay('Unknown')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import { computed, getCurrentInstance, onUnmounted, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { UserFont, FontFormat, FontUploadResult } from '@bookorbit/types'
|
||||
import { FONT_FORMAT_CSS_FORMAT, fontCssFamilyGroupName } from '@bookorbit/types'
|
||||
|
||||
const ACCEPTED_EXTENSIONS = '.ttf,.otf,.woff,.woff2'
|
||||
|
||||
export interface FontFamily {
|
||||
name: string
|
||||
cssFamilyName: string
|
||||
variants: UserFont[]
|
||||
}
|
||||
|
||||
export function useCustomFonts() {
|
||||
const fonts = ref<UserFont[]>([])
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const fontBlobUrls = new Map<number, string>()
|
||||
|
||||
function revokeFontBlobUrl(fontId: number) {
|
||||
const url = fontBlobUrls.get(fontId)
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url)
|
||||
fontBlobUrls.delete(fontId)
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheFontBlobUrl(fontId: number): Promise<void> {
|
||||
try {
|
||||
const res = await api(`/api/v1/fonts/${fontId}/file`)
|
||||
if (!res.ok) return
|
||||
const blob = await res.blob()
|
||||
revokeFontBlobUrl(fontId)
|
||||
fontBlobUrls.set(fontId, URL.createObjectURL(blob))
|
||||
} catch {
|
||||
// Font will fall back to API URL in CSS
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFonts() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api('/api/v1/fonts')
|
||||
if (res.ok) {
|
||||
const newFonts: UserFont[] = await res.json()
|
||||
const newFontIds = new Set(newFonts.map((f) => f.id))
|
||||
for (const id of fontBlobUrls.keys()) {
|
||||
if (!newFontIds.has(id)) revokeFontBlobUrl(id)
|
||||
}
|
||||
fonts.value = newFonts
|
||||
await Promise.allSettled(fonts.value.map((f) => cacheFontBlobUrl(f.id)))
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFont(file: File): Promise<FontUploadResult | null> {
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await api('/api/v1/fonts/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: 'Upload failed' }))
|
||||
throw new Error(err.message || 'Upload failed')
|
||||
}
|
||||
const result: FontUploadResult = await res.json()
|
||||
fonts.value = [...fonts.value, result.font]
|
||||
await cacheFontBlobUrl(result.font.id)
|
||||
return result
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateFont(fontId: number, data: { familyName?: string; weight?: number; style?: 'normal' | 'italic' }): Promise<UserFont | null> {
|
||||
const res = await api(`/api/v1/fonts/${fontId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const updated: UserFont = await res.json()
|
||||
fonts.value = fonts.value.map((f) => (f.id === fontId ? updated : f))
|
||||
return updated
|
||||
}
|
||||
|
||||
async function deleteFont(fontId: number): Promise<boolean> {
|
||||
const res = await api(`/api/v1/fonts/${fontId}`, { method: 'DELETE' })
|
||||
if (!res.ok) return false
|
||||
revokeFontBlobUrl(fontId)
|
||||
fonts.value = fonts.value.filter((f) => f.id !== fontId)
|
||||
return true
|
||||
}
|
||||
|
||||
const families = computed<FontFamily[]>(() => {
|
||||
const map = new Map<string, UserFont[]>()
|
||||
for (const font of fonts.value) {
|
||||
const existing = map.get(font.familyName)
|
||||
if (existing) {
|
||||
existing.push(font)
|
||||
} else {
|
||||
map.set(font.familyName, [font])
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries()).map(([name, variants]) => ({
|
||||
name,
|
||||
cssFamilyName: fontCssFamilyGroupName(name),
|
||||
variants,
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* Generates @font-face CSS where all variants of a family share one CSS
|
||||
* font-family name (differentiated by font-weight/font-style). This lets the
|
||||
* browser automatically pick bold/italic variants. Uses pre-fetched blob URLs
|
||||
* so the iframe can load fonts without needing auth headers in CSS url().
|
||||
*/
|
||||
function generateFontFaceCSS(): string {
|
||||
if (fonts.value.length === 0) return ''
|
||||
return fonts.value
|
||||
.map((f) => {
|
||||
const cssFormat = FONT_FORMAT_CSS_FORMAT[f.format as FontFormat]
|
||||
const src = fontBlobUrls.get(f.id) ?? `/api/v1/fonts/${f.id}/file`
|
||||
return `@font-face {
|
||||
font-family: "${fontCssFamilyGroupName(f.familyName)}";
|
||||
src: url("${src}") format("${cssFormat}");
|
||||
font-weight: ${f.weight};
|
||||
font-style: ${f.style};
|
||||
font-display: swap;
|
||||
}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function isFontFamilySelected(familyName: string, currentFontFamily: string | null): boolean {
|
||||
if (!currentFontFamily) return false
|
||||
return currentFontFamily === fontCssFamilyGroupName(familyName)
|
||||
}
|
||||
|
||||
function getCssFamilyForDisplay(familyName: string): string | null {
|
||||
const exists = fonts.value.some((f) => f.familyName === familyName)
|
||||
return exists ? fontCssFamilyGroupName(familyName) : null
|
||||
}
|
||||
|
||||
if (getCurrentInstance()) {
|
||||
onUnmounted(() => {
|
||||
for (const url of fontBlobUrls.values()) URL.revokeObjectURL(url)
|
||||
fontBlobUrls.clear()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
fonts,
|
||||
families,
|
||||
loading,
|
||||
uploading,
|
||||
fetchFonts,
|
||||
uploadFont,
|
||||
updateFont,
|
||||
deleteFont,
|
||||
generateFontFaceCSS,
|
||||
isFontFamilySelected,
|
||||
getCssFamilyForDisplay,
|
||||
ACCEPTED_EXTENSIONS,
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,12 @@ export function useFoliate(
|
||||
view.addEventListener('load', (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail
|
||||
loading.value = false
|
||||
if (onApplyStyles) onApplyStyles(view.renderer)
|
||||
// The paginator's internal #view reference is updated in a microtask after the
|
||||
// 'load' event fires. Deferring to a macrotask ensures setStyles targets the
|
||||
// new chapter document, not the previous one.
|
||||
setTimeout(() => {
|
||||
if (onApplyStyles) onApplyStyles(view.renderer)
|
||||
}, 0)
|
||||
annotations.reAddAll(view)
|
||||
if (detail?.doc) input.attachIframeClicks(detail.doc)
|
||||
})
|
||||
|
||||
@@ -47,6 +47,8 @@ export function useReaderState() {
|
||||
const themeName = ref(defaults.themeName)
|
||||
const flow = ref<'paginated' | 'scrolled'>(defaults.flow)
|
||||
|
||||
const fontFaceCSS = ref('')
|
||||
|
||||
const state = computed<ReaderState>(() => ({
|
||||
fontSize: fontSize.value,
|
||||
lineHeight: lineHeight.value,
|
||||
@@ -87,7 +89,10 @@ export function useReaderState() {
|
||||
}`
|
||||
: ''
|
||||
|
||||
const fontFaceBlock = fontFaceCSS.value
|
||||
|
||||
return `
|
||||
${fontFaceBlock}
|
||||
@namespace epub "http://www.idpf.org/2007/ops";
|
||||
@media print {
|
||||
html {
|
||||
@@ -261,6 +266,10 @@ export function useReaderState() {
|
||||
flow.value = v
|
||||
}
|
||||
|
||||
function setFontFaceCSS(css: string) {
|
||||
fontFaceCSS.value = css
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
fontSize,
|
||||
@@ -292,5 +301,6 @@ export function useReaderState() {
|
||||
setIsDark,
|
||||
setThemeName,
|
||||
setFlow,
|
||||
setFontFaceCSS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface ReaderBuiltInFontOption {
|
||||
value: string | null
|
||||
label: string
|
||||
}
|
||||
|
||||
export const BUILTIN_READER_FONT_OPTIONS: ReaderBuiltInFontOption[] = [
|
||||
{ value: null, label: 'Book default' },
|
||||
{ value: 'serif', label: 'Serif' },
|
||||
{ value: 'sans-serif', label: 'Sans-serif' },
|
||||
{ value: 'monospace', label: 'Monospace' },
|
||||
{ value: 'Georgia, serif', label: 'Georgia' },
|
||||
{ value: 'Palatino Linotype, Palatino, Book Antiqua, serif', label: 'Palatino' },
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
const FONT_METADATA_TOKENS = /\b(?:variablefont|wght|wdth|ital|opsz|slnt)\b/gi
|
||||
|
||||
export function formatFontFamilyLabel(name: string): string {
|
||||
const normalized = name.replace(/_/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
if (!normalized) return name
|
||||
|
||||
const stripped = normalized.replace(FONT_METADATA_TOKENS, '').replace(/\s+/g, ' ').trim()
|
||||
return stripped || normalized
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import type { EpubReaderSettings } from '@bookorbit/types'
|
||||
import { fontCssFamilyGroupName } 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 { Check } from 'lucide-vue-next'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
@@ -18,16 +22,54 @@ const props = withDefaults(
|
||||
|
||||
const { effective, load, update, reset } = useReaderDefaultSettings<EpubReaderSettings>('epub')
|
||||
|
||||
onMounted(load)
|
||||
const customFonts = useCustomFonts()
|
||||
|
||||
const fontFamilies: { id: string | null; label: string }[] = [
|
||||
{ id: null, label: "Book's font" },
|
||||
{ id: 'serif', label: 'Serif' },
|
||||
{ id: 'sans-serif', label: 'Sans-serif' },
|
||||
{ id: 'monospace', label: 'Monospace' },
|
||||
{ id: 'Georgia, serif', label: 'Georgia' },
|
||||
{ id: 'Palatino Linotype, Palatino, Book Antiqua, serif', label: 'Palatino' },
|
||||
]
|
||||
const customFontOptions = computed(() =>
|
||||
customFonts.families.value.map((f) => ({
|
||||
id: fontCssFamilyGroupName(f.name),
|
||||
label: formatFontFamilyLabel(f.name),
|
||||
})),
|
||||
)
|
||||
|
||||
const previewStyleEl = ref<HTMLStyleElement | null>(null)
|
||||
|
||||
function injectPreviewStyles(css: string) {
|
||||
if (previewStyleEl.value) {
|
||||
previewStyleEl.value.textContent = css
|
||||
return
|
||||
}
|
||||
if (!css) return
|
||||
const el = document.createElement('style')
|
||||
el.setAttribute('data-ebook-settings-font-preview', '')
|
||||
el.textContent = css
|
||||
document.head.appendChild(el)
|
||||
previewStyleEl.value = el
|
||||
}
|
||||
|
||||
watch(
|
||||
() => customFonts.fonts.value,
|
||||
() => {
|
||||
injectPreviewStyles(customFonts.generateFontFaceCSS())
|
||||
|
||||
// If the saved font family is a custom font that no longer exists, reset it.
|
||||
const saved = effective.value.fontFamily
|
||||
if (saved?.startsWith('__userfont_')) {
|
||||
const stillExists = customFonts.families.value.some((f) => fontCssFamilyGroupName(f.name) === saved)
|
||||
if (!stillExists) update({ fontFamily: null })
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
await customFonts.fetchFonts()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
previewStyleEl.value?.remove()
|
||||
previewStyleEl.value = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -210,7 +252,12 @@ const fontFamilies: { id: string | null; label: string }[] = [
|
||||
:value="effective.fontFamily ?? ''"
|
||||
@change="update({ fontFamily: ($event.target as HTMLSelectElement).value || null })"
|
||||
>
|
||||
<option v-for="f in fontFamilies" :key="String(f.id)" :value="f.id ?? ''">{{ f.label }}</option>
|
||||
<optgroup label="Built-in fonts">
|
||||
<option v-for="f in BUILTIN_READER_FONT_OPTIONS" :key="String(f.value)" :value="f.value ?? ''">{{ f.label }}</option>
|
||||
</optgroup>
|
||||
<optgroup v-if="customFontOptions.length > 0" label="Your Fonts">
|
||||
<option v-for="f in customFontOptions" :key="f.id" :value="f.id">{{ f.label }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Pencil, Trash2, Upload, X, Check, ChevronDown, ChevronRight } from 'lucide-vue-next'
|
||||
import { MAX_FONTS_PER_USER } from '@bookorbit/types'
|
||||
import type { UserFont } from '@bookorbit/types'
|
||||
import { useCustomFonts } from '@/features/reader/epub/composables/useCustomFonts'
|
||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
|
||||
const WEIGHT_LABELS: Record<number, string> = {
|
||||
100: 'Thin',
|
||||
200: 'Extra Light',
|
||||
300: 'Light',
|
||||
400: 'Regular',
|
||||
500: 'Medium',
|
||||
600: 'Semi Bold',
|
||||
700: 'Bold',
|
||||
800: 'Extra Bold',
|
||||
900: 'Black',
|
||||
}
|
||||
|
||||
const customFonts = useCustomFonts()
|
||||
const { fonts, families, loading, uploading, fetchFonts, uploadFont, updateFont } = customFonts
|
||||
|
||||
const isDragging = ref(false)
|
||||
const uploadErrors = ref<string[]>([])
|
||||
const expandedFamilies = ref<Set<string>>(new Set())
|
||||
const editingFamilyName = ref<string | null>(null)
|
||||
const editingFamilyValue = ref('')
|
||||
const editingVariantId = ref<number | null>(null)
|
||||
const editingVariantWeight = ref<number>(400)
|
||||
const editingVariantStyle = ref<'normal' | 'italic'>('normal')
|
||||
|
||||
const previewStyleEl = ref<HTMLStyleElement | null>(null)
|
||||
|
||||
function injectPreviewStyles(css: string) {
|
||||
if (previewStyleEl.value) {
|
||||
previewStyleEl.value.textContent = css
|
||||
return
|
||||
}
|
||||
if (!css) return
|
||||
const el = document.createElement('style')
|
||||
el.setAttribute('data-font-settings-preview', '')
|
||||
el.textContent = css
|
||||
document.head.appendChild(el)
|
||||
previewStyleEl.value = el
|
||||
}
|
||||
|
||||
function removePreviewStyles() {
|
||||
previewStyleEl.value?.remove()
|
||||
previewStyleEl.value = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => fonts.value,
|
||||
() => {
|
||||
injectPreviewStyles(customFonts.generateFontFaceCSS())
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(fetchFonts)
|
||||
onUnmounted(removePreviewStyles)
|
||||
|
||||
function toggleFamily(name: string) {
|
||||
if (expandedFamilies.value.has(name)) {
|
||||
expandedFamilies.value.delete(name)
|
||||
} else {
|
||||
expandedFamilies.value.add(name)
|
||||
}
|
||||
}
|
||||
|
||||
function startEditFamily(familyName: string) {
|
||||
cancelEdits()
|
||||
editingFamilyName.value = familyName
|
||||
editingFamilyValue.value = familyName
|
||||
}
|
||||
|
||||
function cancelEdits() {
|
||||
editingFamilyName.value = null
|
||||
editingVariantId.value = null
|
||||
}
|
||||
|
||||
async function saveEditFamily() {
|
||||
const oldName = editingFamilyName.value
|
||||
const newName = editingFamilyValue.value.trim()
|
||||
editingFamilyName.value = null
|
||||
if (!oldName || !newName || newName === oldName) return
|
||||
|
||||
const variantsToUpdate = fonts.value.filter((f) => f.familyName === oldName)
|
||||
const results = await Promise.allSettled(variantsToUpdate.map((f) => updateFont(f.id, { familyName: newName })))
|
||||
const failed = results.some((r) => r.status === 'rejected' || r.value === null)
|
||||
if (failed) {
|
||||
toast.error('Failed to rename font family')
|
||||
await fetchFonts()
|
||||
} else {
|
||||
toast.success(`Renamed to "${newName}"`)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFamilyEditKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') saveEditFamily()
|
||||
else if (event.key === 'Escape') cancelEdits()
|
||||
}
|
||||
|
||||
function startEditVariant(font: UserFont) {
|
||||
cancelEdits()
|
||||
editingVariantId.value = font.id
|
||||
editingVariantWeight.value = font.weight
|
||||
editingVariantStyle.value = font.style
|
||||
}
|
||||
|
||||
async function saveEditVariant() {
|
||||
const id = editingVariantId.value
|
||||
editingVariantId.value = null
|
||||
if (!id) return
|
||||
|
||||
const result = await updateFont(id, {
|
||||
weight: editingVariantWeight.value,
|
||||
style: editingVariantStyle.value,
|
||||
})
|
||||
if (!result) {
|
||||
toast.error('Failed to update font variant')
|
||||
await fetchFonts()
|
||||
}
|
||||
}
|
||||
|
||||
function handleVariantEditKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') saveEditVariant()
|
||||
else if (event.key === 'Escape') cancelEdits()
|
||||
}
|
||||
|
||||
async function deleteVariant(font: UserFont) {
|
||||
const snapshot = [...fonts.value]
|
||||
fonts.value = fonts.value.filter((f) => f.id !== font.id)
|
||||
|
||||
try {
|
||||
const success = await customFonts.deleteFont(font.id)
|
||||
if (!success) {
|
||||
fonts.value = snapshot
|
||||
toast.error('Failed to delete font')
|
||||
}
|
||||
} catch {
|
||||
fonts.value = snapshot
|
||||
toast.error('Failed to delete font')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFamily(familyName: string) {
|
||||
const variants = families.value.find((f) => f.name === familyName)?.variants ?? []
|
||||
if (variants.length === 0) return
|
||||
|
||||
fonts.value = fonts.value.filter((f) => f.familyName !== familyName)
|
||||
|
||||
const results = await Promise.allSettled(variants.map((v) => customFonts.deleteFont(v.id)))
|
||||
const failed = results.some((r) => r.status === 'rejected' || (r.status === 'fulfilled' && !r.value))
|
||||
if (failed) {
|
||||
await fetchFonts()
|
||||
toast.error('Failed to delete font family')
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
isDragging.value = true
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
isDragging.value = false
|
||||
const files = Array.from(event.dataTransfer?.files ?? [])
|
||||
processFiles(files)
|
||||
}
|
||||
|
||||
function handleFileInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = Array.from(input.files ?? [])
|
||||
input.value = ''
|
||||
processFiles(files)
|
||||
}
|
||||
|
||||
async function processFiles(files: File[]) {
|
||||
uploadErrors.value = []
|
||||
for (const file of files) {
|
||||
try {
|
||||
await uploadFont(file)
|
||||
toast.success(`"${file.name}" added`)
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Upload failed'
|
||||
uploadErrors.value = [...uploadErrors.value, `${file.name}: ${message}`]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function dismissError(index: number) {
|
||||
uploadErrors.value = uploadErrors.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SettingsPageHeader title="Fonts" subtitle="Upload custom fonts for use in the eBook reader." />
|
||||
|
||||
<!-- Upload zone -->
|
||||
<div class="mb-6">
|
||||
<p class="settings-group-label">Upload Fonts</p>
|
||||
<label
|
||||
class="relative flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed px-6 py-8 text-center cursor-pointer transition-colors"
|
||||
:class="isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground/40 hover:bg-muted/30'"
|
||||
@dragover="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full transition-colors"
|
||||
:class="isDragging ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'"
|
||||
>
|
||||
<Upload :size="20" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
<span v-if="uploading">Uploading...</span>
|
||||
<span v-else-if="isDragging">Drop files here</span>
|
||||
<span v-else>Drag font files here, or <span class="text-primary underline underline-offset-2">browse</span></span>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">TTF, OTF, WOFF, WOFF2 - max 10 MB each</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".ttf,.otf,.woff,.woff2"
|
||||
multiple
|
||||
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||
:disabled="uploading"
|
||||
@change="handleFileInput"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-if="uploadErrors.length > 0" class="mt-2 space-y-1">
|
||||
<div
|
||||
v-for="(err, i) in uploadErrors"
|
||||
:key="i"
|
||||
class="flex items-start justify-between gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
<span>{{ err }}</span>
|
||||
<button class="mt-0.5 shrink-0 hover:opacity-70" @click="dismissError(i)">
|
||||
<X :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Font list -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="settings-group-label mb-0">Your Fonts</p>
|
||||
<span class="text-xs text-muted-foreground">{{ fonts.length }} / {{ MAX_FONTS_PER_USER }} used</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-10 text-muted-foreground">
|
||||
<span class="text-sm">Loading...</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="families.length === 0"
|
||||
class="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border py-12 text-center"
|
||||
>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Upload :size="22" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">No fonts uploaded yet</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">Drag a font file above to get started.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="border border-border rounded-lg overflow-hidden divide-y divide-border">
|
||||
<div v-for="family in families" :key="family.name" class="bg-card">
|
||||
<!-- Family header row -->
|
||||
<div class="flex items-center gap-3 px-4 py-3">
|
||||
<button class="flex items-center gap-2 flex-1 min-w-0 text-left" @click="toggleFamily(family.name)">
|
||||
<component :is="expandedFamilies.has(family.name) ? ChevronDown : ChevronRight" :size="15" class="shrink-0 text-muted-foreground" />
|
||||
<template v-if="editingFamilyName === family.name">
|
||||
<!-- inline edit input — stop click propagation so toggle doesn't fire -->
|
||||
</template>
|
||||
<span
|
||||
v-if="editingFamilyName !== family.name"
|
||||
class="text-sm font-medium truncate"
|
||||
:style="{ fontFamily: `'${family.cssFamilyName}', sans-serif` }"
|
||||
>
|
||||
{{ family.name }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<input
|
||||
v-if="editingFamilyName === family.name"
|
||||
v-model="editingFamilyValue"
|
||||
class="flex-1 min-w-0 rounded-md border border-primary bg-background px-2 py-0.5 text-sm font-medium focus:outline-none"
|
||||
autofocus
|
||||
@blur="saveEditFamily"
|
||||
@keydown="handleFamilyEditKeydown"
|
||||
@click.stop
|
||||
/>
|
||||
|
||||
<span class="shrink-0 text-xs text-muted-foreground"
|
||||
>{{ family.variants.length }} {{ family.variants.length === 1 ? 'file' : 'files' }}</span
|
||||
>
|
||||
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
v-if="editingFamilyName !== family.name"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title="Rename family"
|
||||
@click.stop="startEditFamily(family.name)"
|
||||
>
|
||||
<Pencil :size="13" />
|
||||
</button>
|
||||
<button
|
||||
v-if="editingFamilyName === family.name"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-md text-primary transition-colors hover:bg-primary/10"
|
||||
title="Save"
|
||||
@click.stop="saveEditFamily"
|
||||
>
|
||||
<Check :size="13" />
|
||||
</button>
|
||||
<button
|
||||
class="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
title="Delete family"
|
||||
@click.stop="deleteFamily(family.name)"
|
||||
>
|
||||
<Trash2 :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview line -->
|
||||
<div
|
||||
class="px-4 pb-3 -mt-1 text-sm text-muted-foreground/90 truncate"
|
||||
:style="{ fontFamily: `'${family.cssFamilyName}', sans-serif`, fontSize: '16px' }"
|
||||
>
|
||||
The quick brown fox jumps over the lazy dog
|
||||
</div>
|
||||
|
||||
<!-- Variant rows (expanded) -->
|
||||
<div v-if="expandedFamilies.has(family.name)" class="border-t border-border/60 divide-y divide-border/60 bg-muted/20">
|
||||
<div v-for="variant in family.variants" :key="variant.id" class="flex items-center gap-3 px-5 py-2.5">
|
||||
<div class="flex-1 min-w-0">
|
||||
<template v-if="editingVariantId === variant.id">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
v-model="editingVariantWeight"
|
||||
class="h-7 rounded border border-border bg-background px-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
@keydown="handleVariantEditKeydown"
|
||||
>
|
||||
<option v-for="(label, w) in WEIGHT_LABELS" :key="w" :value="Number(w)">{{ label }} ({{ w }})</option>
|
||||
</select>
|
||||
<select
|
||||
v-model="editingVariantStyle"
|
||||
class="h-7 rounded border border-border bg-background px-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
@keydown="handleVariantEditKeydown"
|
||||
>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="italic">Italic</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-xs text-foreground">
|
||||
{{ WEIGHT_LABELS[variant.weight] ?? variant.weight }} {{ variant.style === 'italic' ? '· Italic' : '' }}
|
||||
</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>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
v-if="editingVariantId !== variant.id"
|
||||
class="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title="Edit weight/style"
|
||||
@click="startEditVariant(variant)"
|
||||
>
|
||||
<Pencil :size="12" />
|
||||
</button>
|
||||
<button
|
||||
v-if="editingVariantId === variant.id"
|
||||
class="flex h-6 w-6 items-center justify-center rounded text-primary transition-colors hover:bg-primary/10"
|
||||
title="Save"
|
||||
@click="saveEditVariant"
|
||||
>
|
||||
<Check :size="12" />
|
||||
</button>
|
||||
<button
|
||||
v-if="editingVariantId === variant.id"
|
||||
class="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted"
|
||||
title="Cancel"
|
||||
@click="cancelEdits"
|
||||
>
|
||||
<X :size="12" />
|
||||
</button>
|
||||
<button
|
||||
v-if="editingVariantId !== variant.id"
|
||||
class="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
title="Delete variant"
|
||||
@click="deleteVariant(variant)"
|
||||
>
|
||||
<Trash2 :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,6 +7,7 @@ import EbookSettings from './EbookSettings.vue'
|
||||
import PdfSettings from './PdfSettings.vue'
|
||||
import ComicsSettings from './ComicsSettings.vue'
|
||||
import AudioSettings from './AudioSettings.vue'
|
||||
import FontsSettings from './FontsSettings.vue'
|
||||
import { READER_TAB_LABELS, READER_TABS, normalizeReaderTab, type ReaderTab as Tab } from './lib/reader-tabs'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -59,4 +60,5 @@ function selectTab(tab: Tab) {
|
||||
<PdfSettings v-else-if="activeTab === 'pdf'" embedded />
|
||||
<ComicsSettings v-else-if="activeTab === 'comics'" embedded />
|
||||
<AudioSettings v-else-if="activeTab === 'audio'" embedded />
|
||||
<FontsSettings v-else-if="activeTab === 'fonts'" />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import FontsSettings from '../FontsSettings.vue'
|
||||
import type { UserFont } from '@bookorbit/types'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: { success: vi.fn<() => void>(), error: vi.fn<() => void>() },
|
||||
}))
|
||||
|
||||
vi.mock('@/features/reader/epub/composables/useCustomFonts', () => ({
|
||||
useCustomFonts: vi.fn<() => unknown>(),
|
||||
}))
|
||||
|
||||
vi.mock('../SettingsPageHeader.vue', () => ({
|
||||
default: { template: '<div />' },
|
||||
}))
|
||||
|
||||
const mockFont = (id: number, familyName: string, weight = 400, style: 'normal' | 'italic' = 'normal'): UserFont => ({
|
||||
id,
|
||||
familyName,
|
||||
originalFileName: `${familyName}-${weight}.ttf`,
|
||||
format: 'ttf',
|
||||
weight,
|
||||
style,
|
||||
fileSize: 50000,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
|
||||
const literataFonts = [mockFont(1, 'Literata', 400), mockFont(2, 'Literata', 700)]
|
||||
const georgiaPro = mockFont(3, 'Georgia Pro')
|
||||
|
||||
function makeComposable(initialFonts: UserFont[] = []) {
|
||||
const fonts = ref<UserFont[]>(initialFonts)
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
|
||||
const families = computed(() => {
|
||||
const map = new Map<string, UserFont[]>()
|
||||
for (const f of fonts.value) {
|
||||
const arr = map.get(f.familyName) ?? []
|
||||
arr.push(f)
|
||||
map.set(f.familyName, arr)
|
||||
}
|
||||
return Array.from(map.entries()).map(([name, variants]) => ({
|
||||
name,
|
||||
cssFamilyName: `__userfont_${name.toLowerCase().replace(/\s+/g, '_')}`,
|
||||
variants,
|
||||
}))
|
||||
})
|
||||
|
||||
return {
|
||||
fonts,
|
||||
families,
|
||||
loading,
|
||||
uploading,
|
||||
fetchFonts: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
uploadFont: vi.fn<(file: File) => Promise<unknown>>(),
|
||||
updateFont: vi.fn<(id: number, data: object) => Promise<UserFont | null>>(),
|
||||
deleteFont: vi.fn<() => Promise<boolean>>().mockResolvedValue(true),
|
||||
generateFontFaceCSS: vi.fn<() => string>().mockReturnValue(''),
|
||||
isFontFamilySelected: vi.fn<() => boolean>().mockReturnValue(false),
|
||||
getCssFamilyForDisplay: vi.fn<() => string | null>().mockReturnValue(null),
|
||||
ACCEPTED_EXTENSIONS: '.ttf,.otf,.woff,.woff2',
|
||||
}
|
||||
}
|
||||
|
||||
import { useCustomFonts } from '@/features/reader/epub/composables/useCustomFonts'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
describe('FontsSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('empty state', () => {
|
||||
it('shows empty state message when no fonts are loaded', async () => {
|
||||
vi.mocked(useCustomFonts).mockReturnValue(makeComposable() as never)
|
||||
const wrapper = mount(FontsSettings, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('No fonts uploaded yet')
|
||||
})
|
||||
|
||||
it('shows quota counter as 0 / 50', async () => {
|
||||
vi.mocked(useCustomFonts).mockReturnValue(makeComposable() as never)
|
||||
const wrapper = mount(FontsSettings, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('0 / 50 used')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with fonts', () => {
|
||||
it('renders font family names', async () => {
|
||||
vi.mocked(useCustomFonts).mockReturnValue(makeComposable([...literataFonts, georgiaPro]) as never)
|
||||
const wrapper = mount(FontsSettings, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Literata')
|
||||
expect(wrapper.text()).toContain('Georgia Pro')
|
||||
})
|
||||
|
||||
it('shows correct quota count', async () => {
|
||||
vi.mocked(useCustomFonts).mockReturnValue(makeComposable([...literataFonts, georgiaPro]) as never)
|
||||
const wrapper = mount(FontsSettings, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('3 / 50 used')
|
||||
})
|
||||
|
||||
it('shows file count per family', async () => {
|
||||
vi.mocked(useCustomFonts).mockReturnValue(makeComposable([...literataFonts, georgiaPro]) as never)
|
||||
const wrapper = mount(FontsSettings, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('2 files')
|
||||
expect(wrapper.text()).toContain('1 file')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload', () => {
|
||||
it('calls uploadFont when a file is dropped', async () => {
|
||||
const composable = makeComposable()
|
||||
composable.uploadFont.mockResolvedValue({
|
||||
font: literataFonts[0],
|
||||
suggestedFamilyName: 'Literata',
|
||||
suggestedWeight: 400,
|
||||
suggestedStyle: 'normal',
|
||||
})
|
||||
vi.mocked(useCustomFonts).mockReturnValue(composable as never)
|
||||
|
||||
const wrapper = mount(FontsSettings, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
const file = new File(['data'], 'Literata.ttf', { type: 'font/ttf' })
|
||||
const label = wrapper.find('label')
|
||||
const dt = { files: [file] }
|
||||
await label.trigger('drop', { preventDefault: vi.fn<() => void>(), dataTransfer: dt })
|
||||
await flushPromises()
|
||||
|
||||
expect(composable.uploadFont).toHaveBeenCalledWith(file)
|
||||
expect(toast.success).toHaveBeenCalledWith('"Literata.ttf" added')
|
||||
})
|
||||
|
||||
it('shows error toast when upload fails', async () => {
|
||||
const composable = makeComposable()
|
||||
composable.uploadFont.mockRejectedValue(new Error('File too large'))
|
||||
vi.mocked(useCustomFonts).mockReturnValue(composable as never)
|
||||
|
||||
const wrapper = mount(FontsSettings, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
const file = new File(['data'], 'big.ttf')
|
||||
const label = wrapper.find('label')
|
||||
await label.trigger('drop', { preventDefault: vi.fn<() => void>(), dataTransfer: { files: [file] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('big.ttf: File too large')
|
||||
})
|
||||
|
||||
it('dismisses an error when X is clicked', async () => {
|
||||
const composable = makeComposable()
|
||||
composable.uploadFont.mockRejectedValue(new Error('Oops'))
|
||||
vi.mocked(useCustomFonts).mockReturnValue(composable as never)
|
||||
|
||||
const wrapper = mount(FontsSettings, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
const file = new File(['data'], 'test.ttf')
|
||||
await wrapper.find('label').trigger('drop', { preventDefault: vi.fn<() => void>(), dataTransfer: { files: [file] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('test.ttf: Oops')
|
||||
|
||||
const dismissBtn = wrapper.findAll('button').find((b) => b.find('svg').exists() && b.classes().some((c) => c.includes('shrink-0')))
|
||||
await dismissBtn?.trigger('click')
|
||||
|
||||
expect(wrapper.text()).not.toContain('test.ttf: Oops')
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete', () => {
|
||||
it('removes font family (optimistic) and completes silently', async () => {
|
||||
const composable = makeComposable([...literataFonts, georgiaPro])
|
||||
composable.deleteFont.mockResolvedValue(true)
|
||||
vi.mocked(useCustomFonts).mockReturnValue(composable as never)
|
||||
|
||||
const wrapper = mount(FontsSettings, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Georgia Pro')
|
||||
|
||||
const allButtons = wrapper.findAll('button')
|
||||
const deleteBtns = allButtons.filter((b) => b.attributes('title') === 'Delete family')
|
||||
await deleteBtns[1]?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(composable.deleteFont).toHaveBeenCalledWith(georgiaPro.id)
|
||||
expect(composable.fonts.value.some((f) => f.familyName === 'Georgia Pro')).toBe(false)
|
||||
})
|
||||
|
||||
it('refetches and shows error toast when family delete fails', async () => {
|
||||
const composable = makeComposable([georgiaPro])
|
||||
composable.deleteFont.mockResolvedValue(false)
|
||||
vi.mocked(useCustomFonts).mockReturnValue(composable as never)
|
||||
|
||||
const wrapper = mount(FontsSettings, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
const deleteBtn = wrapper.findAll('button').find((b) => b.attributes('title') === 'Delete family')
|
||||
await deleteBtn?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to delete font family')
|
||||
expect(composable.fetchFonts).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes variant (optimistic) and completes silently', async () => {
|
||||
const composable = makeComposable([...literataFonts])
|
||||
composable.deleteFont.mockResolvedValue(true)
|
||||
vi.mocked(useCustomFonts).mockReturnValue(composable as never)
|
||||
|
||||
const wrapper = mount(FontsSettings, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
// Expand the Literata family
|
||||
const toggleBtn = wrapper.find('button')
|
||||
await toggleBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const deleteVariantBtn = wrapper.findAll('button').find((b) => b.attributes('title') === 'Delete variant')
|
||||
await deleteVariantBtn?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(composable.deleteFont).toHaveBeenCalledWith(literataFonts[0]!.id)
|
||||
expect(composable.fonts.value.some((f) => f.id === literataFonts[0]!.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('restores variant and shows error toast when variant delete fails', async () => {
|
||||
const composable = makeComposable([...literataFonts])
|
||||
composable.deleteFont.mockResolvedValue(false)
|
||||
vi.mocked(useCustomFonts).mockReturnValue(composable as never)
|
||||
|
||||
const wrapper = mount(FontsSettings, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
const toggleBtn = wrapper.find('button')
|
||||
await toggleBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const deleteVariantBtn = wrapper.findAll('button').find((b) => b.attributes('title') === 'Delete variant')
|
||||
await deleteVariantBtn?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to delete font')
|
||||
expect(composable.fonts.value.some((f) => f.id === literataFonts[0]!.id)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inline family rename', () => {
|
||||
it('shows input when pencil is clicked and saves on Enter', async () => {
|
||||
const composable = makeComposable([georgiaPro])
|
||||
composable.updateFont.mockResolvedValue({ ...georgiaPro, familyName: 'New Name' })
|
||||
vi.mocked(useCustomFonts).mockReturnValue(composable as never)
|
||||
|
||||
const wrapper = mount(FontsSettings, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
const pencilBtn = wrapper.findAll('button').find((b) => b.attributes('title') === 'Rename family')
|
||||
await pencilBtn?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const textInput = wrapper.findAll('input').find((i) => i.attributes('type') !== 'file')
|
||||
expect(textInput?.exists()).toBe(true)
|
||||
|
||||
await textInput!.setValue('New Name')
|
||||
await textInput!.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(composable.updateFont).toHaveBeenCalledWith(3, { familyName: 'New Name' })
|
||||
expect(toast.success).toHaveBeenCalledWith('Renamed to "New Name"')
|
||||
})
|
||||
|
||||
it('cancels edit on Escape', async () => {
|
||||
const composable = makeComposable([georgiaPro])
|
||||
vi.mocked(useCustomFonts).mockReturnValue(composable as never)
|
||||
|
||||
const wrapper = mount(FontsSettings, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
const pencilBtn = wrapper.findAll('button').find((b) => b.attributes('title') === 'Rename family')
|
||||
await pencilBtn?.trigger('click')
|
||||
|
||||
const textInput = wrapper.findAll('input').find((i) => i.attributes('type') !== 'file')
|
||||
expect(textInput?.exists()).toBe(true)
|
||||
await textInput!.trigger('keydown', { key: 'Escape' })
|
||||
|
||||
const afterInputs = wrapper.findAll('input').filter((i) => i.attributes('type') !== 'file')
|
||||
expect(afterInputs.length).toBe(0)
|
||||
expect(composable.updateFont).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows error and refetches if rename partially fails', async () => {
|
||||
const composable = makeComposable([...literataFonts])
|
||||
composable.updateFont.mockResolvedValueOnce({ ...literataFonts[0]!, familyName: 'New' }).mockResolvedValueOnce(null)
|
||||
vi.mocked(useCustomFonts).mockReturnValue(composable as never)
|
||||
|
||||
const wrapper = mount(FontsSettings, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
const pencilBtn = wrapper.findAll('button').find((b) => b.attributes('title') === 'Rename family')
|
||||
await pencilBtn?.trigger('click')
|
||||
|
||||
const textInput = wrapper.findAll('input').find((i) => i.attributes('type') !== 'file')
|
||||
await textInput!.setValue('New')
|
||||
await textInput!.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to rename font family')
|
||||
expect(composable.fetchFonts).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('variant expand', () => {
|
||||
it('shows variant rows when family header is clicked', async () => {
|
||||
const composable = makeComposable([...literataFonts])
|
||||
vi.mocked(useCustomFonts).mockReturnValue(composable as never)
|
||||
|
||||
const wrapper = mount(FontsSettings, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Regular')
|
||||
|
||||
// Click the family toggle button
|
||||
const toggleBtn = wrapper.find('button')
|
||||
await toggleBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Regular')
|
||||
expect(wrapper.text()).toContain('Bold')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { READER_TABS, READER_TAB_LABELS, READER_TAB_TITLE_LABELS, normalizeReaderTab } from '../reader-tabs'
|
||||
|
||||
describe('reader-tabs', () => {
|
||||
it('includes fonts tab in READER_TABS', () => {
|
||||
expect(READER_TABS).toContain('fonts')
|
||||
})
|
||||
|
||||
it('has labels for all tabs', () => {
|
||||
for (const tab of READER_TABS) {
|
||||
expect(READER_TAB_LABELS[tab]).toBeTruthy()
|
||||
expect(READER_TAB_TITLE_LABELS[tab]).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it('fonts tab has correct labels', () => {
|
||||
expect(READER_TAB_LABELS['fonts']).toBe('Fonts')
|
||||
expect(READER_TAB_TITLE_LABELS['fonts']).toBe('Reader Fonts')
|
||||
})
|
||||
|
||||
describe('normalizeReaderTab', () => {
|
||||
it('returns valid tab unchanged', () => {
|
||||
for (const tab of READER_TABS) {
|
||||
expect(normalizeReaderTab(tab)).toBe(tab)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns ebook for unknown string', () => {
|
||||
expect(normalizeReaderTab('unknown')).toBe('ebook')
|
||||
expect(normalizeReaderTab('manage')).toBe('ebook')
|
||||
})
|
||||
|
||||
it('returns ebook for null and undefined', () => {
|
||||
expect(normalizeReaderTab(null)).toBe('ebook')
|
||||
expect(normalizeReaderTab(undefined)).toBe('ebook')
|
||||
})
|
||||
|
||||
it('returns ebook for non-string values', () => {
|
||||
expect(normalizeReaderTab(42)).toBe('ebook')
|
||||
expect(normalizeReaderTab({})).toBe('ebook')
|
||||
})
|
||||
|
||||
it('returns fonts for fonts tab', () => {
|
||||
expect(normalizeReaderTab('fonts')).toBe('fonts')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
export const READER_TABS = ['general', 'ebook', 'pdf', 'comics', 'audio'] as const
|
||||
export const READER_TABS = ['general', 'ebook', 'pdf', 'comics', 'audio', 'fonts'] as const
|
||||
|
||||
export type ReaderTab = (typeof READER_TABS)[number]
|
||||
|
||||
@@ -8,6 +8,7 @@ export const READER_TAB_LABELS: Record<ReaderTab, string> = {
|
||||
pdf: 'PDF',
|
||||
comics: 'Comics',
|
||||
audio: 'Audiobook',
|
||||
fonts: 'Fonts',
|
||||
}
|
||||
|
||||
export const READER_TAB_TITLE_LABELS: Record<ReaderTab, string> = {
|
||||
@@ -16,6 +17,7 @@ export const READER_TAB_TITLE_LABELS: Record<ReaderTab, string> = {
|
||||
pdf: 'PDF Reader',
|
||||
comics: 'Comics Reader',
|
||||
audio: 'Audiobook Player',
|
||||
fonts: 'Reader Fonts',
|
||||
}
|
||||
|
||||
export function normalizeReaderTab(value: unknown): ReaderTab {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
export type FontFormat = 'ttf' | 'otf' | 'woff' | 'woff2';
|
||||
export type FontStyle = 'normal' | 'italic';
|
||||
|
||||
export interface UserFont {
|
||||
id: number;
|
||||
familyName: string;
|
||||
originalFileName: string;
|
||||
format: FontFormat;
|
||||
weight: number;
|
||||
style: FontStyle;
|
||||
fileSize: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FontUploadResult {
|
||||
font: UserFont;
|
||||
suggestedFamilyName: string | null;
|
||||
suggestedWeight: number;
|
||||
suggestedStyle: FontStyle;
|
||||
}
|
||||
|
||||
export const FONT_FORMATS: readonly FontFormat[] = ['ttf', 'otf', 'woff', 'woff2'];
|
||||
|
||||
export const FONT_FORMAT_EXTENSIONS: Record<FontFormat, string> = {
|
||||
ttf: '.ttf',
|
||||
otf: '.otf',
|
||||
woff: '.woff',
|
||||
woff2: '.woff2',
|
||||
};
|
||||
|
||||
export const FONT_FORMAT_MIME_TYPES: Record<FontFormat, string> = {
|
||||
ttf: 'font/ttf',
|
||||
otf: 'font/otf',
|
||||
woff: 'font/woff',
|
||||
woff2: 'font/woff2',
|
||||
};
|
||||
|
||||
export const FONT_FORMAT_CSS_FORMAT: Record<FontFormat, string> = {
|
||||
ttf: 'truetype',
|
||||
otf: 'opentype',
|
||||
woff: 'woff',
|
||||
woff2: 'woff2',
|
||||
};
|
||||
|
||||
export const MAX_FONT_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
export const MAX_FONTS_PER_USER = 50;
|
||||
|
||||
export const FONT_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900] as const;
|
||||
|
||||
export function fontCssFamilyName(fontId: number): string {
|
||||
return `__userfont_${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
|
||||
* browser pick bold/italic variants automatically.
|
||||
*/
|
||||
export function fontCssFamilyGroupName(familyName: string): string {
|
||||
const safe = familyName.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'font';
|
||||
return `__userfont_${safe}`;
|
||||
}
|
||||
@@ -30,3 +30,4 @@ export * from "./notification";
|
||||
export * from "./series";
|
||||
export * from "./series-collapse";
|
||||
export * from "./entity-manager";
|
||||
export * from "./font";
|
||||
|
||||
Generated
+23
@@ -321,6 +321,9 @@ importers:
|
||||
nodemailer:
|
||||
specifier: ^8.0.7
|
||||
version: 8.0.7
|
||||
opentype.js:
|
||||
specifier: ^1.3.5
|
||||
version: 1.3.5
|
||||
passport:
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.0
|
||||
@@ -388,6 +391,9 @@ importers:
|
||||
"@types/nodemailer":
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
"@types/opentype.js":
|
||||
specifier: ^1.3.9
|
||||
version: 1.3.9
|
||||
"@types/passport-jwt":
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -4037,6 +4043,12 @@ packages:
|
||||
integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==,
|
||||
}
|
||||
|
||||
"@types/opentype.js@1.3.9":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-KOGywvDPncA4/tTWV5xKNhjpsoSSAHIx3mHOhL5l3XX+c6Xu2dQnHvGs7mRNQsQRte1EqmQ0cPQQ8Z14lkv+yw==,
|
||||
}
|
||||
|
||||
"@types/passport-jwt@4.0.1":
|
||||
resolution:
|
||||
{
|
||||
@@ -9102,6 +9114,13 @@ packages:
|
||||
}
|
||||
engines: { node: ">=18" }
|
||||
|
||||
opentype.js@1.3.5:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-thKDiELidAApOvXlncrpwDZKJCa9fXLEKM4+FoEWI+qTLDeNb+h7EkN+8a7KQODsB1GZ+Exz9KknkoPrEdXZDw==,
|
||||
}
|
||||
hasBin: true
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution:
|
||||
{
|
||||
@@ -13798,6 +13817,8 @@ snapshots:
|
||||
|
||||
"@types/normalize-package-data@2.4.4": {}
|
||||
|
||||
"@types/opentype.js@1.3.9": {}
|
||||
|
||||
"@types/passport-jwt@4.0.1":
|
||||
dependencies:
|
||||
"@types/jsonwebtoken": 9.0.10
|
||||
@@ -16833,6 +16854,8 @@ snapshots:
|
||||
is-inside-container: 1.0.0
|
||||
wsl-utils: 0.1.0
|
||||
|
||||
opentype.js@1.3.5: {}
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
|
||||
+8
-6
@@ -36,6 +36,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"7z-wasm": "^1.2.0",
|
||||
"@bookorbit/types": "workspace:*",
|
||||
"@fastify/compress": "^8.3.1",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/formbody": "^8.0.2",
|
||||
@@ -54,7 +55,6 @@
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.19",
|
||||
"@parcel/watcher": "^2.5.6",
|
||||
"@bookorbit/types": "workspace:*",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"archiver": "^7.0.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
@@ -65,14 +65,15 @@
|
||||
"fast-xml-parser": "^5.7.2",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"fastify": "^5.8.5",
|
||||
"jose": "^6.2.2",
|
||||
"jose": "^6.2.3",
|
||||
"mediainfo.js": "^0.3.7",
|
||||
"ms": "^2.1.3",
|
||||
"music-metadata": "^11.12.3",
|
||||
"mysql2": "^3.22.2",
|
||||
"mysql2": "^3.22.3",
|
||||
"nestjs-pino": "^4.6.1",
|
||||
"node-unrar-js": "^2.0.2",
|
||||
"nodemailer": "^8.0.6",
|
||||
"nodemailer": "^8.0.7",
|
||||
"opentype.js": "^1.3.4",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
@@ -92,11 +93,12 @@
|
||||
"@nestjs/schematics": "^11.1.0",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@swc/cli": "^0.8.1",
|
||||
"@swc/core": "^1.15.30",
|
||||
"@swc/core": "^1.15.32",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/opentype.js": "^1.3.9",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pg": "^8.18.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
@@ -116,7 +118,7 @@
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.59.0",
|
||||
"typescript-eslint": "^8.59.1",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ import { MigrationModule } from './modules/migration/migration.module';
|
||||
import { NotificationModule } from './modules/notification/notification.module';
|
||||
import { SeriesModule } from './modules/series/series.module';
|
||||
import { EntityManagerModule } from './modules/entity-manager/entity-manager.module';
|
||||
import { FontModule } from './modules/font/font.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -118,6 +119,7 @@ import { EntityManagerModule } from './modules/entity-manager/entity-manager.mod
|
||||
NotificationModule,
|
||||
SeriesModule,
|
||||
EntityManagerModule,
|
||||
FontModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
|
||||
@@ -915,6 +915,23 @@ CREATE TABLE "dismissed_inline_duplicate_pairs" (
|
||||
CONSTRAINT "dismissed_inline_duplicate_pairs_unique" UNIQUE("entity_type","value_a","value_b")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_fonts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"family_name" varchar(200) NOT NULL,
|
||||
"original_file_name" varchar(500) NOT NULL,
|
||||
"stored_file_name" varchar(500) NOT NULL,
|
||||
"format" varchar(10) NOT NULL,
|
||||
"weight" integer DEFAULT 400 NOT NULL,
|
||||
"style" varchar(10) DEFAULT 'normal' NOT NULL,
|
||||
"file_size" integer NOT NULL,
|
||||
"file_hash" varchar(64) NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "user_fonts_format_chk" CHECK ("user_fonts"."format" in ('ttf', 'otf', 'woff', 'woff2')),
|
||||
CONSTRAINT "user_fonts_weight_chk" CHECK ("user_fonts"."weight" >= 100 and "user_fonts"."weight" <= 900 and "user_fonts"."weight" % 100 = 0),
|
||||
CONSTRAINT "user_fonts_style_chk" CHECK ("user_fonts"."style" in ('normal', 'italic'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "magic_access_tokens" ADD CONSTRAINT "magic_access_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "magic_access_tokens" ADD CONSTRAINT "magic_access_tokens_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
@@ -1029,6 +1046,7 @@ ALTER TABLE "email_send_log" ADD CONSTRAINT "email_send_log_template_id_email_te
|
||||
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "dismissed_duplicate_pairs" ADD CONSTRAINT "dismissed_duplicate_pairs_dismissed_by_users_id_fk" FOREIGN KEY ("dismissed_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "dismissed_inline_duplicate_pairs" ADD CONSTRAINT "dismissed_inline_duplicate_pairs_dismissed_by_users_id_fk" FOREIGN KEY ("dismissed_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_fonts" ADD CONSTRAINT "user_fonts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_audit_user_id" ON "audit_log" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_audit_resource" ON "audit_log" USING btree ("resource","resource_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_audit_action" ON "audit_log" USING btree ("action");--> statement-breakpoint
|
||||
@@ -1153,4 +1171,6 @@ CREATE INDEX "notifications_created_at_idx" ON "notifications" USING btree ("cre
|
||||
CREATE INDEX "dismissed_duplicate_pairs_entity_a_idx" ON "dismissed_duplicate_pairs" USING btree ("entity_type","entity_id_a");--> statement-breakpoint
|
||||
CREATE INDEX "dismissed_duplicate_pairs_entity_b_idx" ON "dismissed_duplicate_pairs" USING btree ("entity_type","entity_id_b");--> statement-breakpoint
|
||||
CREATE INDEX "dismissed_inline_pairs_entity_a_idx" ON "dismissed_inline_duplicate_pairs" USING btree ("entity_type","value_a");--> statement-breakpoint
|
||||
CREATE INDEX "dismissed_inline_pairs_entity_b_idx" ON "dismissed_inline_duplicate_pairs" USING btree ("entity_type","value_b");
|
||||
CREATE INDEX "dismissed_inline_pairs_entity_b_idx" ON "dismissed_inline_duplicate_pairs" USING btree ("entity_type","value_b");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "uf_user_hash_uidx" ON "user_fonts" USING btree ("user_id","file_hash");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "uf_user_family_weight_style_uidx" ON "user_fonts" USING btree ("user_id","family_name","weight","style");
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "e9ca331e-b35a-4d80-a54b-28759d1e1c61",
|
||||
"id": "52d50fa2-4ef0-4f17-be4d-394264347f6f",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -8570,6 +8570,166 @@
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user_fonts": {
|
||||
"name": "user_fonts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"family_name": {
|
||||
"name": "family_name",
|
||||
"type": "varchar(200)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"original_file_name": {
|
||||
"name": "original_file_name",
|
||||
"type": "varchar(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"stored_file_name": {
|
||||
"name": "stored_file_name",
|
||||
"type": "varchar(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"format": {
|
||||
"name": "format",
|
||||
"type": "varchar(10)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"weight": {
|
||||
"name": "weight",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 400
|
||||
},
|
||||
"style": {
|
||||
"name": "style",
|
||||
"type": "varchar(10)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'normal'"
|
||||
},
|
||||
"file_size": {
|
||||
"name": "file_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"file_hash": {
|
||||
"name": "file_hash",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"uf_user_hash_uidx": {
|
||||
"name": "uf_user_hash_uidx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "file_hash",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"uf_user_family_weight_style_uidx": {
|
||||
"name": "uf_user_family_weight_style_uidx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "family_name",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "weight",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "style",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"user_fonts_user_id_users_id_fk": {
|
||||
"name": "user_fonts_user_id_users_id_fk",
|
||||
"tableFrom": "user_fonts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"user_fonts_format_chk": {
|
||||
"name": "user_fonts_format_chk",
|
||||
"value": "\"user_fonts\".\"format\" in ('ttf', 'otf', 'woff', 'woff2')"
|
||||
},
|
||||
"user_fonts_weight_chk": {
|
||||
"name": "user_fonts_weight_chk",
|
||||
"value": "\"user_fonts\".\"weight\" >= 100 and \"user_fonts\".\"weight\" <= 900 and \"user_fonts\".\"weight\" % 100 = 0"
|
||||
},
|
||||
"user_fonts_style_chk": {
|
||||
"name": "user_fonts_style_chk",
|
||||
"value": "\"user_fonts\".\"style\" in ('normal', 'italic')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1777315276249,
|
||||
"when": 1777434203224,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { check, integer, pgTable, serial, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { users } from './auth';
|
||||
|
||||
export const userFonts = pgTable(
|
||||
'user_fonts',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
familyName: varchar('family_name', { length: 200 }).notNull(),
|
||||
originalFileName: varchar('original_file_name', { length: 500 }).notNull(),
|
||||
storedFileName: varchar('stored_file_name', { length: 500 }).notNull(),
|
||||
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'),
|
||||
fileSize: integer('file_size').notNull(),
|
||||
fileHash: varchar('file_hash', { length: 64 }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => [
|
||||
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_style_chk', sql`${t.style} in ('normal', 'italic')`),
|
||||
],
|
||||
);
|
||||
|
||||
export type UserFontRow = typeof userFonts.$inferSelect;
|
||||
export type NewUserFont = typeof userFonts.$inferInsert;
|
||||
@@ -22,3 +22,4 @@ export * from './email-preferences';
|
||||
export * from './email-send-log';
|
||||
export * from './notifications';
|
||||
export * from './tools';
|
||||
export * from './fonts';
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { UserFont } from '@bookorbit/types';
|
||||
import type { UserFontRow } from '../../../db/schema';
|
||||
|
||||
export function toFontResponse(row: UserFontRow): UserFont {
|
||||
return {
|
||||
id: row.id,
|
||||
familyName: row.familyName,
|
||||
originalFileName: row.originalFileName,
|
||||
format: row.format,
|
||||
weight: row.weight,
|
||||
style: row.style as 'normal' | 'italic',
|
||||
fileSize: row.fileSize,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
|
||||
import { FONT_WEIGHTS, type FontStyle } from '@bookorbit/types';
|
||||
|
||||
export class UpdateFontDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
familyName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(100)
|
||||
@Max(900)
|
||||
@IsIn([...FONT_WEIGHTS])
|
||||
weight?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['normal', 'italic'])
|
||||
style?: FontStyle;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { FontController } from './font.controller';
|
||||
import type { FontService } from './font.service';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
createReadStream: vi.fn(() => 'mock-stream'),
|
||||
}));
|
||||
|
||||
function makeUser(overrides: Partial<RequestUser> = {}): RequestUser {
|
||||
return {
|
||||
id: 1,
|
||||
username: 'tester',
|
||||
name: 'Tester',
|
||||
email: null,
|
||||
active: true,
|
||||
isSuperuser: false,
|
||||
isDefaultPassword: false,
|
||||
tokenVersion: 1,
|
||||
settings: {},
|
||||
avatarUrl: null,
|
||||
provisioningMethod: 'local',
|
||||
permissions: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('FontController', () => {
|
||||
let controller: FontController;
|
||||
let fontService: Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
beforeEach(() => {
|
||||
fontService = {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
upload: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
getFileInfo: vi.fn(),
|
||||
};
|
||||
|
||||
controller = new FontController(fontService as unknown as FontService);
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('returns fonts for the current user', async () => {
|
||||
const user = makeUser();
|
||||
const fonts = [{ id: 1, familyName: 'Test' }];
|
||||
fontService.list.mockResolvedValue(fonts);
|
||||
|
||||
const result = await controller.list(user);
|
||||
|
||||
expect(fontService.list).toHaveBeenCalledWith(1);
|
||||
expect(result).toEqual(fonts);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upload', () => {
|
||||
it('uploads a font file', async () => {
|
||||
const user = makeUser();
|
||||
const buffer = Buffer.from('font data');
|
||||
const mockFile = { filename: 'Test.ttf', toBuffer: vi.fn().mockResolvedValue(buffer) };
|
||||
const req = { file: vi.fn().mockResolvedValue(mockFile) };
|
||||
const uploadResult = { font: { id: 1, familyName: 'Test' }, suggestedFamilyName: 'Test', suggestedWeight: 400, suggestedStyle: 'normal' };
|
||||
fontService.upload.mockResolvedValue(uploadResult);
|
||||
|
||||
const result = await controller.upload(user, req as never);
|
||||
|
||||
expect(req.file).toHaveBeenCalled();
|
||||
expect(fontService.upload).toHaveBeenCalledWith(user, buffer, 'Test.ttf');
|
||||
expect(result).toEqual(uploadResult);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when no file provided', async () => {
|
||||
const user = makeUser();
|
||||
const req = { file: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
await expect(controller.upload(user, req as never)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates font metadata', async () => {
|
||||
const user = makeUser();
|
||||
const updated = { id: 1, familyName: 'New Name' };
|
||||
fontService.update.mockResolvedValue(updated);
|
||||
|
||||
const result = await controller.update(1, { familyName: 'New Name' }, user);
|
||||
|
||||
expect(fontService.update).toHaveBeenCalledWith(user, 1, { familyName: 'New Name' });
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('deletes a font', async () => {
|
||||
const user = makeUser();
|
||||
fontService.remove.mockResolvedValue(undefined);
|
||||
|
||||
await controller.remove(1, user);
|
||||
|
||||
expect(fontService.remove).toHaveBeenCalledWith(user, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serveFile', () => {
|
||||
it('serves a font file with correct headers', async () => {
|
||||
const user = makeUser();
|
||||
const font = { id: 1, format: 'ttf', fileHash: 'abc123' };
|
||||
fontService.getFileInfo.mockResolvedValue({
|
||||
filePath: '/path/to/font.ttf',
|
||||
font,
|
||||
});
|
||||
|
||||
const reply = {
|
||||
header: vi.fn().mockReturnThis(),
|
||||
status: vi.fn().mockReturnThis(),
|
||||
send: vi.fn(),
|
||||
};
|
||||
const req = { headers: {} };
|
||||
|
||||
await controller.serveFile(1, user, req as never, reply as never);
|
||||
|
||||
expect(reply.header).toHaveBeenCalledWith('Content-Type', 'font/ttf');
|
||||
expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'private, max-age=31536000, immutable');
|
||||
expect(reply.header).toHaveBeenCalledWith('ETag', '"abc123"');
|
||||
expect(reply.send).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 304 when ETag matches If-None-Match', async () => {
|
||||
const user = makeUser();
|
||||
const font = { id: 1, format: 'ttf', fileHash: 'abc123' };
|
||||
fontService.getFileInfo.mockResolvedValue({
|
||||
filePath: '/path/to/font.ttf',
|
||||
font,
|
||||
});
|
||||
|
||||
const reply = {
|
||||
header: vi.fn().mockReturnThis(),
|
||||
status: vi.fn().mockReturnThis(),
|
||||
send: vi.fn(),
|
||||
};
|
||||
const req = { headers: { 'if-none-match': '"abc123"' } };
|
||||
|
||||
await controller.serveFile(1, user, req as never, reply as never);
|
||||
|
||||
expect(reply.status).toHaveBeenCalledWith(304);
|
||||
expect(reply.send).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { BadRequestException, Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Patch, Post, Req, Res } from '@nestjs/common';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { createReadStream } from 'fs';
|
||||
|
||||
import { FONT_FORMAT_MIME_TYPES, MAX_FONT_FILE_SIZE } from '@bookorbit/types';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { MultipartRequest } from '../../common/types/multipart-request';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { FontService } from './font.service';
|
||||
import { UpdateFontDto } from './dto/update-font.dto';
|
||||
|
||||
@Controller('fonts')
|
||||
export class FontController {
|
||||
constructor(private readonly fontService: FontService) {}
|
||||
|
||||
@Get()
|
||||
async list(@CurrentUser() user: RequestUser) {
|
||||
return this.fontService.list(user.id);
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async upload(@CurrentUser() user: RequestUser, @Req() req: MultipartRequest) {
|
||||
const data = await req.file({ limits: { fileSize: MAX_FONT_FILE_SIZE } });
|
||||
if (!data) throw new BadRequestException('No file provided');
|
||||
const buffer = await data.toBuffer();
|
||||
return this.fontService.upload(user, buffer, data.filename);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateFontDto, @CurrentUser() user: RequestUser) {
|
||||
return this.fontService.update(user, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
|
||||
await this.fontService.remove(user, id);
|
||||
}
|
||||
|
||||
@Get(':id/file')
|
||||
async serveFile(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Req() req: { headers: Record<string, string | undefined> },
|
||||
@Res() reply: FastifyReply,
|
||||
) {
|
||||
const { filePath, font } = await this.fontService.getFileInfo(user, id);
|
||||
|
||||
const etag = `"${font.fileHash}"`;
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
reply.status(304).send();
|
||||
return;
|
||||
}
|
||||
|
||||
const mimeType = FONT_FORMAT_MIME_TYPES[font.format];
|
||||
reply.header('Content-Type', mimeType);
|
||||
reply.header('Cache-Control', 'private, max-age=31536000, immutable');
|
||||
reply.header('ETag', etag);
|
||||
reply.send(createReadStream(filePath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FontController } from './font.controller';
|
||||
import { FontRepository } from './font.repository';
|
||||
import { FontService } from './font.service';
|
||||
import { FontStorageService } from './font.storage.service';
|
||||
import { FontValidationService } from './font.validation.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FontController],
|
||||
providers: [FontService, FontRepository, FontStorageService, FontValidationService],
|
||||
exports: [FontService],
|
||||
})
|
||||
export class FontModule {}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { FontRepository } from './font.repository';
|
||||
|
||||
describe('FontRepository', () => {
|
||||
const mockQuery = {
|
||||
userFonts: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
findFirst: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
|
||||
const mockDb = {
|
||||
query: mockQuery,
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue([{ count: 5 }]),
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn().mockResolvedValue([{ id: 1 }]),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
delete: vi.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
let repo: FontRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset chain to allow both select and insert/update/delete patterns
|
||||
mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([{ count: 5 }]) }) });
|
||||
mockDb.insert.mockReturnValue({ values: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([{ id: 1 }]) }) });
|
||||
mockDb.update.mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ id: 1, familyName: 'Updated' }]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
mockDb.delete.mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ id: 1 }]),
|
||||
}),
|
||||
});
|
||||
|
||||
repo = new FontRepository(mockDb as never);
|
||||
});
|
||||
|
||||
describe('findAllByUser', () => {
|
||||
it('queries fonts by user id', async () => {
|
||||
await repo.findAllByUser(42);
|
||||
expect(mockQuery.userFonts.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.anything() }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('queries a single font by id', async () => {
|
||||
await repo.findById(1);
|
||||
expect(mockQuery.userFonts.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: expect.anything() }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByUserAndHash', () => {
|
||||
it('queries by user and file hash', async () => {
|
||||
await repo.findByUserAndHash(42, 'abc123');
|
||||
expect(mockQuery.userFonts.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: expect.anything() }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('countByUser', () => {
|
||||
it('returns font count for user', async () => {
|
||||
const result = await repo.countByUser(42);
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('inserts a new font and returns it', async () => {
|
||||
const result = await repo.create({
|
||||
userId: 1,
|
||||
familyName: 'Test',
|
||||
originalFileName: 'test.ttf',
|
||||
storedFileName: 'uuid.ttf',
|
||||
format: 'ttf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
fileSize: 1000,
|
||||
fileHash: 'hash',
|
||||
});
|
||||
expect(result).toEqual({ id: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates font and returns the updated row', async () => {
|
||||
const result = await repo.update(1, { familyName: 'Updated' });
|
||||
expect(result).toEqual({ id: 1, familyName: 'Updated' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes a font by id', async () => {
|
||||
await repo.delete(1);
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAllByUser', () => {
|
||||
it('deletes all fonts for a user', async () => {
|
||||
const result = await repo.deleteAllByUser(42);
|
||||
expect(result).toEqual([{ id: 1 }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq, count } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import type { NewUserFont, UserFontRow } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@Injectable()
|
||||
export class FontRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async findAllByUser(userId: number): Promise<UserFontRow[]> {
|
||||
return this.db.query.userFonts.findMany({
|
||||
where: eq(schema.userFonts.userId, userId),
|
||||
orderBy: [schema.userFonts.familyName, schema.userFonts.weight, schema.userFonts.style],
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<UserFontRow | undefined> {
|
||||
return this.db.query.userFonts.findFirst({
|
||||
where: eq(schema.userFonts.id, id),
|
||||
});
|
||||
}
|
||||
|
||||
async findByUserAndHash(userId: number, fileHash: string): Promise<UserFontRow | undefined> {
|
||||
return this.db.query.userFonts.findFirst({
|
||||
where: and(eq(schema.userFonts.userId, userId), eq(schema.userFonts.fileHash, fileHash)),
|
||||
});
|
||||
}
|
||||
|
||||
async countByUser(userId: number): Promise<number> {
|
||||
const result = await this.db.select({ count: count() }).from(schema.userFonts).where(eq(schema.userFonts.userId, userId));
|
||||
return result[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
async create(data: NewUserFont): Promise<UserFontRow> {
|
||||
const [row] = await this.db.insert(schema.userFonts).values(data).returning();
|
||||
return row!;
|
||||
}
|
||||
|
||||
async update(id: number, data: Partial<Pick<UserFontRow, 'familyName' | 'weight' | 'style'>>): Promise<UserFontRow | undefined> {
|
||||
const [row] = await this.db.update(schema.userFonts).set(data).where(eq(schema.userFonts.id, id)).returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<void> {
|
||||
await this.db.delete(schema.userFonts).where(eq(schema.userFonts.id, id));
|
||||
}
|
||||
|
||||
async deleteAllByUser(userId: number): Promise<UserFontRow[]> {
|
||||
return this.db.delete(schema.userFonts).where(eq(schema.userFonts.userId, userId)).returning();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { BadRequestException, ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { FontService } from './font.service';
|
||||
import type { FontRepository } from './font.repository';
|
||||
import type { FontStorageService } from './font.storage.service';
|
||||
import type { FontValidationService } from './font.validation.service';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import type { UserFontRow } from '../../db/schema';
|
||||
|
||||
function makeUser(overrides: Partial<RequestUser> = {}): RequestUser {
|
||||
return {
|
||||
id: 1,
|
||||
username: 'tester',
|
||||
name: 'Tester',
|
||||
email: null,
|
||||
active: true,
|
||||
isSuperuser: false,
|
||||
isDefaultPassword: false,
|
||||
tokenVersion: 1,
|
||||
settings: {},
|
||||
avatarUrl: null,
|
||||
provisioningMethod: 'local',
|
||||
permissions: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFontRow(overrides: Partial<UserFontRow> = {}): UserFontRow {
|
||||
return {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
familyName: 'Literata',
|
||||
originalFileName: 'Literata-Regular.ttf',
|
||||
storedFileName: 'uuid.ttf',
|
||||
format: 'ttf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
fileSize: 50000,
|
||||
fileHash: 'abc123hash',
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTTFBuffer(): Buffer {
|
||||
return Buffer.from([0x00, 0x01, 0x00, 0x00, ...Array(100).fill(0)]);
|
||||
}
|
||||
|
||||
describe('FontService', () => {
|
||||
let service: FontService;
|
||||
let repo: Record<string, ReturnType<typeof vi.fn>>;
|
||||
let storage: Record<string, ReturnType<typeof vi.fn>>;
|
||||
let validation: Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = {
|
||||
findAllByUser: vi.fn().mockResolvedValue([]),
|
||||
findById: vi.fn(),
|
||||
findByUserAndHash: vi.fn().mockResolvedValue(undefined),
|
||||
countByUser: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
deleteAllByUser: vi.fn(),
|
||||
};
|
||||
|
||||
storage = {
|
||||
save: vi.fn().mockResolvedValue('stored-uuid.ttf'),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
getPathIfExists: vi.fn().mockResolvedValue('/app-data/users/1/fonts/stored-uuid.ttf'),
|
||||
getPath: vi.fn().mockReturnValue('/app-data/users/1/fonts/stored-uuid.ttf'),
|
||||
};
|
||||
|
||||
validation = {
|
||||
validateFormat: vi.fn(),
|
||||
extractMetadata: vi.fn().mockReturnValue({
|
||||
familyName: 'Literata',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
}),
|
||||
};
|
||||
|
||||
service = new FontService(
|
||||
repo as unknown as FontRepository,
|
||||
storage as unknown as FontStorageService,
|
||||
validation as unknown as FontValidationService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('returns all fonts for a user mapped to response format', async () => {
|
||||
const row = makeFontRow();
|
||||
repo.findAllByUser.mockResolvedValue([row]);
|
||||
|
||||
const result = await service.list(1);
|
||||
|
||||
expect(repo.findAllByUser).toHaveBeenCalledWith(1);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
id: 1,
|
||||
familyName: 'Literata',
|
||||
originalFileName: 'Literata-Regular.ttf',
|
||||
format: 'ttf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
fileSize: 50000,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array when user has no fonts', async () => {
|
||||
repo.findAllByUser.mockResolvedValue([]);
|
||||
const result = await service.list(1);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upload', () => {
|
||||
it('validates, saves, and creates a font record', async () => {
|
||||
const user = makeUser();
|
||||
const buffer = makeTTFBuffer();
|
||||
const row = makeFontRow();
|
||||
repo.create.mockResolvedValue(row);
|
||||
|
||||
const result = await service.upload(user, buffer, 'Literata-Regular.ttf');
|
||||
|
||||
expect(validation.validateFormat).toHaveBeenCalledWith(buffer, 'ttf');
|
||||
expect(storage.save).toHaveBeenCalledWith(1, 'ttf', buffer);
|
||||
expect(repo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 1,
|
||||
familyName: 'Literata',
|
||||
originalFileName: 'Literata-Regular.ttf',
|
||||
format: 'ttf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
}),
|
||||
);
|
||||
expect(result.font.familyName).toBe('Literata');
|
||||
expect(result.suggestedFamilyName).toBe('Literata');
|
||||
});
|
||||
|
||||
it('rejects files exceeding size limit', async () => {
|
||||
const user = makeUser();
|
||||
const buffer = Buffer.alloc(11 * 1024 * 1024);
|
||||
|
||||
await expect(service.upload(user, buffer, 'big.ttf')).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects unsupported file extensions', async () => {
|
||||
const user = makeUser();
|
||||
const buffer = makeTTFBuffer();
|
||||
|
||||
await expect(service.upload(user, buffer, 'font.svg')).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects duplicate uploads', async () => {
|
||||
const user = makeUser();
|
||||
const buffer = makeTTFBuffer();
|
||||
repo.findByUserAndHash.mockResolvedValue(makeFontRow());
|
||||
|
||||
await expect(service.upload(user, buffer, 'Literata.ttf')).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('rejects when user has reached font limit', async () => {
|
||||
const user = makeUser();
|
||||
const buffer = makeTTFBuffer();
|
||||
repo.countByUser.mockResolvedValue(50);
|
||||
|
||||
await expect(service.upload(user, buffer, 'Literata.ttf')).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('cleans up stored file if DB insert fails', async () => {
|
||||
const user = makeUser();
|
||||
const buffer = makeTTFBuffer();
|
||||
repo.create.mockRejectedValue(new Error('DB error'));
|
||||
|
||||
await expect(service.upload(user, buffer, 'Literata.ttf')).rejects.toThrow();
|
||||
expect(storage.delete).toHaveBeenCalledWith(1, 'stored-uuid.ttf');
|
||||
});
|
||||
|
||||
it('uses fallback family name when metadata extraction returns null', async () => {
|
||||
const user = makeUser();
|
||||
const buffer = makeTTFBuffer();
|
||||
validation.extractMetadata.mockReturnValue({ familyName: null, weight: 400, style: 'normal' });
|
||||
const row = makeFontRow({ familyName: 'Literata' });
|
||||
repo.create.mockResolvedValue(row);
|
||||
|
||||
await service.upload(user, buffer, 'Literata-Regular.ttf');
|
||||
|
||||
expect(repo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
familyName: 'Literata',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses "Custom Font" as fallback when filename yields empty name', async () => {
|
||||
const user = makeUser();
|
||||
const buffer = makeTTFBuffer();
|
||||
validation.extractMetadata.mockReturnValue({ familyName: null, weight: 400, style: 'normal' });
|
||||
const row = makeFontRow({ familyName: 'Custom Font' });
|
||||
repo.create.mockResolvedValue(row);
|
||||
|
||||
await service.upload(user, buffer, 'Regular.ttf');
|
||||
|
||||
expect(repo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
familyName: 'Custom Font',
|
||||
}),
|
||||
);
|
||||
});
|
||||
it('returns suggestedFamilyName as null when font has no parseable family name', async () => {
|
||||
const user = makeUser();
|
||||
const buffer = makeTTFBuffer();
|
||||
validation.extractMetadata.mockReturnValue({ familyName: null, weight: 400, style: 'normal' });
|
||||
const row = makeFontRow({ familyName: 'Custom Font' });
|
||||
repo.create.mockResolvedValue(row);
|
||||
|
||||
const result = await service.upload(user, buffer, 'Regular.ttf');
|
||||
|
||||
expect(result.suggestedFamilyName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates font metadata', async () => {
|
||||
const user = makeUser();
|
||||
const font = makeFontRow();
|
||||
repo.findById.mockResolvedValue(font);
|
||||
const updated = makeFontRow({ familyName: 'New Name', weight: 700 });
|
||||
repo.update.mockResolvedValue(updated);
|
||||
|
||||
const result = await service.update(user, 1, { familyName: 'New Name', weight: 700 });
|
||||
|
||||
expect(repo.update).toHaveBeenCalledWith(1, { familyName: 'New Name', weight: 700 });
|
||||
expect(result.familyName).toBe('New Name');
|
||||
});
|
||||
|
||||
it('returns current font when no fields to update', async () => {
|
||||
const user = makeUser();
|
||||
const font = makeFontRow();
|
||||
repo.findById.mockResolvedValue(font);
|
||||
|
||||
const result = await service.update(user, 1, {});
|
||||
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
expect(result.id).toBe(1);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when repo.update returns undefined (concurrent delete race)', async () => {
|
||||
const user = makeUser();
|
||||
repo.findById.mockResolvedValue(makeFontRow());
|
||||
repo.update.mockResolvedValue(undefined);
|
||||
|
||||
await expect(service.update(user, 1, { familyName: 'Race' })).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects when font not found', async () => {
|
||||
const user = makeUser();
|
||||
repo.findById.mockResolvedValue(undefined);
|
||||
|
||||
await expect(service.update(user, 999, { familyName: 'X' })).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects when user does not own font', async () => {
|
||||
const user = makeUser({ id: 2 });
|
||||
repo.findById.mockResolvedValue(makeFontRow({ userId: 1 }));
|
||||
|
||||
await expect(service.update(user, 1, { familyName: 'X' })).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('deletes font from DB and storage', async () => {
|
||||
const user = makeUser();
|
||||
repo.findById.mockResolvedValue(makeFontRow());
|
||||
|
||||
await service.remove(user, 1);
|
||||
|
||||
expect(repo.delete).toHaveBeenCalledWith(1);
|
||||
expect(storage.delete).toHaveBeenCalledWith(1, 'uuid.ttf');
|
||||
});
|
||||
|
||||
it('rejects when font not found', async () => {
|
||||
const user = makeUser();
|
||||
repo.findById.mockResolvedValue(undefined);
|
||||
|
||||
await expect(service.remove(user, 999)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects when user does not own font', async () => {
|
||||
const user = makeUser({ id: 2 });
|
||||
repo.findById.mockResolvedValue(makeFontRow({ userId: 1 }));
|
||||
|
||||
await expect(service.remove(user, 1)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileInfo', () => {
|
||||
it('returns file path and font info', async () => {
|
||||
const user = makeUser();
|
||||
repo.findById.mockResolvedValue(makeFontRow());
|
||||
|
||||
const result = await service.getFileInfo(user, 1);
|
||||
|
||||
expect(result.filePath).toBe('/app-data/users/1/fonts/stored-uuid.ttf');
|
||||
expect(result.font).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects when file does not exist on disk', async () => {
|
||||
const user = makeUser();
|
||||
repo.findById.mockResolvedValue(makeFontRow());
|
||||
storage.getPathIfExists.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getFileInfo(user, 1)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects when user does not own font', async () => {
|
||||
const user = makeUser({ id: 2 });
|
||||
repo.findById.mockResolvedValue(makeFontRow({ userId: 1 }));
|
||||
|
||||
await expect(service.getFileInfo(user, 1)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { BadRequestException, ConflictException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { createHash } from 'crypto';
|
||||
import { MAX_FONT_FILE_SIZE, MAX_FONTS_PER_USER, FONT_FORMATS, type FontFormat } from '@bookorbit/types';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import type { UserFontRow } from '../../db/schema';
|
||||
import { FontRepository } from './font.repository';
|
||||
import { FontStorageService } from './font.storage.service';
|
||||
import { FontValidationService, familyNameFromFilename } from './font.validation.service';
|
||||
import { toFontResponse } from './dto/font-response.dto';
|
||||
import type { UpdateFontDto } from './dto/update-font.dto';
|
||||
|
||||
@Injectable()
|
||||
export class FontService {
|
||||
private readonly logger = new Logger(FontService.name);
|
||||
|
||||
constructor(
|
||||
private readonly repo: FontRepository,
|
||||
private readonly storage: FontStorageService,
|
||||
private readonly validation: FontValidationService,
|
||||
) {}
|
||||
|
||||
async list(userId: number) {
|
||||
const rows = await this.repo.findAllByUser(userId);
|
||||
return rows.map(toFontResponse);
|
||||
}
|
||||
|
||||
async upload(user: RequestUser, buffer: Buffer, originalFilename: string) {
|
||||
const event = 'font.upload';
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[${event}] [start] userId=${user.id} filename=${originalFilename} sizeBytes=${buffer.length} - font upload started`);
|
||||
|
||||
try {
|
||||
if (buffer.length > MAX_FONT_FILE_SIZE) {
|
||||
throw new BadRequestException(`Font file exceeds maximum size of ${MAX_FONT_FILE_SIZE / 1024 / 1024} MB`);
|
||||
}
|
||||
|
||||
const format = this.resolveFormat(originalFilename);
|
||||
this.validation.validateFormat(buffer, format);
|
||||
|
||||
const fileHash = createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
const existing = await this.repo.findByUserAndHash(user.id, fileHash);
|
||||
if (existing) {
|
||||
throw new ConflictException('This font file has already been uploaded');
|
||||
}
|
||||
|
||||
const fontCount = await this.repo.countByUser(user.id);
|
||||
if (fontCount >= MAX_FONTS_PER_USER) {
|
||||
throw new BadRequestException(`Maximum of ${MAX_FONTS_PER_USER} fonts per user reached`);
|
||||
}
|
||||
|
||||
const metadata = this.validation.extractMetadata(buffer, originalFilename);
|
||||
const familyName = metadata.familyName ?? familyNameFromFilename(originalFilename) ?? 'Custom Font';
|
||||
|
||||
const storedFileName = await this.storage.save(user.id, format, buffer);
|
||||
|
||||
let row: UserFontRow;
|
||||
try {
|
||||
row = await this.repo.create({
|
||||
userId: user.id,
|
||||
familyName,
|
||||
originalFileName: originalFilename,
|
||||
storedFileName,
|
||||
format,
|
||||
weight: metadata.weight,
|
||||
style: metadata.style,
|
||||
fileSize: buffer.length,
|
||||
fileHash,
|
||||
});
|
||||
} catch (err) {
|
||||
await this.storage.delete(user.id, storedFileName);
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[${event}] [end] userId=${user.id} fontId=${row.id} durationMs=${Date.now() - startedAt} familyName=${familyName} format=${format} - font upload completed`,
|
||||
);
|
||||
|
||||
return {
|
||||
font: toFontResponse(row),
|
||||
suggestedFamilyName: metadata.familyName,
|
||||
suggestedWeight: metadata.weight,
|
||||
suggestedStyle: metadata.style,
|
||||
};
|
||||
} catch (err) {
|
||||
const errorClass = err instanceof Error ? err.name : 'Error';
|
||||
const errorMessage = (err instanceof Error ? err.message : String(err)).slice(0, 200);
|
||||
this.logger.warn(
|
||||
`[${event}] [fail] userId=${user.id} filename=${originalFilename} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - font upload failed`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(user: RequestUser, fontId: number, dto: UpdateFontDto) {
|
||||
const font = await this.findOwnedFont(user, fontId);
|
||||
|
||||
const data: Partial<Pick<UserFontRow, 'familyName' | 'weight' | 'style'>> = {};
|
||||
if (dto.familyName !== undefined) data.familyName = dto.familyName;
|
||||
if (dto.weight !== undefined) data.weight = dto.weight;
|
||||
if (dto.style !== undefined) data.style = dto.style;
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return toFontResponse(font);
|
||||
}
|
||||
|
||||
const updated = await this.repo.update(fontId, data);
|
||||
if (!updated) throw new NotFoundException('Font not found');
|
||||
return toFontResponse(updated);
|
||||
}
|
||||
|
||||
async remove(user: RequestUser, fontId: number): Promise<void> {
|
||||
const font = await this.findOwnedFont(user, fontId);
|
||||
await this.repo.delete(fontId);
|
||||
await this.storage.delete(user.id, font.storedFileName);
|
||||
}
|
||||
|
||||
async getFileInfo(user: RequestUser, fontId: number) {
|
||||
const font = await this.findOwnedFont(user, fontId);
|
||||
const filePath = await this.storage.getPathIfExists(user.id, font.storedFileName);
|
||||
if (!filePath) {
|
||||
throw new NotFoundException('Font file not found on disk');
|
||||
}
|
||||
return { filePath, font };
|
||||
}
|
||||
|
||||
private async findOwnedFont(user: RequestUser, fontId: number): Promise<UserFontRow> {
|
||||
const font = await this.repo.findById(fontId);
|
||||
if (!font) {
|
||||
throw new NotFoundException('Font not found');
|
||||
}
|
||||
if (font.userId !== user.id) {
|
||||
throw new ForbiddenException('You do not own this font');
|
||||
}
|
||||
return font;
|
||||
}
|
||||
|
||||
private resolveFormat(filename: string): FontFormat {
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
if (ext && (FONT_FORMATS as readonly string[]).includes(ext)) {
|
||||
return ext as FontFormat;
|
||||
}
|
||||
throw new BadRequestException(`Unsupported file extension. Supported formats: ${FONT_FORMATS.join(', ')}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
vi.mock('fs/promises', () => ({
|
||||
access: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { access, mkdir, unlink, writeFile } from 'fs/promises';
|
||||
|
||||
import { FontStorageService } from './font.storage.service';
|
||||
|
||||
const accessMock = vi.mocked(access);
|
||||
const mkdirMock = vi.mocked(mkdir);
|
||||
const unlinkMock = vi.mocked(unlink);
|
||||
const writeFileMock = vi.mocked(writeFile);
|
||||
|
||||
vi.mock('crypto', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('crypto')>();
|
||||
let callCount = 0;
|
||||
return {
|
||||
...actual,
|
||||
randomUUID: vi.fn(() => {
|
||||
callCount++;
|
||||
return `test-uuid-${callCount}`;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('FontStorageService', () => {
|
||||
const loggerWarn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
const config = {
|
||||
get: vi.fn().mockReturnValue('/app-data'),
|
||||
};
|
||||
|
||||
let service: FontStorageService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
config.get.mockReturnValue('/app-data');
|
||||
service = new FontStorageService(config as never);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('save', () => {
|
||||
it('creates the font directory and writes the file', async () => {
|
||||
const buffer = Buffer.from('font data');
|
||||
const storedFileName = await service.save(42, 'ttf', buffer);
|
||||
|
||||
expect(mkdirMock).toHaveBeenCalledWith('/app-data/users/42/fonts', { recursive: true });
|
||||
expect(writeFileMock).toHaveBeenCalledWith(expect.stringContaining('/app-data/users/42/fonts/'), buffer);
|
||||
expect(storedFileName).toMatch(/\.ttf$/);
|
||||
});
|
||||
|
||||
it('generates correct extension for each format', async () => {
|
||||
const buffer = Buffer.from('font data');
|
||||
|
||||
const ttf = await service.save(1, 'ttf', buffer);
|
||||
expect(ttf).toMatch(/\.ttf$/);
|
||||
|
||||
const otf = await service.save(1, 'otf', buffer);
|
||||
expect(otf).toMatch(/\.otf$/);
|
||||
|
||||
const woff = await service.save(1, 'woff', buffer);
|
||||
expect(woff).toMatch(/\.woff$/);
|
||||
|
||||
const woff2 = await service.save(1, 'woff2', buffer);
|
||||
expect(woff2).toMatch(/\.woff2$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes the file at the correct path', async () => {
|
||||
unlinkMock.mockResolvedValue(undefined);
|
||||
await service.delete(42, 'abc.ttf');
|
||||
expect(unlinkMock).toHaveBeenCalledWith('/app-data/users/42/fonts/abc.ttf');
|
||||
});
|
||||
|
||||
it('silently ignores ENOENT errors', async () => {
|
||||
unlinkMock.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
|
||||
await expect(service.delete(42, 'abc.ttf')).resolves.toBeUndefined();
|
||||
expect(loggerWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs a warning for non-ENOENT errors', async () => {
|
||||
unlinkMock.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
|
||||
await expect(service.delete(42, 'abc.ttf')).resolves.toBeUndefined();
|
||||
expect(loggerWarn).toHaveBeenCalledWith(expect.stringContaining('permission denied'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPathIfExists', () => {
|
||||
it('returns path when file is accessible', async () => {
|
||||
accessMock.mockResolvedValue(undefined);
|
||||
const result = await service.getPathIfExists(42, 'abc.ttf');
|
||||
expect(result).toBe('/app-data/users/42/fonts/abc.ttf');
|
||||
});
|
||||
|
||||
it('returns null when file is not accessible', async () => {
|
||||
accessMock.mockRejectedValue(new Error('not readable'));
|
||||
const result = await service.getPathIfExists(42, 'abc.ttf');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPath', () => {
|
||||
it('returns the full path without checking existence', () => {
|
||||
const result = service.getPath(42, 'abc.ttf');
|
||||
expect(result).toBe('/app-data/users/42/fonts/abc.ttf');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { access, mkdir, unlink, writeFile } from 'fs/promises';
|
||||
import { constants as fsConstants } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { FontFormat } from '@bookorbit/types';
|
||||
import { FONT_FORMAT_EXTENSIONS } from '@bookorbit/types';
|
||||
|
||||
@Injectable()
|
||||
export class FontStorageService {
|
||||
private readonly logger = new Logger(FontStorageService.name);
|
||||
private readonly appDataPath: string;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
|
||||
}
|
||||
|
||||
async save(userId: number, format: FontFormat, buffer: Buffer): Promise<string> {
|
||||
const dir = this.fontDir(userId);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const storedFileName = `${randomUUID()}${FONT_FORMAT_EXTENSIONS[format]}`;
|
||||
await writeFile(join(dir, storedFileName), buffer);
|
||||
return storedFileName;
|
||||
}
|
||||
|
||||
async delete(userId: number, storedFileName: string): Promise<void> {
|
||||
const filePath = join(this.fontDir(userId), storedFileName);
|
||||
await unlink(filePath).catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code !== 'ENOENT') {
|
||||
this.logger.warn(`Failed to delete font file userId=${userId} file=${storedFileName}: ${err.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getPathIfExists(userId: number, storedFileName: string): Promise<string | null> {
|
||||
const filePath = join(this.fontDir(userId), storedFileName);
|
||||
try {
|
||||
await access(filePath, fsConstants.R_OK);
|
||||
return filePath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getPath(userId: number, storedFileName: string): string {
|
||||
return join(this.fontDir(userId), storedFileName);
|
||||
}
|
||||
|
||||
private fontDir(userId: number): string {
|
||||
return join(this.appDataPath, 'users', String(userId), 'fonts');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { FontValidationService } from './font.validation.service';
|
||||
|
||||
vi.mock('opentype.js', () => ({
|
||||
parse: vi.fn(),
|
||||
}));
|
||||
|
||||
import { parse } from 'opentype.js';
|
||||
|
||||
const parseMock = vi.mocked(parse);
|
||||
|
||||
describe('FontValidationService', () => {
|
||||
let service: FontValidationService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new FontValidationService();
|
||||
});
|
||||
|
||||
describe('validateFormat', () => {
|
||||
it('accepts valid TTF magic bytes (version 1.0)', () => {
|
||||
const buffer = Buffer.from([0x00, 0x01, 0x00, 0x00, ...Array(100).fill(0)]);
|
||||
expect(() => service.validateFormat(buffer, 'ttf')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts valid TTF magic bytes ("true")', () => {
|
||||
const buffer = Buffer.from([0x74, 0x72, 0x75, 0x65, ...Array(100).fill(0)]);
|
||||
expect(() => service.validateFormat(buffer, 'ttf')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts valid OTF magic bytes ("OTTO")', () => {
|
||||
const buffer = Buffer.from([0x4f, 0x54, 0x54, 0x4f, ...Array(100).fill(0)]);
|
||||
expect(() => service.validateFormat(buffer, 'otf')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts valid WOFF magic bytes', () => {
|
||||
const buffer = Buffer.from([0x77, 0x4f, 0x46, 0x46, ...Array(100).fill(0)]);
|
||||
expect(() => service.validateFormat(buffer, 'woff')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts valid WOFF2 magic bytes', () => {
|
||||
const buffer = Buffer.from([0x77, 0x4f, 0x46, 0x32, ...Array(100).fill(0)]);
|
||||
expect(() => service.validateFormat(buffer, 'woff2')).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects when magic bytes do not match declared format', () => {
|
||||
const ttfBuffer = Buffer.from([0x00, 0x01, 0x00, 0x00, ...Array(100).fill(0)]);
|
||||
expect(() => service.validateFormat(ttfBuffer, 'otf')).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects unrecognized magic bytes', () => {
|
||||
const buffer = Buffer.from([0xff, 0xff, 0xff, 0xff, ...Array(100).fill(0)]);
|
||||
expect(() => service.validateFormat(buffer, 'ttf')).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects files smaller than 4 bytes', () => {
|
||||
const buffer = Buffer.from([0x00, 0x01]);
|
||||
expect(() => service.validateFormat(buffer, 'ttf')).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects unsupported format', () => {
|
||||
const buffer = Buffer.from([0x00, 0x01, 0x00, 0x00, ...Array(100).fill(0)]);
|
||||
expect(() => service.validateFormat(buffer, 'svg' as never)).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractMetadata', () => {
|
||||
it('extracts family name, weight, and style from font metadata', () => {
|
||||
parseMock.mockReturnValue({
|
||||
names: {
|
||||
fontFamily: { en: 'Literata' },
|
||||
fontSubfamily: { en: 'Bold Italic' },
|
||||
},
|
||||
tables: {
|
||||
os2: { usWeightClass: 700, fsSelection: 1 },
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Literata-BoldItalic.ttf');
|
||||
expect(result.familyName).toBe('Literata');
|
||||
expect(result.weight).toBe(700);
|
||||
expect(result.style).toBe('italic');
|
||||
});
|
||||
|
||||
it('normalizes weight to nearest hundred', () => {
|
||||
parseMock.mockReturnValue({
|
||||
names: { fontFamily: { en: 'Test' }, fontSubfamily: { en: 'Regular' } },
|
||||
tables: { os2: { usWeightClass: 350, fsSelection: 0 } },
|
||||
} as never);
|
||||
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Test.ttf');
|
||||
expect(result.weight).toBe(400);
|
||||
});
|
||||
|
||||
it('falls back to filename heuristics when parsing fails', () => {
|
||||
parseMock.mockImplementation(() => {
|
||||
throw new Error('Cannot parse WOFF2');
|
||||
});
|
||||
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'MyFont-Bold-Italic.woff2');
|
||||
expect(result.familyName).toBe('MyFont');
|
||||
expect(result.weight).toBe(700);
|
||||
expect(result.style).toBe('italic');
|
||||
});
|
||||
|
||||
it('detects weight from filename when metadata lacks weight', () => {
|
||||
parseMock.mockReturnValue({
|
||||
names: { fontFamily: { en: 'Test' }, fontSubfamily: { en: '' } },
|
||||
tables: { os2: {} },
|
||||
} as never);
|
||||
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Test-Light.ttf');
|
||||
expect(result.weight).toBe(300);
|
||||
});
|
||||
|
||||
it('detects italic from subfamily name', () => {
|
||||
parseMock.mockReturnValue({
|
||||
names: { fontFamily: { en: 'Test' }, fontSubfamily: { en: 'italic' } },
|
||||
tables: { os2: { usWeightClass: 400, fsSelection: 0 } },
|
||||
} as never);
|
||||
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Test-Italic.ttf');
|
||||
expect(result.style).toBe('italic');
|
||||
});
|
||||
|
||||
it('defaults to weight 400 and style normal when no metadata or heuristics match', () => {
|
||||
parseMock.mockReturnValue({
|
||||
names: { fontFamily: { en: 'Test' }, fontSubfamily: { en: '' } },
|
||||
tables: { os2: {} },
|
||||
} as never);
|
||||
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Test.ttf');
|
||||
expect(result.weight).toBe(400);
|
||||
expect(result.style).toBe('normal');
|
||||
});
|
||||
|
||||
it('returns null family name when metadata has no fontFamily', () => {
|
||||
parseMock.mockReturnValue({
|
||||
names: { fontFamily: {}, fontSubfamily: { en: '' } },
|
||||
tables: { os2: { usWeightClass: 400, fsSelection: 0 } },
|
||||
} as never);
|
||||
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'TestFont.ttf');
|
||||
expect(result.familyName).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts family name from filename stripping weight/style keywords', () => {
|
||||
parseMock.mockImplementation(() => {
|
||||
throw new Error('fail');
|
||||
});
|
||||
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Open_Sans-SemiBold-Italic.ttf');
|
||||
expect(result.familyName).toBe('Open Sans');
|
||||
expect(result.weight).toBe(600);
|
||||
expect(result.style).toBe('italic');
|
||||
});
|
||||
|
||||
it('detects thin weight', () => {
|
||||
parseMock.mockImplementation(() => {
|
||||
throw new Error('fail');
|
||||
});
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Roboto-Thin.otf');
|
||||
expect(result.weight).toBe(100);
|
||||
});
|
||||
|
||||
it('detects extralight weight', () => {
|
||||
parseMock.mockImplementation(() => {
|
||||
throw new Error('fail');
|
||||
});
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Roboto-ExtraLight.otf');
|
||||
expect(result.weight).toBe(200);
|
||||
});
|
||||
|
||||
it('detects medium weight', () => {
|
||||
parseMock.mockImplementation(() => {
|
||||
throw new Error('fail');
|
||||
});
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Roboto-Medium.woff');
|
||||
expect(result.weight).toBe(500);
|
||||
});
|
||||
|
||||
it('detects black weight', () => {
|
||||
parseMock.mockImplementation(() => {
|
||||
throw new Error('fail');
|
||||
});
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Roboto-Black.woff2');
|
||||
expect(result.weight).toBe(900);
|
||||
});
|
||||
|
||||
it('detects extrabold weight (not misclassified as bold)', () => {
|
||||
parseMock.mockImplementation(() => {
|
||||
throw new Error('fail');
|
||||
});
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Roboto-ExtraBold.ttf');
|
||||
expect(result.weight).toBe(800);
|
||||
});
|
||||
|
||||
it('detects extrabold with space separator (Extra-Bold after normalisation)', () => {
|
||||
parseMock.mockImplementation(() => {
|
||||
throw new Error('fail');
|
||||
});
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Roboto-Extra-Bold.ttf');
|
||||
expect(result.weight).toBe(800);
|
||||
});
|
||||
|
||||
it('detects ultrabold weight (not misclassified as bold)', () => {
|
||||
parseMock.mockImplementation(() => {
|
||||
throw new Error('fail');
|
||||
});
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Font-UltraBold.ttf');
|
||||
expect(result.weight).toBe(800);
|
||||
});
|
||||
|
||||
it('detects semibold weight', () => {
|
||||
parseMock.mockImplementation(() => {
|
||||
throw new Error('fail');
|
||||
});
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Font-SemiBold.woff2');
|
||||
expect(result.weight).toBe(600);
|
||||
});
|
||||
|
||||
it('does not false-positive on word containing a keyword as substring', () => {
|
||||
parseMock.mockImplementation(() => {
|
||||
throw new Error('fail');
|
||||
});
|
||||
// "Abnormal" contains "normal" but as a substring, not a word token
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Abnormal.ttf');
|
||||
expect(result.weight).toBe(400); // default, not matched by "normal"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { parse } from 'opentype.js';
|
||||
import type { FontFormat, FontStyle } from '@bookorbit/types';
|
||||
import { FONT_FORMATS } from '@bookorbit/types';
|
||||
|
||||
export interface FontMetadata {
|
||||
familyName: string | null;
|
||||
weight: number;
|
||||
style: FontStyle;
|
||||
}
|
||||
|
||||
const MAGIC_BYTES: Record<string, FontFormat[]> = {
|
||||
// TTF: version 1.0
|
||||
'00010000': ['ttf'],
|
||||
// TTF: "true" (Apple TrueType)
|
||||
'74727565': ['ttf'],
|
||||
// OTF: "OTTO"
|
||||
'4f54544f': ['otf'],
|
||||
// WOFF1: "wOFF"
|
||||
'774f4646': ['woff'],
|
||||
// WOFF2: "wOF2"
|
||||
'774f4632': ['woff2'],
|
||||
};
|
||||
|
||||
// More-specific patterns must precede narrower ones (e.g. extra-bold before bold).
|
||||
// \s* handles both "ExtraBold" and "Extra Bold" (after filename normalisation).
|
||||
const WEIGHT_PATTERNS: [RegExp, number][] = [
|
||||
[/\bthin\b/, 100],
|
||||
[/\bhairline\b/, 100],
|
||||
[/\bextra\s*light\b/, 200],
|
||||
[/\bultra\s*light\b/, 200],
|
||||
[/\blight\b/, 300],
|
||||
[/\bregular\b/, 400],
|
||||
[/\bnormal\b/, 400],
|
||||
[/\bmedium\b/, 500],
|
||||
[/\bdemi\s*bold\b/, 600],
|
||||
[/\bsemi\s*bold\b/, 600],
|
||||
[/\bextra\s*bold\b/, 800],
|
||||
[/\bultra\s*bold\b/, 800],
|
||||
[/\bbold\b/, 700],
|
||||
[/\bblack\b/, 900],
|
||||
[/\bheavy\b/, 900],
|
||||
];
|
||||
|
||||
const FAMILY_NAME_STRIP =
|
||||
/\b(?:extra\s*light|ultra\s*light|extra\s*bold|ultra\s*bold|semi\s*bold|demi\s*bold|thin|hairline|light|regular|normal|medium|bold|black|heavy|italic|oblique)\b/gi;
|
||||
|
||||
export function familyNameFromFilename(filename: string): string | null {
|
||||
const base = filename.replace(/\.[^.]+$/, '');
|
||||
const cleaned = base.replace(/[-_]/g, ' ').replace(FAMILY_NAME_STRIP, '').replace(/\s+/g, ' ').trim();
|
||||
return cleaned || null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FontValidationService {
|
||||
private readonly logger = new Logger(FontValidationService.name);
|
||||
|
||||
validateFormat(buffer: Buffer, declaredFormat: FontFormat): void {
|
||||
if (!FONT_FORMATS.includes(declaredFormat)) {
|
||||
throw new BadRequestException(`Unsupported font format: ${declaredFormat}`);
|
||||
}
|
||||
|
||||
if (buffer.length < 4) {
|
||||
throw new BadRequestException('File too small to be a valid font');
|
||||
}
|
||||
|
||||
const magic = buffer.subarray(0, 4).toString('hex');
|
||||
const allowedFormats = MAGIC_BYTES[magic];
|
||||
|
||||
if (!allowedFormats) {
|
||||
throw new BadRequestException('File does not appear to be a valid font (unrecognized magic bytes)');
|
||||
}
|
||||
|
||||
if (!allowedFormats.includes(declaredFormat)) {
|
||||
throw new BadRequestException(`File content does not match declared format "${declaredFormat}"`);
|
||||
}
|
||||
}
|
||||
|
||||
extractMetadata(buffer: Buffer, filename: string): FontMetadata {
|
||||
try {
|
||||
const font = parse(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
|
||||
const familyName = (font.names?.fontFamily as Record<string, string>)?.en ?? null;
|
||||
const subfamilyName = ((font.names?.fontSubfamily as Record<string, string>)?.en ?? '').toLowerCase();
|
||||
|
||||
const weight = this.resolveWeight(font.tables?.os2?.usWeightClass, subfamilyName, filename);
|
||||
const style = this.resolveStyle(font.tables?.os2?.fsSelection, subfamilyName, filename);
|
||||
|
||||
return { familyName, weight, style };
|
||||
} catch (err) {
|
||||
this.logger.debug(
|
||||
`[font.extract_metadata] [fail] filename=${filename} error="${(err as 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;
|
||||
}
|
||||
|
||||
return this.weightFromString(subfamily) ?? this.weightFromString(this.normalizeFilename(filename)) ?? 400;
|
||||
}
|
||||
|
||||
private resolveStyle(fsSelection: number | undefined, subfamily: string, filename: string): FontStyle {
|
||||
if (fsSelection !== undefined && (fsSelection & 1) !== 0) {
|
||||
return 'italic';
|
||||
}
|
||||
|
||||
const combined = `${subfamily} ${this.normalizeFilename(filename)}`.toLowerCase();
|
||||
if (/\bitalic\b/.test(combined) || /\boblique\b/.test(combined)) {
|
||||
return 'italic';
|
||||
}
|
||||
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
private weightFromString(str: string): number | null {
|
||||
const lower = str.toLowerCase();
|
||||
for (const [pattern, weight] of WEIGHT_PATTERNS) {
|
||||
if (pattern.test(lower)) return weight;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private extractFromFilename(filename: string): FontMetadata {
|
||||
const normalized = this.normalizeFilename(filename);
|
||||
return {
|
||||
familyName: familyNameFromFilename(filename),
|
||||
weight: this.weightFromString(normalized) ?? 400,
|
||||
style: /\bitalic\b/i.test(normalized) || /\boblique\b/i.test(normalized) ? 'italic' : 'normal',
|
||||
};
|
||||
}
|
||||
|
||||
private familyNameFromFilename(filename: string): string | null {
|
||||
return familyNameFromFilename(filename);
|
||||
}
|
||||
|
||||
private normalizeFilename(filename: string): string {
|
||||
return filename.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user