fix(text): preserve cached font coverage and source offsets

This commit is contained in:
Danila Poyarkov 2026-09-02 09:26:59 +03:00 committed by GitHub
parent 3caf5c99d9
commit 45c401cbf5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 108 additions and 25 deletions

View file

@ -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.

View file

@ -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<FontFallbackScript, string[]>()
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)

View file

@ -149,6 +149,10 @@ export class FontManager {
): Promise<ArrayBuffer | null> {
const cached = await this.readDownloadedFont(family, style, characters)
if (!cached) return null
const key = `${family}|${style}`
const loadedCoverage = this.remoteCoverage.get(key) ?? new Set<string>()
for (const character of normalizedCoverageText(characters)) loadedCoverage.add(character)
this.remoteCoverage.set(key, loadedCoverage)
return this.registerAndCache(family, style, cached, 'cache')
}

View file

@ -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)

View file

@ -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<number, string>()
for (const span of spans) {
spansByOffset.set(offsetsAreUtf16 ? span.utf16Start : span.utf8Start, span.character)
const spansByOffset = new Map<number, MissingGlyphOccurrence>()
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<string>()
const missing = new Map<string, MissingGlyphOccurrence>()
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(

View file

@ -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