feat(reader): increase font file size limit to 50MB for CJK fonts

Increase MAX_FONT_FILE_SIZE to 50MB to support large CJK and non-alphabetic fonts. Implement a lightweight binary font metadata parser to replace opentype.js buffer parsing, and update reader custom font composables to lazily manage blob URLs per font family.

Closes #793
This commit is contained in:
neon
2026-07-31 23:37:32 +00:00
parent d26f6999b4
commit 02debbe543
12 changed files with 13222 additions and 6766 deletions
+13 -9
View File
@@ -283,7 +283,7 @@ onMounted(async () => {
if (isAudioFormat || isPdfFormat || isComicFormat) return
await customFonts.fetchFonts()
setFontFaceCSS(customFonts.generateFontFaceCSS())
await refreshFontFaces()
await progress.load()
@@ -398,14 +398,18 @@ watch(showSettings, (open) => {
setVisibilityLock(open)
})
watch(
() => customFonts.fonts.value,
() => {
setFontFaceCSS(customFonts.generateFontFaceCSS())
const renderer = getRenderer()
if (renderer && shouldApplyStyles.value) applyToRenderer(renderer, isFixedLayout.value ? { flow: 'paginated' } : undefined)
},
)
// Only the selected family is held in memory, so the blob URLs backing the injected
// CSS have to be refreshed whenever either the selection or the font list changes.
async function refreshFontFaces() {
await customFonts.ensureCssFamilyLoaded(state.value.fontFamily)
setFontFaceCSS(customFonts.generateFontFaceCSS())
const renderer = getRenderer()
if (renderer && shouldApplyStyles.value) applyToRenderer(renderer, isFixedLayout.value ? { flow: 'paginated' } : undefined)
}
watch(() => customFonts.fonts.value, refreshFontFaces)
watch(() => state.value.fontFamily, refreshFontFaces)
function handleDeleteAnnotation(id: number) {
const ann = annotations.annotations.value.find((a) => a.id === id)
@@ -67,16 +67,15 @@ describe('useCustomFonts', () => {
})
describe('fetchFonts', () => {
it('fetches fonts and pre-caches blob URLs for all font files', async () => {
it('fetches the font list without downloading any font file', 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(fetchMock).toHaveBeenCalledExactlyOnceWith('/api/v1/fonts')
// Fonts can be tens of megabytes each; downloading the whole library on open
// would put gigabytes in tab memory.
expect(URL.createObjectURL).not.toHaveBeenCalled()
expect(composable.fonts.value).toHaveLength(3)
})
@@ -89,7 +88,7 @@ describe('useCustomFonts', () => {
expect(URL.createObjectURL).not.toHaveBeenCalled()
})
it('continues when individual font file fetch fails', async () => {
it('keeps the list usable when a 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)) })
@@ -98,6 +97,7 @@ describe('useCustomFonts', () => {
})
await composable.fetchFonts()
await composable.ensureCssFamilyLoaded(fontCssFamilyGroupName('Literata'))
expect(composable.fonts.value).toHaveLength(1)
expect(URL.createObjectURL).not.toHaveBeenCalled()
@@ -122,6 +122,7 @@ describe('useCustomFonts', () => {
it('revokes blob URLs for fonts removed between fetches', async () => {
setupFetchMock(mockFonts)
await composable.fetchFonts()
await composable.ensureCssFamilyLoaded(fontCssFamilyGroupName('Literata'))
vi.clearAllMocks()
@@ -130,24 +131,19 @@ describe('useCustomFonts', () => {
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(URL.revokeObjectURL).toHaveBeenCalledTimes(2)
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 () => {
it('uploads a font and adds it to the list without downloading it back', async () => {
const uploadResult: FontUploadResult = {
font: mockFonts[0]!,
suggestedFamilyName: 'Literata',
@@ -159,9 +155,6 @@ describe('useCustomFonts', () => {
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 })
})
@@ -169,8 +162,9 @@ describe('useCustomFonts', () => {
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)
// The client just sent these bytes; pulling them straight back wastes the transfer.
expect(fetchMock).not.toHaveBeenCalledWith('/api/v1/fonts/1/file')
expect(URL.createObjectURL).not.toHaveBeenCalled()
expect(result).toEqual(uploadResult)
expect(composable.fonts.value).toHaveLength(1)
})
@@ -207,10 +201,127 @@ describe('useCustomFonts', () => {
})
})
describe('ensureCssFamilyLoaded', () => {
it('downloads only the requested family', async () => {
setupFetchMock(mockFonts)
await composable.fetchFonts()
vi.clearAllMocks()
await composable.ensureCssFamilyLoaded(fontCssFamilyGroupName('Literata'))
expect(fetchMock).toHaveBeenCalledWith('/api/v1/fonts/1/file')
expect(fetchMock).toHaveBeenCalledWith('/api/v1/fonts/2/file')
expect(fetchMock).not.toHaveBeenCalledWith('/api/v1/fonts/3/file')
expect(URL.createObjectURL).toHaveBeenCalledTimes(2)
})
it('releases the previous family when switching, bounding memory to one family', async () => {
setupFetchMock(mockFonts)
await composable.fetchFonts()
await composable.ensureCssFamilyLoaded(fontCssFamilyGroupName('Literata'))
vi.clearAllMocks()
await composable.ensureCssFamilyLoaded(fontCssFamilyGroupName('Georgia Pro'))
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2)
expect(fetchMock).toHaveBeenCalledWith('/api/v1/fonts/3/file')
const css = composable.generateFontFaceCSS()
expect(css).toContain('/api/v1/fonts/1/file')
expect(css).toContain('/api/v1/fonts/2/file')
expect(css).not.toContain('/api/v1/fonts/3/file')
})
it('does not re-download a family that is already loaded', async () => {
setupFetchMock(mockFonts)
await composable.fetchFonts()
await composable.ensureCssFamilyLoaded(fontCssFamilyGroupName('Literata'))
vi.clearAllMocks()
await composable.ensureCssFamilyLoaded(fontCssFamilyGroupName('Literata'))
expect(fetchMock).not.toHaveBeenCalled()
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
expect(URL.createObjectURL).not.toHaveBeenCalled()
})
it('releases everything when the selection is cleared', async () => {
setupFetchMock(mockFonts)
await composable.fetchFonts()
await composable.ensureCssFamilyLoaded(fontCssFamilyGroupName('Literata'))
vi.clearAllMocks()
await composable.ensureCssFamilyLoaded(null)
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2)
expect(composable.generateFontFaceCSS()).not.toContain('blob:')
})
it('ignores a family name that matches no font', async () => {
setupFetchMock(mockFonts)
await composable.fetchFonts()
vi.clearAllMocks()
await composable.ensureCssFamilyLoaded(fontCssFamilyGroupName('Nonexistent'))
expect(fetchMock).not.toHaveBeenCalled()
expect(URL.createObjectURL).not.toHaveBeenCalled()
})
it('keeps only the newest family when a switch interrupts an in-flight load', async () => {
const listResponse = { ok: true, json: () => Promise.resolve(mockFonts) }
const releaseFontFile = new Map<string, () => void>()
fetchMock.mockImplementation((url: unknown) => {
if (url === '/api/v1/fonts') return Promise.resolve(listResponse)
return new Promise((resolve) => {
releaseFontFile.set(url as string, () => resolve({ ok: true, blob: () => Promise.resolve(new Blob(['font'])) }))
})
})
await composable.fetchFonts()
// Start loading Literata, then switch to Georgia Pro before its files arrive.
const literata = composable.ensureCssFamilyLoaded(fontCssFamilyGroupName('Literata'))
const georgia = composable.ensureCssFamilyLoaded(fontCssFamilyGroupName('Georgia Pro'))
releaseFontFile.get('/api/v1/fonts/3/file')!()
releaseFontFile.get('/api/v1/fonts/1/file')!()
releaseFontFile.get('/api/v1/fonts/2/file')!()
await Promise.all([literata, georgia])
// The abandoned family must not stay resident just because it finished late.
const css = composable.generateFontFaceCSS()
expect(css).toContain('/api/v1/fonts/1/file')
expect(css).toContain('/api/v1/fonts/2/file')
expect(css).not.toContain('/api/v1/fonts/3/file')
})
it('survives a failed font file download', async () => {
fetchMock.mockImplementation((url: unknown) => {
if (url === '/api/v1/fonts') {
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockFonts) })
}
if (url === '/api/v1/fonts/1/file') {
return Promise.resolve({ ok: true, blob: () => Promise.resolve(new Blob(['font'])) })
}
return Promise.resolve({ ok: false })
})
await composable.fetchFonts()
await expect(composable.ensureCssFamilyLoaded(fontCssFamilyGroupName('Literata'))).resolves.toBeUndefined()
const css = composable.generateFontFaceCSS()
expect(css).toContain('blob:test/')
expect(css).toContain('/api/v1/fonts/2/file')
})
})
describe('deleteFont', () => {
it('removes a font from the list and revokes its blob URL on success', async () => {
setupFetchMock(mockFonts)
await composable.fetchFonts()
await composable.ensureCssFamilyLoaded(fontCssFamilyGroupName('Literata'))
vi.clearAllMocks()
fetchMock.mockResolvedValue({ ok: true })
@@ -281,18 +392,22 @@ describe('useCustomFonts', () => {
})
describe('generateFontFaceCSS', () => {
it('uses blob URLs when available after fetchFonts', async () => {
it('uses blob URLs for the loaded family and API URLs for the rest', async () => {
setupFetchMock(mockFonts)
await composable.fetchFonts()
await composable.ensureCssFamilyLoaded(fontCssFamilyGroupName('Literata'))
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
// The loaded family resolves through blobs so the reader iframe can fetch it
// without auth headers; the unloaded one stays on its cookie-authenticated URL.
expect(css).toContain('blob:test/')
expect(css).not.toContain('/api/v1/fonts/1/file')
expect(css).not.toContain('/api/v1/fonts/2/file')
expect(css).toContain('/api/v1/fonts/3/file')
expect(css).toContain('font-weight: 400')
expect(css).toContain('font-weight: 700')
expect(css).toContain('format("truetype")')
@@ -16,6 +16,7 @@ export function useCustomFonts() {
const loading = ref(false)
const uploading = ref(false)
const fontBlobUrls = new Map<number, string>()
let requestedCssFamily: string | null = null
function revokeFontBlobUrl(fontId: number) {
const url = fontBlobUrls.get(fontId)
@@ -48,13 +49,45 @@ export function useCustomFonts() {
if (!newFontIds.has(id)) revokeFontBlobUrl(id)
}
fonts.value = newFonts
await Promise.allSettled(fonts.value.map((f) => cacheFontBlobUrl(f.id)))
}
} finally {
loading.value = false
}
}
function variantsOf(cssFamilyName: string | null): UserFont[] {
if (!cssFamilyName) return []
return fonts.value.filter((f) => fontCssFamilyGroupName(f.familyName) === cssFamilyName)
}
/** Releases every blob URL not belonging to the most recently requested family. */
function revokeUnrequestedBlobUrls() {
const keep = new Set(variantsOf(requestedCssFamily).map((f) => f.id))
for (const id of fontBlobUrls.keys()) {
if (!keep.has(id)) revokeFontBlobUrl(id)
}
}
/**
* Loads blob URLs for one family and releases every other family's.
*
* Individual fonts run to tens of megabytes, so holding a whole library of them in
* memory is not viable. The reader renders one family at a time, and everywhere else
* (settings previews) runs in the main document, where generateFontFaceCSS falls back
* to the API URL and the browser fetches only what it actually renders.
*/
async function ensureCssFamilyLoaded(cssFamilyName: string | null): Promise<void> {
requestedCssFamily = cssFamilyName
revokeUnrequestedBlobUrls()
const variants = variantsOf(cssFamilyName)
await Promise.allSettled(variants.filter((f) => !fontBlobUrls.has(f.id)).map((f) => cacheFontBlobUrl(f.id)))
// Switching families mid-download would otherwise leave the superseded family
// resident, since its downloads land after the newer request already pruned.
revokeUnrequestedBlobUrls()
}
async function uploadFont(file: File): Promise<FontUploadResult | null> {
uploading.value = true
try {
@@ -70,7 +103,6 @@ export function useCustomFonts() {
}
const result: FontUploadResult = await res.json()
fonts.value = [...fonts.value, result.font]
await cacheFontBlobUrl(result.font.id)
return result
} finally {
uploading.value = false
@@ -117,8 +149,9 @@ export function useCustomFonts() {
/**
* 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().
* browser automatically pick bold/italic variants. Prefers a blob URL when the
* family has been loaded via ensureCssFamilyLoaded, since the reader iframe cannot
* send auth headers in CSS url(); elsewhere the API URL authenticates by cookie.
*/
function generateFontFaceCSS(): string {
if (fonts.value.length === 0) return ''
@@ -160,6 +193,7 @@ export function useCustomFonts() {
loading,
uploading,
fetchFonts,
ensureCssFamilyLoaded,
uploadFont,
updateFont,
deleteFont,