fix(text): preserve cached font coverage and source offsets
This commit is contained in:
parent
3caf5c99d9
commit
45c401cbf5
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue