2026-09-01 22:49:36 +00:00
|
|
|
/* eslint-disable max-lines -- font loading scenarios share provider and CanvasKit lifecycle fixtures */
|
|
|
|
|
|
2026-03-04 07:16:26 +00:00
|
|
|
import { describe, test, expect } from 'bun:test'
|
|
|
|
|
|
2026-05-05 23:22:08 +00:00
|
|
|
import type { CanvasKit, TypefaceFontProvider } from 'canvaskit-wasm'
|
|
|
|
|
|
2026-03-04 07:16:26 +00:00
|
|
|
import {
|
2026-05-17 17:33:13 +00:00
|
|
|
chooseLocalFontMatch,
|
2026-05-05 15:59:54 +00:00
|
|
|
fontManager,
|
2026-03-16 10:40:16 +00:00
|
|
|
isVariableFont,
|
|
|
|
|
normalizeFontFamily,
|
|
|
|
|
styleToVariant,
|
2026-03-04 07:16:26 +00:00
|
|
|
styleToWeight,
|
2026-05-16 07:52:51 +00:00
|
|
|
weightToFigmaStyle,
|
2026-03-04 07:16:26 +00:00
|
|
|
weightToStyle,
|
2026-05-05 15:59:54 +00:00
|
|
|
FontManager,
|
2026-07-01 19:19:14 +00:00
|
|
|
SceneGraph
|
Clean up tools/schema, add geometry.ts, isMixed helper, fix imports, add tests
- Strip tools/schema.ts from 1831 to 65 lines (remove duplicate tool defs)
- Fix tools/index.ts to export ALL_TOOLS from registry instead of schema
- Fix renderJsx → renderJSX typo in tools/create.ts and structure.ts
- Add geometry.ts with shared rotation/coordinate math utilities
- Add isArrayMixed() helper to use-multi-props.ts, deduplicate FillSection/StrokeSection/EffectsSection
- Fix broken test imports: use @open-pencil/core instead of relative paths into packages/core/src/
- Merge duplicate import statements across all test files
- Export profiler internals and style-run helpers from core index.ts
- Add 6 new test files: color, undo, snap, vector, style-runs, text-editor (98 new tests)
- Update renderer-effects test for renderer/ directory split
2026-03-06 19:33:10 +00:00
|
|
|
} from '@open-pencil/core'
|
2026-05-05 23:05:02 +00:00
|
|
|
|
2026-05-05 23:59:56 +00:00
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
|
|
|
|
|
2026-03-04 07:16:26 +00:00
|
|
|
function pageId(graph: SceneGraph) {
|
|
|
|
|
return graph.getPages()[0].id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('styleToWeight', () => {
|
|
|
|
|
test('maps common style names', () => {
|
|
|
|
|
expect(styleToWeight('Regular')).toBe(400)
|
|
|
|
|
expect(styleToWeight('Bold')).toBe(700)
|
|
|
|
|
expect(styleToWeight('Light')).toBe(300)
|
|
|
|
|
expect(styleToWeight('Thin')).toBe(100)
|
|
|
|
|
expect(styleToWeight('Medium')).toBe(500)
|
|
|
|
|
expect(styleToWeight('SemiBold')).toBe(600)
|
2026-05-17 10:40:56 +00:00
|
|
|
expect(styleToWeight('Semi Bold')).toBe(600)
|
|
|
|
|
expect(styleToWeight('DemiBold')).toBe(600)
|
2026-03-04 07:16:26 +00:00
|
|
|
expect(styleToWeight('ExtraBold')).toBe(800)
|
|
|
|
|
expect(styleToWeight('Black')).toBe(900)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('handles italic variants', () => {
|
|
|
|
|
expect(styleToWeight('Bold Italic')).toBe(700)
|
|
|
|
|
expect(styleToWeight('Light Italic')).toBe(300)
|
2026-05-17 10:40:56 +00:00
|
|
|
expect(styleToWeight('600 Italic')).toBe(600)
|
2026-03-04 07:16:26 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('case insensitive', () => {
|
|
|
|
|
expect(styleToWeight('bold')).toBe(700)
|
|
|
|
|
expect(styleToWeight('THIN')).toBe(100)
|
|
|
|
|
expect(styleToWeight('semibold')).toBe(600)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('weightToStyle', () => {
|
|
|
|
|
test('maps weights to style names', () => {
|
|
|
|
|
expect(weightToStyle(100)).toBe('Thin')
|
|
|
|
|
expect(weightToStyle(200)).toBe('ExtraLight')
|
|
|
|
|
expect(weightToStyle(300)).toBe('Light')
|
2026-03-12 19:05:59 +00:00
|
|
|
expect(weightToStyle(400)).toBe('Regular')
|
2026-03-04 07:16:26 +00:00
|
|
|
expect(weightToStyle(500)).toBe('Medium')
|
|
|
|
|
expect(weightToStyle(600)).toBe('SemiBold')
|
|
|
|
|
expect(weightToStyle(700)).toBe('Bold')
|
|
|
|
|
expect(weightToStyle(800)).toBe('ExtraBold')
|
|
|
|
|
expect(weightToStyle(900)).toBe('Black')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('appends Italic suffix', () => {
|
2026-03-12 19:05:59 +00:00
|
|
|
expect(weightToStyle(400, true)).toBe('Regular Italic')
|
2026-03-04 07:16:26 +00:00
|
|
|
expect(weightToStyle(700, true)).toBe('Bold Italic')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-16 07:52:51 +00:00
|
|
|
describe('weightToFigmaStyle', () => {
|
|
|
|
|
test('preserves Figma weight name spacing', () => {
|
|
|
|
|
expect(weightToFigmaStyle(200)).toBe('Extra Light')
|
|
|
|
|
expect(weightToFigmaStyle(600)).toBe('Semi Bold')
|
|
|
|
|
expect(weightToFigmaStyle(800)).toBe('Extra Bold')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('appends Italic suffix', () => {
|
|
|
|
|
expect(weightToFigmaStyle(600, true)).toBe('Semi Bold Italic')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-05 15:59:54 +00:00
|
|
|
function createRecordingProvider() {
|
|
|
|
|
const registrations: Array<{ family: string; byteLength: number }> = []
|
|
|
|
|
const provider = {
|
|
|
|
|
registerFont(data: ArrayBuffer, family: string) {
|
|
|
|
|
registrations.push({ family, byteLength: data.byteLength })
|
|
|
|
|
}
|
2026-05-05 21:29:23 +00:00
|
|
|
} as TypefaceFontProvider
|
2026-05-05 15:59:54 +00:00
|
|
|
return { provider, registrations }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 17:33:13 +00:00
|
|
|
describe('chooseLocalFontMatch', () => {
|
|
|
|
|
const fonts = [
|
|
|
|
|
{ family: 'Inter', style: 'Italic' },
|
|
|
|
|
{ family: 'Inter', style: 'Regular' },
|
|
|
|
|
{ family: 'Inter', style: 'Semi Bold' },
|
|
|
|
|
{ family: 'Inter', style: 'Bold Italic' }
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
test('prefers parsed weight and slant over first family match', () => {
|
|
|
|
|
expect(chooseLocalFontMatch(fonts, 'Inter', 'SemiBold')?.style).toBe('Semi Bold')
|
|
|
|
|
expect(chooseLocalFontMatch(fonts, 'Inter', 'Regular')?.style).toBe('Regular')
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-17 17:40:39 +00:00
|
|
|
test('does not substitute nearby weights for explicit style requests', () => {
|
|
|
|
|
expect(chooseLocalFontMatch(fonts, 'Inter', 'Bold')).toBeUndefined()
|
2026-05-17 17:33:13 +00:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-05 15:59:54 +00:00
|
|
|
describe('FontManager loaded font cache', () => {
|
2026-03-04 07:16:26 +00:00
|
|
|
test('marks and checks font', () => {
|
|
|
|
|
const family = `TestFont_${Date.now()}`
|
2026-05-05 15:59:54 +00:00
|
|
|
expect(fontManager.isLoaded(family)).toBe(false)
|
|
|
|
|
fontManager.markLoaded(family, 'Regular', new ArrayBuffer(8))
|
|
|
|
|
expect(fontManager.isLoaded(family)).toBe(true)
|
2026-03-04 07:16:26 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('different styles for same family', () => {
|
|
|
|
|
const family = `MultiStyle_${Date.now()}`
|
2026-05-05 15:59:54 +00:00
|
|
|
expect(fontManager.isLoaded(family)).toBe(false)
|
|
|
|
|
fontManager.markLoaded(family, 'Bold', new ArrayBuffer(8))
|
|
|
|
|
expect(fontManager.isLoaded(family)).toBe(true)
|
2026-05-13 01:47:32 +00:00
|
|
|
expect(fontManager.isStyleLoaded(family, 'Bold')).toBe(true)
|
|
|
|
|
expect(fontManager.isStyleLoaded(family, 'Regular')).toBe(false)
|
2026-05-05 15:59:54 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('re-registers cached fonts when provider changes', () => {
|
|
|
|
|
const manager = new FontManager()
|
|
|
|
|
const canvasKit = {} as CanvasKit
|
|
|
|
|
const first = createRecordingProvider()
|
|
|
|
|
const second = createRecordingProvider()
|
|
|
|
|
|
|
|
|
|
manager.attachProvider(canvasKit, first.provider)
|
|
|
|
|
manager.markLoaded('ProviderLifecycle', 'Regular', new ArrayBuffer(12))
|
2026-07-17 13:19:18 +00:00
|
|
|
expect(first.registrations).toEqual([{ family: 'ProviderLifecycle', byteLength: 12 }])
|
2026-05-05 15:59:54 +00:00
|
|
|
|
|
|
|
|
manager.attachProvider(canvasKit, second.provider)
|
2026-07-17 13:19:18 +00:00
|
|
|
expect(second.registrations).toEqual([{ family: 'ProviderLifecycle', byteLength: 12 }])
|
2026-07-20 19:19:27 +00:00
|
|
|
|
|
|
|
|
manager.markLoaded('SharedProviderLifecycle', 'Regular', new ArrayBuffer(16))
|
|
|
|
|
expect(first.registrations.at(-1)).toEqual({
|
|
|
|
|
family: 'SharedProviderLifecycle',
|
|
|
|
|
byteLength: 16
|
|
|
|
|
})
|
|
|
|
|
expect(second.registrations.at(-1)).toEqual({
|
|
|
|
|
family: 'SharedProviderLifecycle',
|
|
|
|
|
byteLength: 16
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-05 15:59:54 +00:00
|
|
|
manager.detachProvider(first.provider)
|
|
|
|
|
expect(manager.provider()).toBe(second.provider)
|
|
|
|
|
|
|
|
|
|
manager.detachProvider(second.provider)
|
|
|
|
|
expect(manager.provider()).toBeNull()
|
2026-03-04 07:16:26 +00:00
|
|
|
})
|
2026-05-05 18:14:38 +00:00
|
|
|
|
2026-07-17 13:19:18 +00:00
|
|
|
test('renders loaded faces under their source families', () => {
|
2026-05-17 10:40:56 +00:00
|
|
|
const manager = new FontManager()
|
|
|
|
|
const recording = createRecordingProvider()
|
|
|
|
|
|
|
|
|
|
manager.attachProvider({} as CanvasKit, recording.provider)
|
|
|
|
|
manager.markLoaded('Inter', 'SemiBold', new ArrayBuffer(12))
|
|
|
|
|
|
|
|
|
|
const renderFamily = manager.renderFamily('Inter', 'SemiBold')
|
|
|
|
|
|
2026-07-17 13:19:18 +00:00
|
|
|
expect(renderFamily).toBe('Inter')
|
|
|
|
|
expect(recording.registrations).toEqual([{ family: 'Inter', byteLength: 12 }])
|
2026-05-17 10:40:56 +00:00
|
|
|
expect(manager.renderFamily('Inter', 'SemiBold')).toBe(renderFamily)
|
2026-07-17 13:19:18 +00:00
|
|
|
expect(recording.registrations).toHaveLength(1)
|
2026-05-17 10:40:56 +00:00
|
|
|
})
|
|
|
|
|
|
2026-07-17 10:00:09 +00:00
|
|
|
test('prefers local font data before downloaded cache', async () => {
|
|
|
|
|
const manager = new FontManager()
|
|
|
|
|
const recording = createRecordingProvider()
|
|
|
|
|
let cacheReads = 0
|
|
|
|
|
|
|
|
|
|
manager.attachProvider({} as CanvasKit, recording.provider)
|
|
|
|
|
manager.setHostFontLoader(async () => new ArrayBuffer(20))
|
|
|
|
|
manager.setDownloadedFontCache({
|
|
|
|
|
async read() {
|
|
|
|
|
cacheReads++
|
|
|
|
|
return new ArrayBuffer(16)
|
|
|
|
|
},
|
|
|
|
|
async write() {
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const data = await manager.loadFont('LocalPriority', 'Regular')
|
|
|
|
|
expect(data?.byteLength).toBe(20)
|
|
|
|
|
expect(cacheReads).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('loads downloaded cache when local sources are unavailable', async () => {
|
2026-05-05 18:14:38 +00:00
|
|
|
const manager = new FontManager()
|
|
|
|
|
const recording = createRecordingProvider()
|
|
|
|
|
const data = new ArrayBuffer(16)
|
|
|
|
|
let writes = 0
|
|
|
|
|
|
|
|
|
|
manager.attachProvider({} as CanvasKit, recording.provider)
|
|
|
|
|
manager.setDownloadedFontCache({
|
|
|
|
|
async read(family, style) {
|
|
|
|
|
return family === 'DownloadedCache' && style === 'Regular' ? data : null
|
|
|
|
|
},
|
|
|
|
|
async write() {
|
|
|
|
|
writes++
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await expect(manager.loadFont('DownloadedCache', 'Regular')).resolves.toBe(data)
|
2026-07-17 13:19:18 +00:00
|
|
|
expect(recording.registrations).toEqual([{ family: 'DownloadedCache', byteLength: 16 }])
|
2026-05-05 18:14:38 +00:00
|
|
|
expect(writes).toBe(0)
|
|
|
|
|
})
|
2026-05-17 21:53:29 +00:00
|
|
|
|
2026-09-02 06:26:59 +00:00
|
|
|
test('tracks character coverage restored from downloaded cache', async () => {
|
|
|
|
|
const manager = new FontManager()
|
|
|
|
|
manager.attachProvider({} as CanvasKit, createRecordingProvider().provider)
|
|
|
|
|
const cached = new ArrayBuffer(16)
|
|
|
|
|
manager.setDownloadedFontCache({
|
|
|
|
|
async read(family, style, characters) {
|
|
|
|
|
return family === 'SubsetCache' && style === 'Regular' && characters === 'A' ? cached : null
|
|
|
|
|
},
|
|
|
|
|
async write() {
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await expect(manager.loadFont('SubsetCache', 'Regular', 'A')).resolves.toBe(cached)
|
|
|
|
|
const originalLoadRemoteFont = manager.loadRemoteFont.bind(manager)
|
|
|
|
|
let requested = ''
|
|
|
|
|
manager.loadRemoteFont = async (_family, _style, characters) => {
|
|
|
|
|
requested = characters ?? ''
|
|
|
|
|
return cached
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await manager.loadFont('SubsetCache', 'Regular', 'B')
|
|
|
|
|
} finally {
|
|
|
|
|
manager.loadRemoteFont = originalLoadRemoteFont
|
|
|
|
|
}
|
|
|
|
|
expect(requested).toContain('B')
|
|
|
|
|
})
|
feat(app): prepare documents atomically per tab (#592)
* feat(app): show atomic document loading progress
- Preserve the existing full-canvas pencil loader while adding phase, detail, accessible status, and honest determinate progress
- Keep one generation-safe load owner across FIG decoding, graph preparation, page population, fonts, fallbacks, layout, viewport fitting, and first-render fade
- Prevent nested page setup and viewport cleanup from revealing partially prepared documents
- Cover obsolete sessions, font-resolution ownership, and staged loader UI
* refactor(app): scope editor preparation per tab
- Replace the shared loading boolean with one reactive preparation snapshot and one imperative controller per editor store
- Keep Core page work progress-only and inject canvas suspension from the app boundary
- Route FIG, storage, recovery, DOM import, and page switching through reusable tab-local preparation handles
- Abort only the closing tab's operation and cover generation safety, multi-tab isolation, progress UI, and disposal
* fix(editor): commit prepared pages atomically
- Prepare population, fonts, fallbacks, and layout without changing the visible page
- Reject cancelled and stale prepared pages before committing viewport, selection, and page events
- Keep the preparation overlay until the committed scene version is presented
- Cover call order, cancellation, stale generations, and presentation acknowledgement
* fix(app): stage imported documents before commit
- Prepare imported graphs in an isolated Core editor before replacing the live document
- Share font loading while keeping live selection, graph, renderers, and history untouched during staging
- Preserve the previous graph when staging is cancelled or fails and remove the duplicate pre-font layout pass
* refactor(app): namespace preparation UI
- Move canvas and tab preparation presentations into focused subfolders with concise component names
- Share progress and phase presentation helpers across preparation surfaces
- Show tab-local preparation status without covering the active canvas for background work
* fix(app): cancel preparation work at source
- Publish typed per-store preparation lifecycle events with explicit completion, cancellation, and failure outcomes
- Propagate tab-local AbortSignals through FIG parsing, population workers, and browser font downloads
- Keep cancellable font requests outside shared in-flight caches while retaining globally completed font registrations
- Stop FIG manifest previews from replacing the live graph before atomic document commit
* fix(app): cancel storage and DOM preparation
- Propagate preparation signals through S3 downloads, byte progress, local-cache boundaries, and DOM/CSS conversion checkpoints
- Reuse merged diagnostics and localized toasts for document, storage, and presentation failures
- Replace manual font concurrency and presentation timers with es-toolkit limitAsync and withTimeout
- Guard stalled first presentation and fix the merged recovery dialog title bindings
* fix(app): stage reload and font retry
- Prepare reload graphs in isolation and preserve the current document on read, decode, font, or layout failure
- Restore page and viewport state only after atomic graph commit with cancellable reload reads
- Run font Retry as a tab-local preparation with cache reset, final layout, picture invalidation, and presentation acknowledgement
- Keep completed document pixels visible while Retry reports activity in the tab
* fix(app): enforce exclusive preparation outcomes
- Complete document, storage, recovery, and DOM preparations only after successful commit
- Keep failed and cancelled handles terminal so lifecycle events cannot report contradictory outcomes
- Preserve external AbortError identity across storage timeouts and cancel streamed readers without returning partial bytes
- Cover credential-free pre-abort, mid-stream cancellation, progress cutoff, and terminal outcome exclusivity
* feat(diagnostics): record preparation outcomes
- Persist completed, cancelled, and failed preparation lifecycles through the validated diagnostics recorder
- Store only operation kind, outcome, cancellation or failure category, terminal phase, and coarse duration bucket
- Exclude document subjects, font families, storage identities, URLs, raw durations, messages, and stack traces
* chore(app): keep browser font tests with typography split
- Remove the browser font transport test inherited from a mixed cancellation commit; the source and coverage remain on the typography branch and safety snapshot
* test(vue): assert injected render suspension
- Exercise shouldSuspendRender instead of removed Core loading state\n- Preserve the contract that rendering resumes without a version change
* test(app): complete atomic preparation contracts
- Acknowledge first presentation in headless file-open tests\n- Assert the cancellable font-loading signature at the Tauri fallback boundary
* fix(app): preserve preparation cancellation
- Stage imported graphs before mutating live tabs and propagate aborts through page, DOM, font, and storage work\n- Use the accessible progress primitive and clamp determinate values\n- Cover fallback-font cancellation and yield pending-open test polling to the task queue
* test(text): await fallback font request cancellation
Start the mocked remote font request before aborting so the test proves that the active request receives the preparation signal.
2026-08-30 09:21:49 +00:00
|
|
|
test('forwards cancellation to fallback web-font requests', async () => {
|
|
|
|
|
const manager = new FontManager()
|
|
|
|
|
let remoteRequestStarted: (() => void) | null = null
|
|
|
|
|
const started = new Promise<void>((resolve) => {
|
|
|
|
|
remoteRequestStarted = resolve
|
|
|
|
|
})
|
|
|
|
|
const originalLoadRemoteFont = manager.loadRemoteFont.bind(manager)
|
|
|
|
|
manager.loadRemoteFont = async (_family, _style, _characters, signal) => {
|
|
|
|
|
remoteRequestStarted?.()
|
|
|
|
|
return new Promise<ArrayBuffer | null>((_resolve, reject) => {
|
|
|
|
|
signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), {
|
|
|
|
|
once: true
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
const abort = new AbortController()
|
|
|
|
|
const loading = manager.ensureFallbackPack(['cjk'], '字', abort.signal)
|
|
|
|
|
|
|
|
|
|
await started
|
|
|
|
|
abort.abort()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await expect(loading).rejects.toHaveProperty('name', 'AbortError')
|
|
|
|
|
} finally {
|
|
|
|
|
manager.loadRemoteFont = originalLoadRemoteFont
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-17 21:53:29 +00:00
|
|
|
test('loads bundled Inter ExtraBold without network access', async () => {
|
|
|
|
|
const manager = new FontManager()
|
|
|
|
|
const recording = createRecordingProvider()
|
|
|
|
|
const originalFetch = globalThis.fetch
|
|
|
|
|
globalThis.fetch = (() => {
|
|
|
|
|
throw new Error('network disabled')
|
|
|
|
|
}) as typeof fetch
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
manager.attachProvider({} as CanvasKit, recording.provider)
|
|
|
|
|
const data = await manager.loadFont('Inter', 'ExtraBold')
|
|
|
|
|
|
|
|
|
|
expect(data?.byteLength).toBeGreaterThan(0)
|
2026-07-17 13:19:18 +00:00
|
|
|
expect(recording.registrations).toEqual([{ family: 'Inter', byteLength: data?.byteLength }])
|
2026-05-17 21:53:29 +00:00
|
|
|
} finally {
|
|
|
|
|
globalThis.fetch = originalFetch
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-03-04 07:16:26 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('collectFontKeys', () => {
|
|
|
|
|
test('returns empty for non-text nodes', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const id = graph.createNode('RECTANGLE', pageId(graph), {
|
|
|
|
|
name: 'Rect',
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
width: 100,
|
2026-05-03 18:03:40 +00:00
|
|
|
height: 100
|
2026-03-04 07:16:26 +00:00
|
|
|
}).id
|
2026-05-05 15:59:54 +00:00
|
|
|
expect(fontManager.collectFontKeys(graph, [id])).toEqual([])
|
2026-03-04 07:16:26 +00:00
|
|
|
})
|
|
|
|
|
|
2026-03-12 19:05:59 +00:00
|
|
|
test('includes default font (Inter) in collected keys', () => {
|
2026-03-04 07:16:26 +00:00
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const id = graph.createNode('TEXT', pageId(graph), {
|
|
|
|
|
name: 'Label',
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 20,
|
|
|
|
|
text: 'Hello',
|
|
|
|
|
fontFamily: 'Inter',
|
2026-05-03 18:03:40 +00:00
|
|
|
fontWeight: 400
|
2026-03-04 07:16:26 +00:00
|
|
|
}).id
|
2026-05-05 15:59:54 +00:00
|
|
|
expect(fontManager.collectFontKeys(graph, [id])).toEqual([['Inter', 'Regular']])
|
2026-03-04 07:16:26 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('collects non-default font family', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const id = graph.createNode('TEXT', pageId(graph), {
|
|
|
|
|
name: 'Label',
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 20,
|
|
|
|
|
text: 'Hello',
|
|
|
|
|
fontFamily: 'Roboto',
|
2026-05-03 18:03:40 +00:00
|
|
|
fontWeight: 400
|
2026-03-04 07:16:26 +00:00
|
|
|
}).id
|
2026-05-05 15:59:54 +00:00
|
|
|
const keys = fontManager.collectFontKeys(graph, [id])
|
2026-03-12 19:05:59 +00:00
|
|
|
expect(keys).toEqual([['Roboto', 'Regular']])
|
2026-03-04 07:16:26 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('collects bold weight', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const id = graph.createNode('TEXT', pageId(graph), {
|
|
|
|
|
name: 'Label',
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 20,
|
|
|
|
|
text: 'Hello',
|
|
|
|
|
fontFamily: 'Roboto',
|
2026-05-03 18:03:40 +00:00
|
|
|
fontWeight: 700
|
2026-03-04 07:16:26 +00:00
|
|
|
}).id
|
2026-05-05 15:59:54 +00:00
|
|
|
const keys = fontManager.collectFontKeys(graph, [id])
|
2026-03-04 07:16:26 +00:00
|
|
|
expect(keys).toEqual([['Roboto', 'Bold']])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('collects italic variant', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const id = graph.createNode('TEXT', pageId(graph), {
|
|
|
|
|
name: 'Label',
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 20,
|
|
|
|
|
text: 'Hello',
|
|
|
|
|
fontFamily: 'Roboto',
|
|
|
|
|
fontWeight: 400,
|
2026-05-03 18:03:40 +00:00
|
|
|
italic: true
|
2026-03-04 07:16:26 +00:00
|
|
|
}).id
|
2026-05-05 15:59:54 +00:00
|
|
|
const keys = fontManager.collectFontKeys(graph, [id])
|
2026-03-12 19:05:59 +00:00
|
|
|
expect(keys).toEqual([['Roboto', 'Regular Italic']])
|
2026-03-04 07:16:26 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('deduplicates same family+style', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = pageId(graph)
|
|
|
|
|
graph.createNode('TEXT', page, {
|
|
|
|
|
name: 'A',
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 20,
|
|
|
|
|
text: 'A',
|
|
|
|
|
fontFamily: 'Roboto',
|
2026-05-03 18:03:40 +00:00
|
|
|
fontWeight: 400
|
2026-03-04 07:16:26 +00:00
|
|
|
})
|
|
|
|
|
graph.createNode('TEXT', page, {
|
|
|
|
|
name: 'B',
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 30,
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 20,
|
|
|
|
|
text: 'B',
|
|
|
|
|
fontFamily: 'Roboto',
|
2026-05-03 18:03:40 +00:00
|
|
|
fontWeight: 400
|
2026-03-04 07:16:26 +00:00
|
|
|
})
|
|
|
|
|
const ids = graph.getChildren(page).map((n) => n.id)
|
2026-05-05 15:59:54 +00:00
|
|
|
const keys = fontManager.collectFontKeys(graph, ids)
|
2026-03-12 19:05:59 +00:00
|
|
|
expect(keys).toEqual([['Roboto', 'Regular']])
|
2026-03-04 07:16:26 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('collects multiple families', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = pageId(graph)
|
|
|
|
|
graph.createNode('TEXT', page, {
|
|
|
|
|
name: 'A',
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 20,
|
|
|
|
|
text: 'A',
|
|
|
|
|
fontFamily: 'Roboto',
|
2026-05-03 18:03:40 +00:00
|
|
|
fontWeight: 400
|
2026-03-04 07:16:26 +00:00
|
|
|
})
|
|
|
|
|
graph.createNode('TEXT', page, {
|
|
|
|
|
name: 'B',
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 30,
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 20,
|
|
|
|
|
text: 'B',
|
|
|
|
|
fontFamily: 'Open Sans',
|
2026-05-03 18:03:40 +00:00
|
|
|
fontWeight: 700
|
2026-03-04 07:16:26 +00:00
|
|
|
})
|
|
|
|
|
const ids = graph.getChildren(page).map((n) => n.id)
|
2026-05-05 15:59:54 +00:00
|
|
|
const keys = fontManager.collectFontKeys(graph, ids)
|
2026-03-04 07:16:26 +00:00
|
|
|
expect(keys).toHaveLength(2)
|
2026-03-12 19:05:59 +00:00
|
|
|
expect(keys).toContainEqual(['Roboto', 'Regular'])
|
2026-03-04 07:16:26 +00:00
|
|
|
expect(keys).toContainEqual(['Open Sans', 'Bold'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('walks into nested children', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = pageId(graph)
|
|
|
|
|
const frame = graph.createNode('FRAME', page, {
|
|
|
|
|
name: 'Frame',
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
width: 400,
|
2026-05-03 18:03:40 +00:00
|
|
|
height: 400
|
2026-03-04 07:16:26 +00:00
|
|
|
}).id
|
|
|
|
|
graph.createNode('TEXT', frame, {
|
|
|
|
|
name: 'Nested',
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 20,
|
|
|
|
|
text: 'Hello',
|
|
|
|
|
fontFamily: 'Poppins',
|
2026-05-03 18:03:40 +00:00
|
|
|
fontWeight: 600
|
2026-03-04 07:16:26 +00:00
|
|
|
})
|
2026-05-05 15:59:54 +00:00
|
|
|
const keys = fontManager.collectFontKeys(graph, [frame])
|
2026-03-04 07:16:26 +00:00
|
|
|
expect(keys).toEqual([['Poppins', 'SemiBold']])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('collects fonts from style runs', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const id = graph.createNode('TEXT', pageId(graph), {
|
|
|
|
|
name: 'Mixed',
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
width: 200,
|
|
|
|
|
height: 20,
|
|
|
|
|
text: 'Hello World',
|
|
|
|
|
fontFamily: 'Roboto',
|
|
|
|
|
fontWeight: 400,
|
|
|
|
|
styleRuns: [
|
|
|
|
|
{
|
|
|
|
|
start: 0,
|
|
|
|
|
end: 5,
|
2026-05-03 18:03:40 +00:00
|
|
|
style: { fontFamily: 'Roboto', fontWeight: 400 }
|
2026-03-04 07:16:26 +00:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
start: 6,
|
|
|
|
|
end: 11,
|
2026-05-03 18:03:40 +00:00
|
|
|
style: { fontFamily: 'Montserrat', fontWeight: 700 }
|
|
|
|
|
}
|
|
|
|
|
]
|
2026-03-04 07:16:26 +00:00
|
|
|
}).id
|
2026-05-05 15:59:54 +00:00
|
|
|
const keys = fontManager.collectFontKeys(graph, [id])
|
2026-03-04 07:16:26 +00:00
|
|
|
expect(keys).toHaveLength(2)
|
2026-03-12 19:05:59 +00:00
|
|
|
expect(keys).toContainEqual(['Roboto', 'Regular'])
|
2026-03-04 07:16:26 +00:00
|
|
|
expect(keys).toContainEqual(['Montserrat', 'Bold'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('style run inherits node font when not specified', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const id = graph.createNode('TEXT', pageId(graph), {
|
|
|
|
|
name: 'Partial',
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
width: 200,
|
|
|
|
|
height: 20,
|
|
|
|
|
text: 'Hello',
|
|
|
|
|
fontFamily: 'Lato',
|
|
|
|
|
fontWeight: 300,
|
|
|
|
|
styleRuns: [
|
|
|
|
|
{
|
|
|
|
|
start: 0,
|
|
|
|
|
end: 5,
|
2026-05-03 18:03:40 +00:00
|
|
|
style: {}
|
|
|
|
|
}
|
|
|
|
|
]
|
2026-03-04 07:16:26 +00:00
|
|
|
}).id
|
2026-05-05 15:59:54 +00:00
|
|
|
const keys = fontManager.collectFontKeys(graph, [id])
|
2026-03-04 07:16:26 +00:00
|
|
|
expect(keys).toEqual([['Lato', 'Light']])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('skips invalid node IDs', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
2026-05-05 15:59:54 +00:00
|
|
|
expect(fontManager.collectFontKeys(graph, ['nonexistent'])).toEqual([])
|
2026-03-04 07:16:26 +00:00
|
|
|
})
|
|
|
|
|
})
|
2026-03-16 10:40:16 +00:00
|
|
|
|
|
|
|
|
describe('normalizeFontFamily', () => {
|
|
|
|
|
test('strips " Variable" suffix', () => {
|
|
|
|
|
expect(normalizeFontFamily('Inter Variable')).toBe('Inter')
|
|
|
|
|
expect(normalizeFontFamily('Roboto Flex Variable')).toBe('Roboto Flex')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('case insensitive', () => {
|
|
|
|
|
expect(normalizeFontFamily('Inter VARIABLE')).toBe('Inter')
|
|
|
|
|
expect(normalizeFontFamily('Inter variable')).toBe('Inter')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('handles extra whitespace before Variable', () => {
|
|
|
|
|
expect(normalizeFontFamily('Inter Variable')).toBe('Inter')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('returns unchanged when no Variable suffix', () => {
|
|
|
|
|
expect(normalizeFontFamily('Inter')).toBe('Inter')
|
|
|
|
|
expect(normalizeFontFamily('Roboto')).toBe('Roboto')
|
|
|
|
|
expect(normalizeFontFamily('')).toBe('')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('does not strip Variable in the middle', () => {
|
|
|
|
|
expect(normalizeFontFamily('Variable Sans')).toBe('Variable Sans')
|
|
|
|
|
})
|
2026-03-16 17:57:58 +00:00
|
|
|
|
|
|
|
|
test('strips optical size suffix (pt)', () => {
|
|
|
|
|
expect(normalizeFontFamily('DM Sans 9pt')).toBe('DM Sans')
|
|
|
|
|
expect(normalizeFontFamily('DM Sans 14pt')).toBe('DM Sans')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('strips optical size suffix (px)', () => {
|
|
|
|
|
expect(normalizeFontFamily('Noto Sans 12px')).toBe('Noto Sans')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('strips optical size suffix (em)', () => {
|
|
|
|
|
expect(normalizeFontFamily('Custom Font 1em')).toBe('Custom Font')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('does not strip size units in the middle', () => {
|
|
|
|
|
expect(normalizeFontFamily('12pt Serif')).toBe('12pt Serif')
|
|
|
|
|
})
|
2026-03-16 10:40:16 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('styleToVariant', () => {
|
|
|
|
|
test('regular → "regular"', () => {
|
|
|
|
|
expect(styleToVariant('Regular')).toBe('regular')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('italic at 400 → "italic"', () => {
|
|
|
|
|
expect(styleToVariant('Regular Italic')).toBe('italic')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('bold → "700"', () => {
|
|
|
|
|
expect(styleToVariant('Bold')).toBe('700')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('bold italic → "700italic"', () => {
|
|
|
|
|
expect(styleToVariant('Bold Italic')).toBe('700italic')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('light → "300"', () => {
|
|
|
|
|
expect(styleToVariant('Light')).toBe('300')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('thin italic → "100italic"', () => {
|
|
|
|
|
expect(styleToVariant('Thin Italic')).toBe('100italic')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('semibold → "600"', () => {
|
|
|
|
|
expect(styleToVariant('SemiBold')).toBe('600')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('black → "900"', () => {
|
|
|
|
|
expect(styleToVariant('Black')).toBe('900')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('isVariableFont', () => {
|
|
|
|
|
function makeFontBuffer(tables: string[]): ArrayBuffer {
|
|
|
|
|
const numTables = tables.length
|
|
|
|
|
const headerSize = 12
|
|
|
|
|
const tableRecordSize = 16
|
|
|
|
|
const totalSize = headerSize + numTables * tableRecordSize
|
|
|
|
|
const buf = new ArrayBuffer(totalSize)
|
|
|
|
|
const view = new DataView(buf)
|
|
|
|
|
view.setUint32(0, 0x00010000)
|
|
|
|
|
view.setUint16(4, numTables)
|
|
|
|
|
for (let i = 0; i < numTables; i++) {
|
|
|
|
|
const offset = headerSize + i * tableRecordSize
|
|
|
|
|
for (let c = 0; c < 4; c++) {
|
|
|
|
|
view.setUint8(offset + c, tables[i].charCodeAt(c))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return buf
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('detects fvar table', () => {
|
|
|
|
|
expect(isVariableFont(makeFontBuffer(['head', 'fvar', 'glyf']))).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('returns false without fvar', () => {
|
|
|
|
|
expect(isVariableFont(makeFontBuffer(['head', 'glyf', 'cmap']))).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('returns false for empty buffer', () => {
|
|
|
|
|
expect(isVariableFont(new ArrayBuffer(0))).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('returns false for too-small buffer', () => {
|
|
|
|
|
expect(isVariableFont(new ArrayBuffer(8))).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('fvar as only table', () => {
|
|
|
|
|
expect(isVariableFont(makeFontBuffer(['fvar']))).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('fetchBundledFont', () => {
|
|
|
|
|
test('loads Inter-Regular.ttf from assets in headless', async () => {
|
2026-05-05 15:59:54 +00:00
|
|
|
const buffer = await fontManager.fetchBundledFont('/Inter-Regular.ttf')
|
2026-03-16 10:40:16 +00:00
|
|
|
expect(buffer).toBeInstanceOf(ArrayBuffer)
|
2026-05-05 23:59:56 +00:00
|
|
|
expect(expectDefined(buffer, 'Inter font buffer').byteLength).toBeGreaterThan(100_000)
|
2026-03-16 10:40:16 +00:00
|
|
|
})
|
|
|
|
|
|
2026-05-13 00:50:35 +00:00
|
|
|
test('loads Inter-Bold.ttf from assets in headless', async () => {
|
|
|
|
|
const buffer = await fontManager.fetchBundledFont('/Inter-Bold.ttf')
|
|
|
|
|
expect(buffer).toBeInstanceOf(ArrayBuffer)
|
|
|
|
|
expect(expectDefined(buffer, 'Inter bold font buffer').byteLength).toBeGreaterThan(100_000)
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-16 10:40:16 +00:00
|
|
|
test('returns valid TTF data', async () => {
|
2026-05-05 15:59:54 +00:00
|
|
|
const buffer = await fontManager.fetchBundledFont('/Inter-Regular.ttf')
|
2026-05-05 23:59:56 +00:00
|
|
|
const view = new DataView(expectDefined(buffer, 'Inter font buffer'))
|
2026-03-16 10:40:16 +00:00
|
|
|
// TrueType magic: 0x00010000
|
|
|
|
|
expect(view.getUint32(0)).toBe(0x00010000)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('throws for nonexistent font', async () => {
|
2026-05-05 15:59:54 +00:00
|
|
|
expect(fontManager.fetchBundledFont('/Nonexistent-Font.ttf')).rejects.toThrow()
|
2026-03-16 10:40:16 +00:00
|
|
|
})
|
|
|
|
|
})
|