mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -42,7 +42,9 @@ export const FONT_FORMAT_CSS_FORMAT: Record<FontFormat, string> = {
|
||||
woff2: "woff2",
|
||||
};
|
||||
|
||||
export const MAX_FONT_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
// Sized for CJK and other non-alphabetic fonts, which routinely exceed 30 MB because
|
||||
// they carry tens of thousands of glyphs.
|
||||
export const MAX_FONT_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||
export const MAX_FONTS_PER_USER = 50;
|
||||
|
||||
export const FONT_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900] as const;
|
||||
|
||||
Generated
+12209
-6612
File diff suppressed because it is too large
Load Diff
@@ -88,7 +88,6 @@
|
||||
"nestjs-pino": "^4.6.1",
|
||||
"node-unrar-js": "^2.0.2",
|
||||
"nodemailer": "^9.0.3",
|
||||
"opentype.js": "^2.0.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
@@ -116,7 +115,6 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/opentype.js": "^1.3.10",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/supertest": "^7.2.1",
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { parseFontMetadata } from './font-metadata.parser';
|
||||
import {
|
||||
LANG_WINDOWS_EN_GB,
|
||||
LANG_WINDOWS_EN_US,
|
||||
LANG_WINDOWS_JAPANESE,
|
||||
PLATFORM_WINDOWS,
|
||||
buildNameTable,
|
||||
buildOs2Table,
|
||||
buildSfnt,
|
||||
buildWoff,
|
||||
macName,
|
||||
windowsName,
|
||||
} from './font-test-fixtures';
|
||||
|
||||
describe('parseFontMetadata', () => {
|
||||
describe('container formats', () => {
|
||||
it('parses a TrueType font (version 1.0 signature)', () => {
|
||||
const font = buildSfnt([
|
||||
{ tag: 'name', data: buildNameTable([windowsName(1, 'Literata'), windowsName(2, 'Bold')]) },
|
||||
{ tag: 'OS/2', data: buildOs2Table({ usWeightClass: 700, fsSelection: 32 }) },
|
||||
]);
|
||||
|
||||
expect(parseFontMetadata(font)).toEqual({
|
||||
familyName: 'Literata',
|
||||
subfamilyName: 'Bold',
|
||||
usWeightClass: 700,
|
||||
fsSelection: 32,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['true', Buffer.from('true', 'latin1')],
|
||||
['typ1', Buffer.from('typ1', 'latin1')],
|
||||
['OTTO', Buffer.from('OTTO', 'latin1')],
|
||||
])('parses a font with the %s signature', (_label, signature) => {
|
||||
const font = buildSfnt([{ tag: 'name', data: buildNameTable([windowsName(1, 'Iosevka')]) }], signature);
|
||||
expect(parseFontMetadata(font).familyName).toBe('Iosevka');
|
||||
});
|
||||
|
||||
it('rejects WOFF2, which needs a brotli decompressor', () => {
|
||||
const font = Buffer.alloc(64);
|
||||
font.write('wOF2', 0, 4, 'latin1');
|
||||
expect(() => parseFontMetadata(font)).toThrow(/WOFF2/);
|
||||
});
|
||||
|
||||
it('rejects an unrecognized signature', () => {
|
||||
expect(() => parseFontMetadata(Buffer.alloc(64, 0xff))).toThrow(/Unrecognized font signature/);
|
||||
});
|
||||
|
||||
it('rejects a buffer too small to hold a table directory', () => {
|
||||
expect(() => parseFontMetadata(Buffer.from([0x00, 0x01, 0x00, 0x00]))).toThrow(/too small/);
|
||||
});
|
||||
|
||||
it('rejects a truncated WOFF header', () => {
|
||||
const font = Buffer.alloc(20);
|
||||
font.write('wOFF', 0, 4, 'latin1');
|
||||
expect(() => parseFontMetadata(font)).toThrow(/WOFF header is truncated/);
|
||||
});
|
||||
|
||||
it('rejects an implausible table count rather than allocating for it', () => {
|
||||
const font = buildSfnt([{ tag: 'name', data: buildNameTable([windowsName(1, 'X')]) }], undefined, 60000);
|
||||
expect(() => parseFontMetadata(font)).toThrow(/Implausible table count/);
|
||||
});
|
||||
|
||||
it('rejects a zero table count', () => {
|
||||
const font = buildSfnt([{ tag: 'name', data: buildNameTable([windowsName(1, 'X')]) }], undefined, 0);
|
||||
expect(() => parseFontMetadata(font)).toThrow(/Implausible table count/);
|
||||
});
|
||||
|
||||
it('rejects a directory that extends past the end of the font', () => {
|
||||
const font = buildSfnt([{ tag: 'name', data: buildNameTable([windowsName(1, 'X')]) }], undefined, 400);
|
||||
expect(() => parseFontMetadata(font)).toThrow(/extends past end/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('name table', () => {
|
||||
it('decodes Windows UTF-16BE names', () => {
|
||||
const font = buildSfnt([{ tag: 'name', data: buildNameTable([windowsName(1, 'Noto Sans JP'), windowsName(2, 'Regular')]) }]);
|
||||
const result = parseFontMetadata(font);
|
||||
expect(result.familyName).toBe('Noto Sans JP');
|
||||
expect(result.subfamilyName).toBe('Regular');
|
||||
});
|
||||
|
||||
it('decodes Macintosh latin1 names', () => {
|
||||
const font = buildSfnt([{ tag: 'name', data: buildNameTable([macName(1, 'Charter'), macName(2, 'Italic')]) }]);
|
||||
const result = parseFontMetadata(font);
|
||||
expect(result.familyName).toBe('Charter');
|
||||
expect(result.subfamilyName).toBe('Italic');
|
||||
});
|
||||
|
||||
it('prefers typographic names so weight variants group under one family', () => {
|
||||
const font = buildSfnt([
|
||||
{
|
||||
tag: 'name',
|
||||
data: buildNameTable([
|
||||
windowsName(1, 'Source Han Sans Light'),
|
||||
windowsName(2, 'Regular'),
|
||||
windowsName(16, 'Source Han Sans'),
|
||||
windowsName(17, 'Light'),
|
||||
]),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = parseFontMetadata(font);
|
||||
expect(result.familyName).toBe('Source Han Sans');
|
||||
expect(result.subfamilyName).toBe('Light');
|
||||
});
|
||||
|
||||
it('falls back to legacy names when typographic names are absent', () => {
|
||||
const font = buildSfnt([{ tag: 'name', data: buildNameTable([windowsName(1, 'Legacy Only'), windowsName(2, 'Bold')]) }]);
|
||||
const result = parseFontMetadata(font);
|
||||
expect(result.familyName).toBe('Legacy Only');
|
||||
expect(result.subfamilyName).toBe('Bold');
|
||||
});
|
||||
|
||||
it('prefers the Windows record when Windows and Mac both provide English', () => {
|
||||
const font = buildSfnt([
|
||||
{
|
||||
tag: 'name',
|
||||
data: buildNameTable([macName(1, 'Mac Variant'), windowsName(1, 'Windows Variant')]),
|
||||
},
|
||||
]);
|
||||
expect(parseFontMetadata(font).familyName).toBe('Windows Variant');
|
||||
});
|
||||
|
||||
it('accepts non-US English variants such as en-GB', () => {
|
||||
const font = buildSfnt([{ tag: 'name', data: buildNameTable([windowsName(1, 'Gill Sans', LANG_WINDOWS_EN_GB)]) }]);
|
||||
expect(parseFontMetadata(font).familyName).toBe('Gill Sans');
|
||||
});
|
||||
|
||||
it('ignores non-English records and keeps the English name', () => {
|
||||
const font = buildSfnt([
|
||||
{
|
||||
tag: 'name',
|
||||
data: buildNameTable([windowsName(1, 'Japanese Name', LANG_WINDOWS_JAPANESE), windowsName(1, 'English Name')]),
|
||||
},
|
||||
]);
|
||||
expect(parseFontMetadata(font).familyName).toBe('English Name');
|
||||
});
|
||||
|
||||
it('returns null names when every record is non-English', () => {
|
||||
const font = buildSfnt([{ tag: 'name', data: buildNameTable([windowsName(1, 'Japanese Only', LANG_WINDOWS_JAPANESE)]) }]);
|
||||
expect(parseFontMetadata(font).familyName).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null names when the font has no name table', () => {
|
||||
const font = buildSfnt([{ tag: 'OS/2', data: buildOs2Table({ usWeightClass: 400 }) }]);
|
||||
const result = parseFontMetadata(font);
|
||||
expect(result.familyName).toBeNull();
|
||||
expect(result.subfamilyName).toBeNull();
|
||||
expect(result.usWeightClass).toBe(400);
|
||||
});
|
||||
|
||||
it('skips a record whose string extends past the end of the table', () => {
|
||||
const data = buildNameTable([{ platformID: PLATFORM_WINDOWS, languageID: LANG_WINDOWS_EN_US, nameID: 1, value: 'X', offsetOverride: 60000 }]);
|
||||
const font = buildSfnt([{ tag: 'name', data }]);
|
||||
expect(parseFontMetadata(font).familyName).toBeNull();
|
||||
});
|
||||
|
||||
it('stops cleanly when the record count exceeds the actual record data', () => {
|
||||
const data = buildNameTable([windowsName(1, 'Truncated')], 500);
|
||||
const font = buildSfnt([{ tag: 'name', data }]);
|
||||
expect(parseFontMetadata(font).familyName).toBe('Truncated');
|
||||
});
|
||||
|
||||
it('skips an odd-length UTF-16 value rather than throwing', () => {
|
||||
const data = buildNameTable([
|
||||
{ platformID: PLATFORM_WINDOWS, languageID: LANG_WINDOWS_EN_US, nameID: 1, value: '', rawValue: Buffer.from([0x00, 0x41, 0x00]) },
|
||||
]);
|
||||
const font = buildSfnt([{ tag: 'name', data }]);
|
||||
expect(parseFontMetadata(font).familyName).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a whitespace-only name', () => {
|
||||
const font = buildSfnt([{ tag: 'name', data: buildNameTable([windowsName(1, ' ')]) }]);
|
||||
expect(parseFontMetadata(font).familyName).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mutate the caller buffer while byte-swapping UTF-16 names', () => {
|
||||
const font = buildSfnt([{ tag: 'name', data: buildNameTable([windowsName(1, 'Immutable')]) }]);
|
||||
const before = Buffer.from(font);
|
||||
|
||||
expect(parseFontMetadata(font).familyName).toBe('Immutable');
|
||||
expect(font.equals(before)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OS/2 table', () => {
|
||||
it('reads weight class and fsSelection', () => {
|
||||
const font = buildSfnt([{ tag: 'OS/2', data: buildOs2Table({ usWeightClass: 350, fsSelection: 1 }) }]);
|
||||
const result = parseFontMetadata(font);
|
||||
expect(result.usWeightClass).toBe(350);
|
||||
expect(result.fsSelection).toBe(1);
|
||||
});
|
||||
|
||||
it('returns undefined fields when the font has no OS/2 table', () => {
|
||||
const font = buildSfnt([{ tag: 'name', data: buildNameTable([windowsName(1, 'No OS2')]) }]);
|
||||
const result = parseFontMetadata(font);
|
||||
expect(result.usWeightClass).toBeUndefined();
|
||||
expect(result.fsSelection).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still reads the weight when the table is too short to hold fsSelection', () => {
|
||||
const font = buildSfnt([{ tag: 'OS/2', data: buildOs2Table({ usWeightClass: 600, length: 32 }) }]);
|
||||
const result = parseFontMetadata(font);
|
||||
expect(result.usWeightClass).toBe(600);
|
||||
expect(result.fsSelection).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WOFF', () => {
|
||||
it('reads uncompressed WOFF tables', () => {
|
||||
const font = buildWoff([
|
||||
{ tag: 'name', data: buildNameTable([windowsName(1, 'Woff Family'), windowsName(2, 'Italic')]) },
|
||||
{ tag: 'OS/2', data: buildOs2Table({ usWeightClass: 300, fsSelection: 1 }) },
|
||||
]);
|
||||
|
||||
expect(parseFontMetadata(font)).toEqual({
|
||||
familyName: 'Woff Family',
|
||||
subfamilyName: 'Italic',
|
||||
usWeightClass: 300,
|
||||
fsSelection: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('inflates zlib-compressed WOFF tables', () => {
|
||||
const font = buildWoff([
|
||||
{ tag: 'name', data: buildNameTable([windowsName(1, 'Compressed Family')]), compress: true },
|
||||
{ tag: 'OS/2', data: buildOs2Table({ usWeightClass: 800, fsSelection: 0 }), compress: true },
|
||||
]);
|
||||
|
||||
const result = parseFontMetadata(font);
|
||||
expect(result.familyName).toBe('Compressed Family');
|
||||
expect(result.usWeightClass).toBe(800);
|
||||
});
|
||||
|
||||
it('returns null for a table whose compressed bytes are corrupt', () => {
|
||||
const font = buildWoff([
|
||||
{ tag: 'name', data: Buffer.from('not actually deflate data'), declaredOrigLength: 9999 },
|
||||
{ tag: 'OS/2', data: buildOs2Table({ usWeightClass: 400 }) },
|
||||
]);
|
||||
|
||||
const result = parseFontMetadata(font);
|
||||
expect(result.familyName).toBeNull();
|
||||
expect(result.usWeightClass).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed and hostile input', () => {
|
||||
it('returns null for a table whose offset points past the end of the font', () => {
|
||||
const font = buildSfnt([{ tag: 'name', data: buildNameTable([windowsName(1, 'X')]), declaredOffset: 900000 }]);
|
||||
expect(parseFontMetadata(font).familyName).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a table declaring a length past the end of the font', () => {
|
||||
const font = buildSfnt([{ tag: 'name', data: buildNameTable([windowsName(1, 'X')]), declaredLength: 900000 }]);
|
||||
expect(parseFontMetadata(font).familyName).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a metadata table declaring an absurd size', () => {
|
||||
const font = buildSfnt([{ tag: 'name', data: buildNameTable([windowsName(1, 'X')]), declaredLength: 64 * 1024 * 1024 }]);
|
||||
expect(parseFontMetadata(font).familyName).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to inflate a decompression bomb', () => {
|
||||
// 8 MB of zeros compresses to a few KB and would inflate past the metadata cap.
|
||||
const bomb = Buffer.alloc(8 * 1024 * 1024, 0);
|
||||
const font = buildWoff([{ tag: 'name', data: bomb, compress: true, declaredOrigLength: bomb.length }]);
|
||||
expect(parseFontMetadata(font).familyName).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores tables other than name and OS/2', () => {
|
||||
const font = buildSfnt([
|
||||
{ tag: 'glyf', data: Buffer.alloc(1024, 0xab) },
|
||||
{ tag: 'name', data: buildNameTable([windowsName(1, 'Selective')]) },
|
||||
{ tag: 'loca', data: Buffer.alloc(512, 0xcd) },
|
||||
]);
|
||||
expect(parseFontMetadata(font).familyName).toBe('Selective');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scale', () => {
|
||||
it('parses a 40 MB font without cost growing with file size', () => {
|
||||
// A real CJK font is large because of its glyph data. Padding stands in for that:
|
||||
// the parser must never touch it, so both time and heap stay flat.
|
||||
const glyphData = Buffer.alloc(40 * 1024 * 1024, 0x00);
|
||||
const font = buildSfnt([
|
||||
{ tag: 'name', data: buildNameTable([windowsName(16, 'Source Han Sans'), windowsName(17, 'Bold')]) },
|
||||
{ tag: 'OS/2', data: buildOs2Table({ usWeightClass: 700, fsSelection: 32 }) },
|
||||
{ tag: 'glyf', data: glyphData },
|
||||
]);
|
||||
|
||||
const heapBefore = process.memoryUsage().heapUsed;
|
||||
const startedAt = process.hrtime.bigint();
|
||||
const result = parseFontMetadata(font);
|
||||
const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
|
||||
const heapGrowthMb = (process.memoryUsage().heapUsed - heapBefore) / 1024 / 1024;
|
||||
|
||||
expect(result).toEqual({
|
||||
familyName: 'Source Han Sans',
|
||||
subfamilyName: 'Bold',
|
||||
usWeightClass: 700,
|
||||
fsSelection: 32,
|
||||
});
|
||||
// A full parse of a font this size cost hundreds of megabytes and hundreds of ms.
|
||||
expect(heapGrowthMb).toBeLessThan(25);
|
||||
expect(elapsedMs).toBeLessThan(250);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import { inflateSync } from 'zlib';
|
||||
|
||||
export interface RawFontMetadata {
|
||||
familyName: string | null;
|
||||
subfamilyName: string | null;
|
||||
usWeightClass: number | undefined;
|
||||
fsSelection: number | undefined;
|
||||
}
|
||||
|
||||
const SFNT_TABLE_DIRECTORY_OFFSET = 12;
|
||||
const SFNT_TABLE_ENTRY_SIZE = 16;
|
||||
const WOFF_TABLE_DIRECTORY_OFFSET = 44;
|
||||
const WOFF_TABLE_ENTRY_SIZE = 20;
|
||||
|
||||
// A font may legitimately carry a few dozen tables. Anything beyond this is malformed
|
||||
// or crafted, and the count controls how much we read, so bound it before allocating.
|
||||
const MAX_TABLE_COUNT = 512;
|
||||
|
||||
// `name` and `OS/2` are metadata tables measured in kilobytes. A larger declared size
|
||||
// means the file is malformed or hostile, so refuse to allocate or inflate for it.
|
||||
const MAX_METADATA_TABLE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
const NAME_ID_FAMILY = 1;
|
||||
const NAME_ID_SUBFAMILY = 2;
|
||||
const NAME_ID_TYPOGRAPHIC_FAMILY = 16;
|
||||
const NAME_ID_TYPOGRAPHIC_SUBFAMILY = 17;
|
||||
|
||||
const PLATFORM_UNICODE = 0;
|
||||
const PLATFORM_MACINTOSH = 1;
|
||||
const PLATFORM_WINDOWS = 3;
|
||||
|
||||
const MAC_LANGUAGE_ENGLISH = 0;
|
||||
const WINDOWS_PRIMARY_LANGUAGE_ENGLISH = 0x09;
|
||||
const WINDOWS_PRIMARY_LANGUAGE_MASK = 0x3ff;
|
||||
|
||||
const OS2_WEIGHT_CLASS_OFFSET = 4;
|
||||
const OS2_FS_SELECTION_OFFSET = 62;
|
||||
|
||||
const NAME_RECORD_SIZE = 12;
|
||||
const NAME_TABLE_HEADER_SIZE = 6;
|
||||
|
||||
interface TableEntry {
|
||||
offset: number;
|
||||
compLength: number;
|
||||
origLength: number;
|
||||
}
|
||||
|
||||
interface TableDirectory {
|
||||
entries: Map<string, TableEntry>;
|
||||
isWoff: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads font metadata by locating only the `name` and `OS/2` tables.
|
||||
*
|
||||
* A full font parse materialises an object per glyph, which for a CJK font of tens of
|
||||
* thousands of glyphs costs hundreds of megabytes to obtain a family name and two
|
||||
* integers. Reading the two metadata tables directly keeps cost independent of both
|
||||
* file size and glyph count.
|
||||
*
|
||||
* Throws when the container cannot be read at all; callers fall back to filename
|
||||
* heuristics. Individual tables that are absent or unreadable yield null/undefined
|
||||
* fields rather than throwing, so a font with a damaged `name` table still reports
|
||||
* whatever `OS/2` provides.
|
||||
*/
|
||||
export function parseFontMetadata(buffer: Buffer): RawFontMetadata {
|
||||
const { entries, isWoff } = readTableDirectory(buffer);
|
||||
|
||||
const nameTable = readTable(buffer, entries.get('name'), isWoff);
|
||||
const os2Table = readTable(buffer, entries.get('OS/2'), isWoff);
|
||||
|
||||
return { ...parseNameTable(nameTable), ...parseOs2Table(os2Table) };
|
||||
}
|
||||
|
||||
function readTableDirectory(buffer: Buffer): TableDirectory {
|
||||
if (buffer.length < SFNT_TABLE_DIRECTORY_OFFSET) {
|
||||
throw new Error('Font is too small to contain a table directory');
|
||||
}
|
||||
|
||||
const signature = buffer.toString('latin1', 0, 4);
|
||||
const isTrueTypeVersion1 = buffer.readUInt32BE(0) === 0x00010000;
|
||||
|
||||
let numTables: number;
|
||||
let directoryOffset: number;
|
||||
let entrySize: number;
|
||||
let isWoff: boolean;
|
||||
|
||||
if (isTrueTypeVersion1 || signature === 'true' || signature === 'typ1' || signature === 'OTTO') {
|
||||
numTables = buffer.readUInt16BE(4);
|
||||
directoryOffset = SFNT_TABLE_DIRECTORY_OFFSET;
|
||||
entrySize = SFNT_TABLE_ENTRY_SIZE;
|
||||
isWoff = false;
|
||||
} else if (signature === 'wOFF') {
|
||||
if (buffer.length < WOFF_TABLE_DIRECTORY_OFFSET) {
|
||||
throw new Error('WOFF header is truncated');
|
||||
}
|
||||
numTables = buffer.readUInt16BE(12);
|
||||
directoryOffset = WOFF_TABLE_DIRECTORY_OFFSET;
|
||||
entrySize = WOFF_TABLE_ENTRY_SIZE;
|
||||
isWoff = true;
|
||||
} else if (signature === 'wOF2') {
|
||||
throw new Error('WOFF2 metadata extraction is not supported');
|
||||
} else {
|
||||
throw new Error('Unrecognized font signature');
|
||||
}
|
||||
|
||||
if (numTables === 0 || numTables > MAX_TABLE_COUNT) {
|
||||
throw new Error('Implausible table count');
|
||||
}
|
||||
if (directoryOffset + numTables * entrySize > buffer.length) {
|
||||
throw new Error('Table directory extends past end of font');
|
||||
}
|
||||
|
||||
const entries = new Map<string, TableEntry>();
|
||||
for (let i = 0; i < numTables; i += 1) {
|
||||
const record = directoryOffset + i * entrySize;
|
||||
const tag = buffer.toString('latin1', record, record + 4);
|
||||
if (tag !== 'name' && tag !== 'OS/2') continue;
|
||||
|
||||
entries.set(
|
||||
tag,
|
||||
isWoff
|
||||
? {
|
||||
offset: buffer.readUInt32BE(record + 4),
|
||||
compLength: buffer.readUInt32BE(record + 8),
|
||||
origLength: buffer.readUInt32BE(record + 12),
|
||||
}
|
||||
: {
|
||||
offset: buffer.readUInt32BE(record + 8),
|
||||
compLength: buffer.readUInt32BE(record + 12),
|
||||
origLength: buffer.readUInt32BE(record + 12),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return { entries, isWoff };
|
||||
}
|
||||
|
||||
function readTable(buffer: Buffer, entry: TableEntry | undefined, isWoff: boolean): Buffer | null {
|
||||
if (!entry) return null;
|
||||
if (entry.compLength > MAX_METADATA_TABLE_BYTES || entry.origLength > MAX_METADATA_TABLE_BYTES) return null;
|
||||
if (entry.offset + entry.compLength > buffer.length) return null;
|
||||
|
||||
const raw = buffer.subarray(entry.offset, entry.offset + entry.compLength);
|
||||
if (!isWoff || entry.compLength === entry.origLength) return raw;
|
||||
|
||||
try {
|
||||
return inflateSync(raw, { maxOutputLength: MAX_METADATA_TABLE_BYTES });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks a name record by how well it answers "what would an English-speaking user call
|
||||
* this font?". Zero means the record is for another language and must be ignored.
|
||||
*/
|
||||
function englishScore(platformID: number, languageID: number): number {
|
||||
if (platformID === PLATFORM_WINDOWS && (languageID & WINDOWS_PRIMARY_LANGUAGE_MASK) === WINDOWS_PRIMARY_LANGUAGE_ENGLISH) return 3;
|
||||
if (platformID === PLATFORM_MACINTOSH && languageID === MAC_LANGUAGE_ENGLISH) return 2;
|
||||
if (platformID === PLATFORM_UNICODE) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function decodeNameValue(raw: Buffer, platformID: number): string | null {
|
||||
if (platformID === PLATFORM_WINDOWS || platformID === PLATFORM_UNICODE) {
|
||||
if (raw.length % 2 !== 0) return null;
|
||||
// swap16 mutates in place, so copy rather than corrupting the caller's buffer.
|
||||
return Buffer.from(raw).swap16().toString('utf16le');
|
||||
}
|
||||
return raw.toString('latin1');
|
||||
}
|
||||
|
||||
interface NameCandidate {
|
||||
value: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
function parseNameTable(table: Buffer | null): Pick<RawFontMetadata, 'familyName' | 'subfamilyName'> {
|
||||
if (!table || table.length < NAME_TABLE_HEADER_SIZE) {
|
||||
return { familyName: null, subfamilyName: null };
|
||||
}
|
||||
|
||||
const count = table.readUInt16BE(2);
|
||||
const storageOffset = table.readUInt16BE(4);
|
||||
|
||||
let family: NameCandidate | null = null;
|
||||
let subfamily: NameCandidate | null = null;
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const record = NAME_TABLE_HEADER_SIZE + i * NAME_RECORD_SIZE;
|
||||
if (record + NAME_RECORD_SIZE > table.length) break;
|
||||
|
||||
const platformID = table.readUInt16BE(record);
|
||||
const languageID = table.readUInt16BE(record + 4);
|
||||
const nameID = table.readUInt16BE(record + 6);
|
||||
|
||||
// Typographic names group weight/style variants under one family ("Source Han Sans"
|
||||
// rather than "Source Han Sans Light"), which is what the reader needs to let the
|
||||
// browser pick variants, so prefer them over the legacy names when both exist.
|
||||
const isFamily = nameID === NAME_ID_FAMILY || nameID === NAME_ID_TYPOGRAPHIC_FAMILY;
|
||||
const isSubfamily = nameID === NAME_ID_SUBFAMILY || nameID === NAME_ID_TYPOGRAPHIC_SUBFAMILY;
|
||||
if (!isFamily && !isSubfamily) continue;
|
||||
|
||||
const language = englishScore(platformID, languageID);
|
||||
if (language === 0) continue;
|
||||
|
||||
const isTypographic = nameID === NAME_ID_TYPOGRAPHIC_FAMILY || nameID === NAME_ID_TYPOGRAPHIC_SUBFAMILY;
|
||||
const score = (isTypographic ? 10 : 0) + language;
|
||||
|
||||
const current = isFamily ? family : subfamily;
|
||||
if (current && current.score >= score) continue;
|
||||
|
||||
const length = table.readUInt16BE(record + 8);
|
||||
const offset = table.readUInt16BE(record + 10);
|
||||
const start = storageOffset + offset;
|
||||
if (start + length > table.length) continue;
|
||||
|
||||
const value = decodeNameValue(table.subarray(start, start + length), platformID)?.trim();
|
||||
if (!value) continue;
|
||||
|
||||
if (isFamily) family = { value, score };
|
||||
else subfamily = { value, score };
|
||||
}
|
||||
|
||||
return { familyName: family?.value ?? null, subfamilyName: subfamily?.value ?? null };
|
||||
}
|
||||
|
||||
function parseOs2Table(table: Buffer | null): Pick<RawFontMetadata, 'usWeightClass' | 'fsSelection'> {
|
||||
if (!table) return { usWeightClass: undefined, fsSelection: undefined };
|
||||
|
||||
return {
|
||||
usWeightClass: table.length >= OS2_WEIGHT_CLASS_OFFSET + 2 ? table.readUInt16BE(OS2_WEIGHT_CLASS_OFFSET) : undefined,
|
||||
fsSelection: table.length >= OS2_FS_SELECTION_OFFSET + 2 ? table.readUInt16BE(OS2_FS_SELECTION_OFFSET) : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { deflateSync } from 'zlib';
|
||||
|
||||
/**
|
||||
* Builders for synthetic font binaries, so metadata tests exercise real byte layouts
|
||||
* instead of trusting a mock to describe them. Each builder allows deliberately
|
||||
* malformed output (declared lengths, offsets and counts that disagree with reality)
|
||||
* so parsers can be tested against hostile input.
|
||||
*/
|
||||
|
||||
export const PLATFORM_UNICODE = 0;
|
||||
export const PLATFORM_MAC = 1;
|
||||
export const PLATFORM_WINDOWS = 3;
|
||||
|
||||
export const LANG_WINDOWS_EN_US = 0x0409;
|
||||
export const LANG_WINDOWS_EN_GB = 0x0809;
|
||||
export const LANG_WINDOWS_JAPANESE = 0x0411;
|
||||
export const LANG_MAC_ENGLISH = 0;
|
||||
|
||||
export const NAME_ID_FAMILY = 1;
|
||||
export const NAME_ID_SUBFAMILY = 2;
|
||||
export const NAME_ID_TYPOGRAPHIC_FAMILY = 16;
|
||||
export const NAME_ID_TYPOGRAPHIC_SUBFAMILY = 17;
|
||||
|
||||
export interface NameRecord {
|
||||
platformID: number;
|
||||
languageID: number;
|
||||
nameID: number;
|
||||
value: string;
|
||||
/** Overrides the encoded string, for exercising malformed records. */
|
||||
rawValue?: Buffer;
|
||||
/** Overrides the storage offset, for pointing a record outside the table. */
|
||||
offsetOverride?: number;
|
||||
}
|
||||
|
||||
export interface TableInput {
|
||||
tag: string;
|
||||
data: Buffer;
|
||||
/** Declares a length different from the real one, for malformed-input tests. */
|
||||
declaredLength?: number;
|
||||
/** Declares an offset different from the real one, for out-of-bounds tests. */
|
||||
declaredOffset?: number;
|
||||
}
|
||||
|
||||
export interface WoffTableInput extends TableInput {
|
||||
compress?: boolean;
|
||||
declaredOrigLength?: number;
|
||||
}
|
||||
|
||||
function encodeNameValue(record: NameRecord): Buffer {
|
||||
if (record.rawValue) return record.rawValue;
|
||||
if (record.platformID === PLATFORM_WINDOWS || record.platformID === PLATFORM_UNICODE) {
|
||||
return Buffer.from(record.value, 'utf16le').swap16();
|
||||
}
|
||||
return Buffer.from(record.value, 'latin1');
|
||||
}
|
||||
|
||||
export function buildNameTable(records: NameRecord[], countOverride?: number): Buffer {
|
||||
const encoded = records.map(encodeNameValue);
|
||||
const storageOffset = 6 + records.length * 12;
|
||||
|
||||
const header = Buffer.alloc(6);
|
||||
header.writeUInt16BE(0, 0);
|
||||
header.writeUInt16BE(countOverride ?? records.length, 2);
|
||||
header.writeUInt16BE(storageOffset, 4);
|
||||
|
||||
const recordBytes = Buffer.alloc(records.length * 12);
|
||||
let cursor = 0;
|
||||
records.forEach((record, i) => {
|
||||
const at = i * 12;
|
||||
recordBytes.writeUInt16BE(record.platformID, at);
|
||||
recordBytes.writeUInt16BE(record.platformID === PLATFORM_WINDOWS ? 1 : 0, at + 2);
|
||||
recordBytes.writeUInt16BE(record.languageID, at + 4);
|
||||
recordBytes.writeUInt16BE(record.nameID, at + 6);
|
||||
recordBytes.writeUInt16BE(encoded[i]!.length, at + 8);
|
||||
recordBytes.writeUInt16BE(record.offsetOverride ?? cursor, at + 10);
|
||||
cursor += encoded[i]!.length;
|
||||
});
|
||||
|
||||
return Buffer.concat([header, recordBytes, ...encoded]);
|
||||
}
|
||||
|
||||
export function buildOs2Table(options: { usWeightClass?: number; fsSelection?: number; length?: number } = {}): Buffer {
|
||||
const table = Buffer.alloc(options.length ?? 96);
|
||||
if (options.usWeightClass !== undefined && table.length >= 6) table.writeUInt16BE(options.usWeightClass, 4);
|
||||
if (options.fsSelection !== undefined && table.length >= 64) table.writeUInt16BE(options.fsSelection, 62);
|
||||
return table;
|
||||
}
|
||||
|
||||
export function buildSfnt(tables: TableInput[], signature: Buffer = Buffer.from([0x00, 0x01, 0x00, 0x00]), numTablesOverride?: number): Buffer {
|
||||
const header = Buffer.alloc(12);
|
||||
signature.copy(header, 0);
|
||||
header.writeUInt16BE(numTablesOverride ?? tables.length, 4);
|
||||
|
||||
const directory = Buffer.alloc(tables.length * 16);
|
||||
const body: Buffer[] = [];
|
||||
let offset = 12 + tables.length * 16;
|
||||
|
||||
tables.forEach((table, i) => {
|
||||
const at = i * 16;
|
||||
directory.write(table.tag, at, 4, 'latin1');
|
||||
directory.writeUInt32BE(0, at + 4);
|
||||
directory.writeUInt32BE(table.declaredOffset ?? offset, at + 8);
|
||||
directory.writeUInt32BE(table.declaredLength ?? table.data.length, at + 12);
|
||||
body.push(table.data);
|
||||
offset += table.data.length;
|
||||
});
|
||||
|
||||
return Buffer.concat([header, directory, ...body]);
|
||||
}
|
||||
|
||||
export function buildWoff(tables: WoffTableInput[]): Buffer {
|
||||
const header = Buffer.alloc(44);
|
||||
header.write('wOFF', 0, 4, 'latin1');
|
||||
header.writeUInt32BE(0x00010000, 4);
|
||||
header.writeUInt16BE(tables.length, 12);
|
||||
|
||||
const directory = Buffer.alloc(tables.length * 20);
|
||||
const body: Buffer[] = [];
|
||||
let offset = 44 + tables.length * 20;
|
||||
|
||||
tables.forEach((table, i) => {
|
||||
const stored = table.compress ? deflateSync(table.data) : table.data;
|
||||
const at = i * 20;
|
||||
directory.write(table.tag, at, 4, 'latin1');
|
||||
directory.writeUInt32BE(table.declaredOffset ?? offset, at + 4);
|
||||
directory.writeUInt32BE(table.declaredLength ?? stored.length, at + 8);
|
||||
directory.writeUInt32BE(table.declaredOrigLength ?? table.data.length, at + 12);
|
||||
directory.writeUInt32BE(0, at + 16);
|
||||
body.push(stored);
|
||||
offset += stored.length;
|
||||
});
|
||||
|
||||
header.writeUInt32BE(offset, 8);
|
||||
return Buffer.concat([header, directory, ...body]);
|
||||
}
|
||||
|
||||
export function windowsName(nameID: number, value: string, languageID = LANG_WINDOWS_EN_US): NameRecord {
|
||||
return { platformID: PLATFORM_WINDOWS, languageID, nameID, value };
|
||||
}
|
||||
|
||||
export function macName(nameID: number, value: string): NameRecord {
|
||||
return { platformID: PLATFORM_MAC, languageID: LANG_MAC_ENGLISH, nameID, value };
|
||||
}
|
||||
|
||||
/** A minimal, well-formed TrueType font carrying just the metadata tables. */
|
||||
export function buildFontWithMetadata(options: {
|
||||
family?: string;
|
||||
subfamily?: string;
|
||||
typographicFamily?: string;
|
||||
typographicSubfamily?: string;
|
||||
usWeightClass?: number;
|
||||
fsSelection?: number;
|
||||
}): Buffer {
|
||||
const records: NameRecord[] = [];
|
||||
if (options.family !== undefined) records.push(windowsName(NAME_ID_FAMILY, options.family));
|
||||
if (options.subfamily !== undefined) records.push(windowsName(NAME_ID_SUBFAMILY, options.subfamily));
|
||||
if (options.typographicFamily !== undefined) records.push(windowsName(NAME_ID_TYPOGRAPHIC_FAMILY, options.typographicFamily));
|
||||
if (options.typographicSubfamily !== undefined) records.push(windowsName(NAME_ID_TYPOGRAPHIC_SUBFAMILY, options.typographicSubfamily));
|
||||
|
||||
return buildSfnt([
|
||||
{ tag: 'name', data: buildNameTable(records) },
|
||||
{ tag: 'OS/2', data: buildOs2Table({ usWeightClass: options.usWeightClass, fsSelection: options.fsSelection }) },
|
||||
]);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ 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';
|
||||
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
|
||||
import { EMPTY_CONTENT_FILTER_RULES, MAX_FONT_FILE_SIZE } from '@bookorbit/types';
|
||||
|
||||
function makeUser(overrides: Partial<RequestUser> = {}): RequestUser {
|
||||
return {
|
||||
@@ -144,11 +144,22 @@ describe('FontService', () => {
|
||||
|
||||
it('rejects files exceeding size limit', async () => {
|
||||
const user = makeUser();
|
||||
const buffer = Buffer.alloc(11 * 1024 * 1024);
|
||||
const buffer = Buffer.alloc(MAX_FONT_FILE_SIZE + 1);
|
||||
|
||||
await expect(service.upload(user, buffer, 'big.ttf')).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('accepts a font at exactly the size limit', async () => {
|
||||
const user = makeUser();
|
||||
const buffer = makeTTFBuffer();
|
||||
// Reporting the limit as the length avoids allocating 50 MB just to probe the boundary.
|
||||
Object.defineProperty(buffer, 'length', { value: MAX_FONT_FILE_SIZE });
|
||||
repo.create.mockResolvedValue(makeFontRow());
|
||||
|
||||
await expect(service.upload(user, buffer, 'exactly-at-limit.ttf')).resolves.toBeDefined();
|
||||
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ fileSize: MAX_FONT_FILE_SIZE }));
|
||||
});
|
||||
|
||||
it('rejects unsupported file extensions', async () => {
|
||||
const user = makeUser();
|
||||
const buffer = makeTTFBuffer();
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { FontValidationService } from './font.validation.service';
|
||||
import { buildFontWithMetadata } from './font-test-fixtures';
|
||||
|
||||
vi.mock('opentype.js', () => ({
|
||||
parse: vi.fn(),
|
||||
}));
|
||||
vi.mock('./font-metadata.parser', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./font-metadata.parser')>();
|
||||
return { ...actual, parseFontMetadata: vi.fn(actual.parseFontMetadata) };
|
||||
});
|
||||
|
||||
import { parse } from 'opentype.js';
|
||||
import { parseFontMetadata } from './font-metadata.parser';
|
||||
|
||||
const parseMock = vi.mocked(parse);
|
||||
const parseMock = vi.mocked(parseFontMetadata);
|
||||
|
||||
/** Any buffer works when the parser is stubbed; only the stubbed return value matters. */
|
||||
const STUB_BUFFER = Buffer.from([0, 1, 2, 3]);
|
||||
|
||||
describe('FontValidationService', () => {
|
||||
let service: FontValidationService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
parseMock.mockRestore?.();
|
||||
service = new FontValidationService();
|
||||
});
|
||||
|
||||
@@ -66,82 +73,61 @@ describe('FontValidationService', () => {
|
||||
|
||||
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);
|
||||
parseMock.mockReturnValue({ familyName: 'Literata', subfamilyName: 'Bold Italic', usWeightClass: 700, fsSelection: 1 });
|
||||
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Literata-BoldItalic.ttf');
|
||||
const result = service.extractMetadata(STUB_BUFFER, '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);
|
||||
parseMock.mockReturnValue({ familyName: 'Test', subfamilyName: 'Regular', usWeightClass: 350, fsSelection: 0 });
|
||||
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Test.ttf');
|
||||
expect(result.weight).toBe(400);
|
||||
expect(service.extractMetadata(STUB_BUFFER, 'Test.ttf').weight).toBe(400);
|
||||
});
|
||||
|
||||
it('falls back to filename heuristics when parsing fails', () => {
|
||||
parseMock.mockImplementation(() => {
|
||||
throw new Error('Cannot parse WOFF2');
|
||||
throw new Error('WOFF2 metadata extraction is not supported');
|
||||
});
|
||||
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'MyFont-Bold-Italic.woff2');
|
||||
const result = service.extractMetadata(STUB_BUFFER, '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);
|
||||
parseMock.mockReturnValue({ familyName: 'Test', subfamilyName: null, usWeightClass: undefined, fsSelection: undefined });
|
||||
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Test-Light.ttf');
|
||||
expect(result.weight).toBe(300);
|
||||
expect(service.extractMetadata(STUB_BUFFER, 'Test-Light.ttf').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);
|
||||
parseMock.mockReturnValue({ familyName: 'Test', subfamilyName: 'Italic', usWeightClass: 400, fsSelection: 0 });
|
||||
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Test-Italic.ttf');
|
||||
expect(result.style).toBe('italic');
|
||||
expect(service.extractMetadata(STUB_BUFFER, 'Test-Italic.ttf').style).toBe('italic');
|
||||
});
|
||||
|
||||
it('defaults to weight 400 and style normal when no metadata or heuristics match', () => {
|
||||
parseMock.mockReturnValue({
|
||||
names: { fontFamily: { en: 'Test' }, fontSubfamily: { en: '' } },
|
||||
tables: { os2: {} },
|
||||
} as never);
|
||||
parseMock.mockReturnValue({ familyName: 'Test', subfamilyName: null, usWeightClass: undefined, fsSelection: undefined });
|
||||
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Test.ttf');
|
||||
const result = service.extractMetadata(STUB_BUFFER, '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);
|
||||
it('returns null family name when the font carries no usable name', () => {
|
||||
parseMock.mockReturnValue({ familyName: null, subfamilyName: null, usWeightClass: 400, fsSelection: 0 });
|
||||
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'TestFont.ttf');
|
||||
expect(result.familyName).toBeNull();
|
||||
expect(service.extractMetadata(STUB_BUFFER, 'TestFont.ttf').familyName).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores an out-of-range usWeightClass and falls back to the filename', () => {
|
||||
parseMock.mockReturnValue({ familyName: 'Test', subfamilyName: null, usWeightClass: 5000, fsSelection: 0 });
|
||||
|
||||
expect(service.extractMetadata(STUB_BUFFER, 'Test-Bold.ttf').weight).toBe(700);
|
||||
});
|
||||
|
||||
it('extracts family name from filename stripping weight/style keywords', () => {
|
||||
@@ -149,83 +135,82 @@ describe('FontValidationService', () => {
|
||||
throw new Error('fail');
|
||||
});
|
||||
|
||||
const result = service.extractMetadata(Buffer.from([0, 1, 2, 3]), 'Open_Sans-SemiBold-Italic.ttf');
|
||||
const result = service.extractMetadata(STUB_BUFFER, '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', () => {
|
||||
it.each([
|
||||
['Roboto-Thin.otf', 100],
|
||||
['Roboto-ExtraLight.otf', 200],
|
||||
['Roboto-Medium.woff', 500],
|
||||
['Font-SemiBold.woff2', 600],
|
||||
['Roboto-ExtraBold.ttf', 800],
|
||||
['Roboto-Extra-Bold.ttf', 800],
|
||||
['Font-UltraBold.ttf', 800],
|
||||
['Roboto-Black.woff2', 900],
|
||||
])('derives weight from the filename %s', (filename, expected) => {
|
||||
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);
|
||||
expect(service.extractMetadata(STUB_BUFFER, filename).weight).toBe(expected);
|
||||
});
|
||||
|
||||
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"
|
||||
expect(service.extractMetadata(STUB_BUFFER, 'Abnormal.ttf').weight).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// The mocked cases above cannot catch a service that reads the wrong fields off the
|
||||
// parser, so these drive the real parser with real font bytes.
|
||||
describe('extractMetadata against real font binaries', () => {
|
||||
it('reads family, weight and style out of an actual font', () => {
|
||||
const font = buildFontWithMetadata({ family: 'Iosevka', subfamily: 'Bold', usWeightClass: 700, fsSelection: 32 });
|
||||
|
||||
const result = service.extractMetadata(font, 'whatever-the-file-was-called.ttf');
|
||||
expect(result).toEqual({ familyName: 'Iosevka', weight: 700, style: 'normal' });
|
||||
});
|
||||
|
||||
it('reads the italic bit out of fsSelection', () => {
|
||||
const font = buildFontWithMetadata({ family: 'Iosevka', subfamily: 'Italic', usWeightClass: 400, fsSelection: 1 });
|
||||
|
||||
expect(service.extractMetadata(font, 'Iosevka.ttf').style).toBe('italic');
|
||||
});
|
||||
|
||||
it('prefers the embedded typographic family over the filename', () => {
|
||||
const font = buildFontWithMetadata({
|
||||
family: 'Source Han Sans Light',
|
||||
subfamily: 'Regular',
|
||||
typographicFamily: 'Source Han Sans',
|
||||
typographicSubfamily: 'Light',
|
||||
usWeightClass: 300,
|
||||
});
|
||||
|
||||
const result = service.extractMetadata(font, 'SourceHanSans-Light.ttf');
|
||||
expect(result.familyName).toBe('Source Han Sans');
|
||||
expect(result.weight).toBe(300);
|
||||
});
|
||||
|
||||
it('falls back to filename heuristics for a real WOFF2 file', () => {
|
||||
const woff2 = Buffer.concat([Buffer.from('wOF2', 'latin1'), Buffer.alloc(200)]);
|
||||
|
||||
const result = service.extractMetadata(woff2, 'Noto_Sans-Bold-Italic.woff2');
|
||||
expect(result.familyName).toBe('Noto Sans');
|
||||
expect(result.weight).toBe(700);
|
||||
expect(result.style).toBe('italic');
|
||||
});
|
||||
|
||||
it('falls back to filename heuristics for truncated font bytes', () => {
|
||||
const result = service.extractMetadata(Buffer.from([0x00, 0x01, 0x00, 0x00]), 'Broken-Thin.ttf');
|
||||
expect(result.familyName).toBe('Broken');
|
||||
expect(result.weight).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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';
|
||||
|
||||
import { parseFontMetadata } from './font-metadata.parser';
|
||||
|
||||
export interface FontMetadata {
|
||||
familyName: string | null;
|
||||
weight: number;
|
||||
@@ -78,14 +79,13 @@ export class FontValidationService {
|
||||
|
||||
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 metadata = parseFontMetadata(buffer);
|
||||
const subfamilyName = (metadata.subfamilyName ?? '').toLowerCase();
|
||||
|
||||
const weight = this.resolveWeight(font.tables?.os2?.usWeightClass, subfamilyName, filename);
|
||||
const style = this.resolveStyle(font.tables?.os2?.fsSelection, subfamilyName, filename);
|
||||
const weight = this.resolveWeight(metadata.usWeightClass, subfamilyName, filename);
|
||||
const style = this.resolveStyle(metadata.fsSelection, subfamilyName, filename);
|
||||
|
||||
return { familyName, weight, style };
|
||||
return { familyName: metadata.familyName, weight, style };
|
||||
} catch (err) {
|
||||
this.logger.debug(
|
||||
`[font.extract_metadata] [fail] filename=${filename} error="${(err as Error).message}" - falling back to filename heuristics`,
|
||||
|
||||
Reference in New Issue
Block a user