From 45c401cbf5a8d2ef2a507f9fd35f1788cbcf0569 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 2 Sep 2026 09:26:59 +0300 Subject: [PATCH] fix(text): preserve cached font coverage and source offsets --- CHANGELOG.md | 6 ++- packages/core/src/canvas/text/index.ts | 33 ++++++++++++---- packages/core/src/text/fonts.ts | 4 ++ packages/core/src/text/requirements.ts | 19 +++++++-- packages/core/src/text/resolver/coverage.ts | 44 +++++++++++++++------ tests/engine/text/fonts/loading.test.ts | 27 +++++++++++++ 6 files changed, 108 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5162e64ee..ff7b2223a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,11 @@ ## Unreleased -### Added +### Fixed + +- Preserve source text offsets when resolving fallback languages after text-case transformations. +- Track character coverage restored from downloaded font cache entries. + - Add a searchable command palette for editor and application actions. - Render triangle and line arrow stroke caps on lines and open vector paths, and choose them from the stroke cap picker. diff --git a/packages/core/src/canvas/text/index.ts b/packages/core/src/canvas/text/index.ts index 554622b20..4b4b77b82 100644 --- a/packages/core/src/canvas/text/index.ts +++ b/packages/core/src/canvas/text/index.ts @@ -22,7 +22,7 @@ import { fontFaceDemand, fontRemoteCoverageDemand, fontResolver, - missingGlyphCharacters, + missingGlyphOccurrences, type FontResolutionSettled } from '#core/text/resolver' @@ -70,12 +70,25 @@ function requiredNodeFaces(node: SceneNode): Array<{ family: string; style: stri return Array.from(faces.values()) } -function languageForCharacter(node: SceneNode, character: string): string | null { - const index = node.text.indexOf(character) - const run = node.styleRuns.find((item) => index >= item.start && index < item.start + item.length) +function languageForCharacter(node: SceneNode, sourceIndex: number): string | null { + const run = node.styleRuns.find( + (item) => sourceIndex >= item.start && sourceIndex < item.start + item.length + ) return run?.style.textLanguage ?? node.textLanguage } +function transformedSourceOffsets(node: SceneNode): number[] { + const offsets: number[] = [] + let sourceIndex = 0 + for (const sourceCharacter of node.text) { + for (const _character of transformTextCase(sourceCharacter, node.textCase)) { + offsets.push(sourceIndex) + } + sourceIndex += sourceCharacter.length + } + return offsets +} + export type NodeFontReadiness = 'ready' | 'substituted' | 'pending' | 'exhausted' function requiredFacesReadiness(r: FontReadinessRenderer, node: SceneNode): NodeFontReadiness { @@ -119,13 +132,17 @@ function demandRemoteCoverage(r: TextRenderer, node: SceneNode, characters: stri function observedGlyphReadiness(r: TextRenderer, node: SceneNode): NodeFontReadiness { const paragraph = buildParagraph(r, node) paragraph.layout(resolveParagraphLayoutWidth(node)) - const missingCharacters = missingGlyphCharacters(node.text, paragraph.getShapedLines()) + const missingOccurrences = missingGlyphOccurrences( + transformTextCase(node.text, node.textCase), + paragraph.getShapedLines(), + transformedSourceOffsets(node) + ) paragraph.delete() - if (missingCharacters.length === 0) return 'ready' + if (missingOccurrences.length === 0) return 'ready' const charactersByScript = new Map() - for (const character of missingCharacters) { - const script = fontFallbackScriptForCharacter(character, languageForCharacter(node, character)) + for (const { character, utf16Start } of missingOccurrences) { + const script = fontFallbackScriptForCharacter(character, languageForCharacter(node, utf16Start)) if (!script) continue const characters = charactersByScript.get(script) ?? [] characters.push(character) diff --git a/packages/core/src/text/fonts.ts b/packages/core/src/text/fonts.ts index 298f2cbe6..91c30518b 100644 --- a/packages/core/src/text/fonts.ts +++ b/packages/core/src/text/fonts.ts @@ -149,6 +149,10 @@ export class FontManager { ): Promise { const cached = await this.readDownloadedFont(family, style, characters) if (!cached) return null + const key = `${family}|${style}` + const loadedCoverage = this.remoteCoverage.get(key) ?? new Set() + for (const character of normalizedCoverageText(characters)) loadedCoverage.add(character) + this.remoteCoverage.set(key, loadedCoverage) return this.registerAndCache(family, style, cached, 'cache') } diff --git a/packages/core/src/text/requirements.ts b/packages/core/src/text/requirements.ts index 8665e69ca..6e62494aa 100644 --- a/packages/core/src/text/requirements.ts +++ b/packages/core/src/text/requirements.ts @@ -57,6 +57,19 @@ function textLanguageAt(node: SceneNode, index: number): string | null { return run?.style.textLanguage ?? node.textLanguage } +function transformedCharactersWithSourceOffsets( + node: SceneNode +): Array<{ character: string; sourceIndex: number }> { + const result: Array<{ character: string; sourceIndex: number }> = [] + let sourceIndex = 0 + for (const sourceCharacter of node.text) { + for (const character of transformTextCase(sourceCharacter, node.textCase)) { + result.push({ character, sourceIndex }) + } + sourceIndex += sourceCharacter.length + } + return result +} export interface GraphFontRequirements { characters: string nodes: SceneNode[] @@ -75,12 +88,10 @@ export function collectGraphFontRequirements( if (!node) return nodes.push(node) if (node.type === 'TEXT') { - let index = 0 - for (const character of transformTextCase(node.text, node.textCase)) { + for (const { character, sourceIndex } of transformedCharactersWithSourceOffsets(node)) { characters.add(character) - const script = fallbackScriptForCharacter(character, textLanguageAt(node, index)) + const script = fallbackScriptForCharacter(character, textLanguageAt(node, sourceIndex)) if (script) scripts.add(script) - index += character.length } } for (const childId of node.childIds) collect(childId) diff --git a/packages/core/src/text/resolver/coverage.ts b/packages/core/src/text/resolver/coverage.ts index 2465ba263..5a7ed0a73 100644 --- a/packages/core/src/text/resolver/coverage.ts +++ b/packages/core/src/text/resolver/coverage.ts @@ -27,31 +27,51 @@ function codePointSpans(text: string): { spans: CodePointSpan[]; utf8Length: num return { spans, utf8Length: utf8Start } } -export function missingGlyphCharacters( +export interface MissingGlyphOccurrence { + character: string + utf16Start: number +} + +export function missingGlyphOccurrences( text: string, - lines: readonly ObservedShapedLine[] -): string[] { + lines: readonly ObservedShapedLine[], + sourceOffsets?: readonly number[] +): MissingGlyphOccurrence[] { if (!text || lines.length === 0) return [] const { spans, utf8Length } = codePointSpans(text) - const finalOffset = lines.at(-1)?.textRange.last + const sourceText = text + const sourceSpans = codePointSpans(sourceText).spans + const finalLine = lines.at(-1) + const finalOffset = finalLine ? finalLine.textRange.last : undefined const offsetsAreUtf16 = finalOffset === text.length && utf8Length !== text.length - const spansByOffset = new Map() - for (const span of spans) { - spansByOffset.set(offsetsAreUtf16 ? span.utf16Start : span.utf8Start, span.character) + const spansByOffset = new Map() + for (const [index, span] of spans.entries()) { + const sourceIndex = + sourceOffsets?.[index] ?? sourceSpans[Math.min(index, sourceSpans.length - 1)].utf16Start + spansByOffset.set(offsetsAreUtf16 ? span.utf16Start : span.utf8Start, { + character: span.character, + utf16Start: sourceIndex + }) } - const missing = new Set() + const missing = new Map() for (const line of lines) { for (const run of line.runs) { for (let index = 0; index < run.glyphs.length; index++) { if (run.glyphs[index] !== 0) continue - const offset = run.offsets[index] - const character = spansByOffset.get(offset) - if (character) missing.add(character) + const occurrence = spansByOffset.get(run.offsets[index]) + if (occurrence) missing.set(`${occurrence.utf16Start}\0${occurrence.character}`, occurrence) } } } - return [...missing] + return [...missing.values()] +} + +export function missingGlyphCharacters( + text: string, + lines: readonly ObservedShapedLine[] +): string[] { + return missingGlyphOccurrences(text, lines).map(({ character }) => character) } export function missingGlyphScripts( diff --git a/tests/engine/text/fonts/loading.test.ts b/tests/engine/text/fonts/loading.test.ts index b68f34681..79f0bafa8 100644 --- a/tests/engine/text/fonts/loading.test.ts +++ b/tests/engine/text/fonts/loading.test.ts @@ -214,6 +214,33 @@ describe('FontManager loaded font cache', () => { expect(writes).toBe(0) }) + 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') + }) test('forwards cancellation to fallback web-font requests', async () => { const manager = new FontManager() let remoteRequestStarted: (() => void) | null = null