diff --git a/CHANGELOG.md b/CHANGELOG.md index 589ba7cfb..fcc07455b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,10 +29,13 @@ - Add desktop image drag-and-drop into the Tauri app window. - Add open-document discovery for live CLI and MCP automation so agents can target the intended document and page. - Publish lower-level SceneGraph, Pen, Kiwi, Fig, and DOM/CSS functionality through clearer package boundaries for SDK and automation consumers. +- Upgrade `opentype.js` to v2 and add BCP-47 text-language hints for language-correct CJK shaping and fallback order. ### Fixes - Make canvas text rendering demand missing font faces and verify CJK/Arabic fallback coverage from CanvasKit shaping results instead of coarse script predictions. +- Resolve fonts before loaded, pasted, imported, and tool-created nodes render; invalidate generation-stale text caches and use baked `.fig` glyphs only after live font resolution is exhausted. +- Load character-specific remote font subsets without Latin-only assumptions, preserve cumulative subset coverage, and reject unavailable desktop font styles instead of substituting the first family face. - Fix live CLI and MCP automation drifting to the wrong open document or page when multiple files are open. - Improve Chinese, Japanese, and Korean text rendering with glyph-aware fallback fonts and outline rendering when needed. - Preserve imported Figma text sizing more accurately, especially auto-sized text inside auto-layout frames. diff --git a/desktop/src/fonts.rs b/desktop/src/fonts.rs index 213564026..96ee9dd8a 100644 --- a/desktop/src/fonts.rs +++ b/desktop/src/fonts.rs @@ -108,15 +108,7 @@ fn load_system_font_blocking(family: String, style: String) -> Result, S } } - if let Some(handle) = family_handle.fonts().first() { - if let Ok(font) = handle.load() { - if let Some(data) = font.copy_font_data() { - return Ok((*data).clone()); - } - } - } - - Err(format!("Could not load font {family} {style}")) + Err(format!("Font face not found: {family} {style}")) } #[tauri::command] diff --git a/packages/core/src/canvas/boolean.ts b/packages/core/src/canvas/boolean.ts index 4d8feab82..a83fbf77a 100644 --- a/packages/core/src/canvas/boolean.ts +++ b/packages/core/src/canvas/boolean.ts @@ -7,7 +7,7 @@ import { getTextOutlineSupport } from '#core/text/outlines' import { makeArcPath } from './fills' import type { SkiaRenderer } from './renderer' import { nodeHasRadius } from './shapes' -import { textNodeToOutlinePath } from './text-outlines' +import { textNodeToOutlinePath } from './text/outlines' const BOOLEAN_PATH_OP: Record< NonNullable, diff --git a/packages/core/src/canvas/renderer.ts b/packages/core/src/canvas/renderer.ts index 058254bec..5106dad42 100644 --- a/packages/core/src/canvas/renderer.ts +++ b/packages/core/src/canvas/renderer.ts @@ -17,6 +17,7 @@ import { import type { EditorState } from '#core/editor/types' import { RenderProfiler } from '#core/profiler' import type { TextEditor } from '#core/text/editor' +import type { FontResolutionSnapshot } from '#core/text/resolver' import { LabelCache } from './labels/cache' import * as LabelHitTest from './labels/hit-test' @@ -50,6 +51,12 @@ export interface SubtreePictureCacheEntry { pageId: string | null sceneVersion: number positionPreviewVersion: number + fontGeneration: number +} + +export interface PendingFontNode { + node: SceneNode + keys: Set } import type { RenderOverlays, RulerTheme } from './renderer/types' @@ -78,7 +85,12 @@ export class SkiaRenderer { fontMgr: FontMgr | null = null fontProvider: TypefaceFontProvider | null = null fontsLoaded = false - onFontResolutionSettled: (() => void) | undefined + fontGeneration = 0 + onFontResolutionSettled: + | ((snapshot: FontResolutionSnapshot, nodeIds: readonly string[]) => void) + | undefined + pendingFontNodes = new Map() + textPictureGenerations = new Map() imageCache = new Map() vectorPathCache = new Map() vectorStrokePathCache = new Map() @@ -87,6 +99,7 @@ export class SkiaRenderer { strokeGeometryCache = new Map() scenePicture: SkPicture | null = null scenePictureVersion = -1 + scenePictureFontGeneration = -1 scenePicturePositionPreviewVersion = -1 scenePicturePageId: string | null = null sceneBacking: { @@ -94,6 +107,7 @@ export class SkiaRenderer { pageId: string | null sceneVersion: number positionPreviewVersion: number + fontGeneration: number panX: number panY: number zoom: number @@ -116,6 +130,7 @@ export class SkiaRenderer { pageId: string | null sceneVersion: number positionPreviewVersion: number + fontGeneration: number panX: number panY: number zoom: number @@ -132,10 +147,12 @@ export class SkiaRenderer { sceneBackingLastViewportEventAt = 0 lastSceneViewport: { panX: number; panY: number; zoom: number } | null = null nodePictureCache = new Map() + nodePictureCacheGenerations = new Map() subtreePictureCache = new Map() subtreePictureCachePageId: string | null = null subtreePictureCacheSceneVersion = -1 subtreePictureCachePositionPreviewVersion = -1 + subtreePictureCacheFontGeneration = -1 readonly labelCache = new LabelCache() readonly profiler: RenderProfiler @@ -410,6 +427,18 @@ export class SkiaRenderer { await RendererFonts.loadFonts(this, onFallbackFontsLoaded) } + syncFontGeneration(): void { + RendererFonts.syncFontGeneration(this) + } + + trackFontDemand(node: SceneNode, key: string): void { + RendererFonts.trackFontDemand(this, node, key) + } + + isTextPictureCurrent(node: SceneNode): boolean { + return RendererFonts.isTextPictureCurrent(this, node) + } + async prepareForExport( graph: SceneGraph, pageId: string, @@ -566,8 +595,12 @@ export class SkiaRenderer { return RenderText.measureTextNode(this, node, maxWidth) } + nodeFontReadiness(node: SceneNode): RenderText.NodeFontReadiness { + return RenderText.nodeFontReadiness(this, node) + } + isNodeFontLoaded(node: SceneNode): boolean { - return RenderText.isNodeFontLoaded(this, node) + return this.nodeFontReadiness(node) === 'ready' } buildTextPicture(node: SceneNode): Uint8Array | null { diff --git a/packages/core/src/canvas/renderer/fonts.ts b/packages/core/src/canvas/renderer/fonts.ts index 6c5bb369f..35f80fc77 100644 --- a/packages/core/src/canvas/renderer/fonts.ts +++ b/packages/core/src/canvas/renderer/fonts.ts @@ -1,4 +1,4 @@ -import type { SceneGraph } from '@open-pencil/scene-graph' +import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' import type { SkiaRenderer } from '#core/canvas/renderer' import { @@ -10,6 +10,52 @@ import { SIZE_FONT_SIZE } from '#core/constants' import { fontManager } from '#core/text/fonts' +import { collectGraphFontRequirements } from '#core/text/requirements' +import { missingGraphFontScripts } from '#core/text/resolved-requirements' +import type { FontResolutionSnapshot } from '#core/text/resolver' + +export function syncFontGeneration(r: SkiaRenderer): void { + r.fontGeneration = fontManager.generation() +} + +export function trackFontDemand(r: SkiaRenderer, node: SceneNode, key: string): void { + const pending = r.pendingFontNodes.get(node.id) ?? { node, keys: new Set() } + pending.node = node + pending.keys.add(key) + r.pendingFontNodes.set(node.id, pending) +} + +export function isTextPictureCurrent(r: SkiaRenderer, node: SceneNode): boolean { + const data = node.textPicture + if (!data) { + r.textPictureGenerations.delete(node.id) + return false + } + const cached = r.textPictureGenerations.get(node.id) + if (!cached || cached.data !== data) { + r.textPictureGenerations.set(node.id, { data, generation: r.fontGeneration }) + return true + } + return cached.generation === r.fontGeneration +} + +function settleFontDemand( + r: SkiaRenderer, + snapshot: FontResolutionSnapshot, + nodeIds: readonly string[] +): void { + syncFontGeneration(r) + for (const nodeId of nodeIds) { + const pending = r.pendingFontNodes.get(nodeId) + if (pending) { + pending.node.textPicture = null + pending.keys.delete(snapshot.key) + if (pending.keys.size === 0) r.pendingFontNodes.delete(nodeId) + } + r.textPictureGenerations.delete(nodeId) + r.invalidateNodePicture(nodeId) + } +} export function getFontProvider(r: SkiaRenderer) { return r.isDestroyed() || !r.fontProvider ? null : r.fontProvider @@ -20,20 +66,20 @@ export async function loadFonts( onFallbackFontsLoaded?: () => void ): Promise { if (r.isDestroyed()) return - r.onFontResolutionSettled = () => { + r.onFontResolutionSettled = (snapshot, nodeIds) => { if (r.isDestroyed()) return - r.invalidateAllPictures() + settleFontDemand(r, snapshot, nodeIds) onFallbackFontsLoaded?.() } r.fontProvider?.delete() r.fontProvider = r.ck.TypefaceFontProvider.Make() fontManager.attachProvider(r.ck, r.fontProvider) + syncFontGeneration(r) const fontData = await fontManager.loadFont(DEFAULT_FONT_FAMILY, 'Regular') if (r.isDestroyed()) return if (fontData) { - r.fontProvider.registerFont(fontData, DEFAULT_FONT_FAMILY) const typeface = r.ck.Typeface.MakeFreeTypeFaceFromData(fontData) if (typeface) { r.textFont?.delete() @@ -52,22 +98,8 @@ export async function loadFonts( } r.fontsLoaded = true + syncFontGeneration(r) r.invalidateAllPictures() - - void fontManager.ensureCJKFallback().then((families) => { - if (!r.isDestroyed() && families.length > 0) { - r.invalidateAllPictures() - onFallbackFontsLoaded?.() - } - return undefined - }) - void fontManager.ensureArabicFallback().then((families) => { - if (!r.isDestroyed() && families.length > 0) { - r.invalidateAllPictures() - onFallbackFontsLoaded?.() - } - return undefined - }) } export async function prepareForExport( @@ -82,8 +114,15 @@ export async function prepareForExport( setTextMeasurer((node, maxWidth) => r.measureTextNode(node, maxWidth)) const fontKeys = fontManager.collectFontKeys(graph, nodeIds) - await Promise.all(fontKeys.map(([family, style]) => fontManager.loadFont(family, style))) - + const requirements = collectGraphFontRequirements(graph, nodeIds) + await Promise.all( + fontKeys.map(([family, style]) => fontManager.loadFont(family, style, requirements.characters)) + ) + await fontManager.ensureFallbackPack( + missingGraphFontScripts(requirements), + requirements.characters + ) + syncFontGeneration(r) computeAllLayouts(graph, pageId) return () => setTextMeasurer(previousTextMeasurer) diff --git a/packages/core/src/canvas/renderer/pipeline.ts b/packages/core/src/canvas/renderer/pipeline.ts index 00a5290a4..29b8a039a 100644 --- a/packages/core/src/canvas/renderer/pipeline.ts +++ b/packages/core/src/canvas/renderer/pipeline.ts @@ -101,6 +101,7 @@ function scenePictureMissReason( if (graph.positionPreviewVersion !== r.scenePicturePositionPreviewVersion) return 'position-preview-version' if (sceneVersion !== r.scenePictureVersion) return 'scene-version' + if (r.fontGeneration !== r.scenePictureFontGeneration) return 'font-generation' if (r.pageId !== r.scenePicturePageId) return 'page' return 'unknown' } @@ -116,6 +117,7 @@ function canUseScenePicture( !!r.scenePicture && graph.positionPreviewVersion === r.scenePicturePositionPreviewVersion && sceneVersion === r.scenePictureVersion && + r.fontGeneration === r.scenePictureFontGeneration && r.pageId === r.scenePicturePageId ) } @@ -136,6 +138,7 @@ export function render( sceneVersion = -1, layer: RenderLayer = 'full' ): void { + r.syncFontGeneration() const p = r.profiler p.beginFrame() p.setScenePictureDrawTime(0) @@ -348,6 +351,7 @@ function recordScenePicture( recorder.delete() r.worldViewport = prevViewport r.scenePictureVersion = sceneVersion + r.scenePictureFontGeneration = r.fontGeneration r.scenePicturePositionPreviewVersion = graph.positionPreviewVersion r.scenePicturePageId = r.pageId canvas.drawPicture(r.scenePicture) diff --git a/packages/core/src/canvas/renderer/retained-backing.ts b/packages/core/src/canvas/renderer/retained-backing.ts index d94fc26bc..862839ede 100644 --- a/packages/core/src/canvas/renderer/retained-backing.ts +++ b/packages/core/src/canvas/renderer/retained-backing.ts @@ -73,7 +73,8 @@ function backingMetadataMatches( backing && backing.pageId === r.pageId && backing.sceneVersion === sceneVersion && - backing.positionPreviewVersion === positionPreviewVersion + backing.positionPreviewVersion === positionPreviewVersion && + backing.fontGeneration === r.fontGeneration ) } @@ -191,7 +192,8 @@ function ensureSubtreePictureCacheScope( if ( r.subtreePictureCachePageId === r.pageId && r.subtreePictureCacheSceneVersion === sceneVersion && - r.subtreePictureCachePositionPreviewVersion === graph.positionPreviewVersion + r.subtreePictureCachePositionPreviewVersion === graph.positionPreviewVersion && + r.subtreePictureCacheFontGeneration === r.fontGeneration ) { return } @@ -199,6 +201,7 @@ function ensureSubtreePictureCacheScope( r.subtreePictureCachePageId = r.pageId r.subtreePictureCacheSceneVersion = sceneVersion r.subtreePictureCachePositionPreviewVersion = graph.positionPreviewVersion + r.subtreePictureCacheFontGeneration = r.fontGeneration } function cachedSubtreePicture( @@ -213,7 +216,8 @@ function cachedSubtreePicture( cached && cached.pageId === r.pageId && cached.sceneVersion === sceneVersion && - cached.positionPreviewVersion === graph.positionPreviewVersion + cached.positionPreviewVersion === graph.positionPreviewVersion && + cached.fontGeneration === r.fontGeneration ) { return cached.picture } @@ -245,7 +249,8 @@ function cachedSubtreePicture( picture, pageId: r.pageId, sceneVersion, - positionPreviewVersion: graph.positionPreviewVersion + positionPreviewVersion: graph.positionPreviewVersion, + fontGeneration: r.fontGeneration }) return picture } @@ -305,6 +310,7 @@ function installSceneBackingImage( pageId: r.pageId, sceneVersion, positionPreviewVersion, + fontGeneration: r.fontGeneration, ...sceneBackingMetrics(backing) } r.scenePictureVersion = sceneVersion @@ -326,6 +332,7 @@ function sceneBackingBuildMatches(r: SkiaRenderer, sceneVersion: number): boolea build.pageId === r.pageId && build.sceneVersion === sceneVersion && build.positionPreviewVersion === build.graph.positionPreviewVersion && + build.fontGeneration === r.fontGeneration && build.panX === backing.panX && build.panY === backing.panY && build.zoom === backing.zoom && @@ -349,6 +356,7 @@ function startSceneBackingBuild(r: SkiaRenderer, graph: SceneGraph, sceneVersion pageId: r.pageId, sceneVersion, positionPreviewVersion: graph.positionPreviewVersion, + fontGeneration: r.fontGeneration, ...sceneBackingMetrics(backing) } } diff --git a/packages/core/src/canvas/renderer/state.ts b/packages/core/src/canvas/renderer/state.ts index 3b64929e6..66d1c517c 100644 --- a/packages/core/src/canvas/renderer/state.ts +++ b/packages/core/src/canvas/renderer/state.ts @@ -4,6 +4,7 @@ export function invalidateScenePicture(r: SkiaRenderer): void { r.scenePicture?.delete() r.scenePicture = null r.scenePictureVersion = -1 + r.scenePictureFontGeneration = -1 r.sceneBacking?.image.delete() r.sceneBacking = null r.sceneBackingBuild?.surface.delete() @@ -16,12 +17,14 @@ export function clearSubtreePictureCache(r: SkiaRenderer): void { r.subtreePictureCachePageId = null r.subtreePictureCacheSceneVersion = -1 r.subtreePictureCachePositionPreviewVersion = -1 + r.subtreePictureCacheFontGeneration = -1 } export function invalidateAllPictures(r: SkiaRenderer): void { invalidateScenePicture(r) for (const pic of r.nodePictureCache.values()) pic?.delete() r.nodePictureCache.clear() + r.nodePictureCacheGenerations.clear() clearSubtreePictureCache(r) } @@ -30,6 +33,7 @@ export function invalidateNodePicture(r: SkiaRenderer, nodeId: string): void { if (pic) { pic.delete() r.nodePictureCache.delete(nodeId) + r.nodePictureCacheGenerations.delete(nodeId) } const subtree = r.subtreePictureCache.get(nodeId) if (subtree) { diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index 823bc4d59..1356f4a4b 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -6,6 +6,7 @@ import { computeDescendantVisualBounds } from '@open-pencil/scene-graph/geometry import type { Color } from '@open-pencil/scene-graph/primitives' import { DROP_HIGHLIGHT_ALPHA, DROP_HIGHLIGHT_STROKE, SECTION_CORNER_RADIUS } from '#core/constants' +import { fontManager } from '#core/text/fonts' import { vectorNetworkToCenterlinePath } from '#core/vector' import { figmaBlendModeToSkia, needsIsolatedBlendLayer } from './blend' @@ -20,8 +21,8 @@ import { getStrokeCapEntity, getStrokeJoinEntity } from './strokes' -import { drawFigmaDerivedText } from './text-derived' -import { textNodeToOutlinePath } from './text-outlines' +import { drawFigmaDerivedText } from './text/derived' +import { textNodeToOutlinePath } from './text/outlines' function drawVisibleFills( r: SkiaRenderer, @@ -196,7 +197,7 @@ export function renderNode( parentAbsY = 0 ): void { const node = graph.getNode(nodeId) - if (!node || !node.visible || node.isMask) return + if (!node || !node.visible || node.isMask || fontManager.isNodeBlocked(nodeId)) return // Hide the node being edited in node-edit mode (overlay draws it live) if (overlays.nodeEditState?.nodeId === nodeId) return @@ -351,10 +352,14 @@ export function renderShape( if (hasEffects) { const cached = r.nodePictureCache.get(node.id) - if (cached) { + const cachedGeneration = r.nodePictureCacheGenerations.get(node.id) + if (cached && cachedGeneration === r.fontGeneration) { canvas.drawPicture(cached) return } + if (cached) cached.delete() + r.nodePictureCache.delete(node.id) + r.nodePictureCacheGenerations.delete(node.id) const margin = r.effectOverflow(node) const bounds = r.ck.LTRBRect(-margin, -margin, node.width + margin, node.height + margin) @@ -364,6 +369,7 @@ export function renderShape( const picture = recorder.finishRecordingAsPicture() recorder.delete() r.nodePictureCache.set(node.id, picture) + r.nodePictureCacheGenerations.set(node.id, r.fontGeneration) canvas.drawPicture(picture) } else { r.renderShapeUncached(canvas, node, graph) @@ -606,12 +612,6 @@ function drawOutlinedText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): boo return true } -const CJK_TEXT_PATTERN = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/u - -function shouldRenderCJKAsOutline(node: SceneNode): boolean { - return CJK_TEXT_PATTERN.test(node.text) -} - function drawGradientText( r: SkiaRenderer, canvas: Canvas, @@ -654,28 +654,27 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode, fil } const paragraphY = 0 - if (node.textPicture) { - const pic = r.ck.MakePicture(node.textPicture) - if (pic) { - canvas.drawPicture(pic) - pic.delete() - canvas.restore() - return + const fontReadiness = r.nodeFontReadiness(node) + if (fontReadiness !== 'ready') { + if (fontReadiness === 'exhausted') { + if (node.textPicture && r.isTextPictureCurrent(node)) { + const pic = r.ck.MakePicture(node.textPicture) + if (pic) { + canvas.drawPicture(pic) + pic.delete() + canvas.restore() + return + } + } + if (drawFigmaDerivedText(r, canvas, node)) { + canvas.restore() + return + } } - } - if (drawFigmaDerivedText(r, canvas, node)) { canvas.restore() return } - - if (!r.isNodeFontLoaded(node)) { - canvas.restore() - return - } - if ( - (shouldRenderTextAsOutline(fill) || shouldRenderCJKAsOutline(node)) && - drawOutlinedText(r, canvas, node) - ) { + if (shouldRenderTextAsOutline(fill) && drawOutlinedText(r, canvas, node)) { canvas.restore() return } diff --git a/packages/core/src/canvas/text/clipboard.ts b/packages/core/src/canvas/text/clipboard.ts new file mode 100644 index 000000000..edcc36472 --- /dev/null +++ b/packages/core/src/canvas/text/clipboard.ts @@ -0,0 +1,127 @@ +import type { Paragraph } from 'canvaskit-wasm' + +import type { NodeChange } from '@open-pencil/kiwi/fig/codec' +import type { SceneNode } from '@open-pencil/scene-graph' + +import { buildParagraph } from '#core/canvas/text' +import { getCanvasKit } from '#core/canvaskit' +import { fontManager } from '#core/text/fonts' + +export interface ClipboardShapedGlyph { + glyphIndex: number + firstCharacter: number + x: number + y: number + advance: number +} + +export interface ClipboardShapedText { + lineHeight: number + lineAscent: number + lineWidth: number + baseline: number + baselines?: NonNullable['baselines'] + glyphs: ClipboardShapedGlyph[] + logicalIndexToCharacterOffsetMap: number[] +} + +function addShapedRunGlyphs( + run: ReturnType[number]['runs'][number], + glyphs: ClipboardShapedGlyph[], + logicalIndexToCharacterOffsetMap: number[], + fallbackLineY: number, + fallbackLineWidth: number +): void { + const positions = run.positions + for (let index = 0; index < run.glyphs.length; index++) { + const x = positions[index * 2] ?? 0 + const y = positions[index * 2 + 1] ?? fallbackLineY + const nextX = positions[(index + 1) * 2] ?? x + const glyphCharacter = run.offsets[index] ?? index + glyphs.push({ + glyphIndex: index, + firstCharacter: glyphCharacter, + x, + y, + advance: nextX - x + }) + if (glyphCharacter >= 0 && glyphCharacter < logicalIndexToCharacterOffsetMap.length) { + logicalIndexToCharacterOffsetMap[glyphCharacter] = x + } + } + + const finalOffset = run.offsets[run.offsets.length - 1] + const finalX = positions[positions.length - 2] ?? fallbackLineWidth + if (finalOffset >= 0 && finalOffset < logicalIndexToCharacterOffsetMap.length) { + logicalIndexToCharacterOffsetMap[finalOffset] = finalX + } +} + +function addLineBaseline( + metrics: ReturnType[number], + textLength: number, + baselines: NonNullable +): void { + if (metrics.startIndex >= textLength) return + baselines.push({ + firstCharacter: metrics.startIndex, + endCharacter: metrics.endIndex, + position: { x: 0, y: metrics.baseline }, + width: metrics.width, + lineY: metrics.startIndex === 0 ? 0 : metrics.baseline - Math.abs(metrics.ascent), + lineHeight: metrics.height, + lineAscent: Math.abs(metrics.ascent) + }) +} + +export async function shapeTextForClipboard(node: SceneNode): Promise { + const ck = await getCanvasKit() + const fontProvider = fontManager.provider() + if (!fontProvider) return null + + const paragraph = buildParagraph({ ck, fontProvider, fontsLoaded: true }, node) + paragraph.layout(node.textAutoResize === 'WIDTH_AND_HEIGHT' ? 1e6 : node.width) + const shapedLines = paragraph.getShapedLines() + const lineMetrics = paragraph.getLineMetrics() + if (shapedLines.length === 0 || lineMetrics.length === 0) { + paragraph.delete() + return null + } + const firstMetrics = lineMetrics[0] + + const glyphs: ClipboardShapedGlyph[] = [] + const baselines: NonNullable = [] + const logicalIndexToCharacterOffsetMap = Array.from({ length: node.text.length + 1 }, () => 0) + + for (let lineIndex = 0; lineIndex < shapedLines.length; lineIndex++) { + const line = shapedLines[lineIndex] + const metrics = lineMetrics[lineIndex] ?? firstMetrics + for (const run of line.runs) { + addShapedRunGlyphs( + run, + glyphs, + logicalIndexToCharacterOffsetMap, + metrics.baseline, + metrics.width + ) + } + addLineBaseline(metrics, node.text.length, baselines) + } + + for (let index = 1; index < logicalIndexToCharacterOffsetMap.length; index++) { + if (logicalIndexToCharacterOffsetMap[index] === 0) { + logicalIndexToCharacterOffsetMap[index] = logicalIndexToCharacterOffsetMap[index - 1] + } + } + + paragraph.delete() + return { + lineHeight: firstMetrics.height, + lineAscent: Math.abs(firstMetrics.ascent), + lineWidth: firstMetrics.width, + baseline: firstMetrics.baseline, + baselines, + glyphs, + logicalIndexToCharacterOffsetMap + } +} diff --git a/packages/core/src/canvas/text-derived.ts b/packages/core/src/canvas/text/derived.ts similarity index 99% rename from packages/core/src/canvas/text-derived.ts rename to packages/core/src/canvas/text/derived.ts index 63f1f5000..216dd6a03 100644 --- a/packages/core/src/canvas/text-derived.ts +++ b/packages/core/src/canvas/text/derived.ts @@ -2,10 +2,9 @@ import type { Canvas, Paint } from 'canvaskit-wasm' import type { Fill, SceneNode, StyleRun, TextDecorationStyle } from '@open-pencil/scene-graph' +import type { SkiaRenderer } from '#core/canvas/renderer' import { geometryBlobToPath } from '#core/vector' -import type { SkiaRenderer } from './renderer' - interface DecorationRange { x1: number x2: number diff --git a/packages/core/src/canvas/text.ts b/packages/core/src/canvas/text/index.ts similarity index 72% rename from packages/core/src/canvas/text.ts rename to packages/core/src/canvas/text/index.ts index 481edb8a6..636327fbb 100644 --- a/packages/core/src/canvas/text.ts +++ b/packages/core/src/canvas/text/index.ts @@ -8,10 +8,8 @@ import type { } from 'canvaskit-wasm' import { uniq } from 'es-toolkit/array' -import type { NodeChange } from '@open-pencil/kiwi/fig/codec' import type { SceneNode } from '@open-pencil/scene-graph' -import { getCanvasKit } from '#core/canvaskit' import { resolveRGBAForPreview } from '#core/color/management' import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from '#core/constants' import { fontFallbackScriptForCharacter } from '#core/text/coverage' @@ -21,15 +19,18 @@ import { fontManager, weightToStyle } from '#core/text/fonts' import { fontCoverageDemand, fontFaceDemand, + fontRemoteCoverageDemand, fontResolver, - missingGlyphCharacters + missingGlyphCharacters, + type FontResolutionSettled } from '#core/text/resolver' interface FontReadinessRenderer { ck?: CanvasKit fontProvider?: TypefaceFontProvider | null fontsLoaded?: boolean - onFontResolutionSettled?: () => void + onFontResolutionSettled?: FontResolutionSettled + trackFontDemand?: (node: SceneNode, key: string) => void } interface TextRenderer extends FontReadinessRenderer { @@ -38,88 +39,134 @@ interface TextRenderer extends FontReadinessRenderer { fontsLoaded: boolean } -export interface ClipboardShapedGlyph { - glyphIndex: number - firstCharacter: number - x: number - y: number - advance: number -} - -export interface ClipboardShapedText { - lineHeight: number - lineAscent: number - lineWidth: number - baseline: number - baselines?: NonNullable['baselines'] - glyphs: ClipboardShapedGlyph[] - logicalIndexToCharacterOffsetMap: number[] -} - const FONT_FAMILY_CACHE_LIMIT = 256 const fontFamilyCache = new Map() -function demandFace(r: FontReadinessRenderer, family: string, style: string): boolean { +function demandFace( + r: FontReadinessRenderer, + node: SceneNode, + family: string, + style: string +): boolean { if (fontManager.isStyleLoaded(family, style)) return true - void fontResolver.demand(fontFaceDemand(family, style), r.onFontResolutionSettled) + const demand = fontFaceDemand(family, style, node.text) + r.trackFontDemand?.(node, demand.key) + void fontResolver.demandForNode(demand, node.id, r.onFontResolutionSettled) return false } -function hasRequiredFaces(r: FontReadinessRenderer, node: SceneNode): boolean { +function requiredNodeFaces(node: SceneNode): Array<{ family: string; style: string }> { const baseFamily = node.fontFamily || DEFAULT_FONT_FAMILY - let ready = demandFace(r, baseFamily, weightToStyle(node.fontWeight, node.italic)) - + const faces = new Map() + const add = (family: string, style: string) => faces.set(`${family}\0${style}`, { family, style }) + add(baseFamily, weightToStyle(node.fontWeight, node.italic)) for (const run of node.styleRuns) { const family = run.style.fontFamily ?? baseFamily const weight = run.style.fontWeight ?? node.fontWeight const italic = run.style.italic ?? node.italic - if (!demandFace(r, family, weightToStyle(weight, italic))) ready = false + add(family, weightToStyle(weight, italic)) } - return ready + return Array.from(faces.values()) } -function hasObservedGlyphCoverage(r: TextRenderer, node: SceneNode): boolean { +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) + return run?.style.textLanguage ?? node.textLanguage +} + +export type NodeFontReadiness = 'ready' | 'pending' | 'exhausted' + +function requiredFacesReadiness(r: FontReadinessRenderer, node: SceneNode): NodeFontReadiness { + let pending = false + let exhausted = false + for (const { family, style } of requiredNodeFaces(node)) { + if (fontManager.isStyleLoaded(family, style)) continue + const demand = fontFaceDemand(family, style, node.text) + const state = fontResolver.state(demand).state + demandFace(r, node, family, style) + if (state === 'failed' || state === 'exhausted') exhausted = true + else pending = true + } + if (pending) return 'pending' + return exhausted ? 'exhausted' : 'ready' +} + +function demandRemoteCoverage(r: TextRenderer, node: SceneNode, characters: string[]): boolean { + for (const { family, style } of requiredNodeFaces(node)) { + if (!fontManager.remoteStyleNeedsCoverage(family, style, characters)) continue + const demand = fontRemoteCoverageDemand(family, style, characters) + const state = fontResolver.state(demand).state + if (state === 'idle') { + r.trackFontDemand?.(node, demand.key) + void fontResolver.demandForNode(demand, node.id, r.onFontResolutionSettled) + return true + } + if (state === 'loading') return true + if (state === 'loaded') fontResolver.exhaust(demand) + } + return false +} + +function observedGlyphReadiness(r: TextRenderer, node: SceneNode): NodeFontReadiness { const paragraph = buildParagraph(r, node) paragraph.layout(resolveParagraphLayoutWidth(node)) const missingCharacters = missingGlyphCharacters(node.text, paragraph.getShapedLines()) paragraph.delete() - if (missingCharacters.length === 0) return true + if (missingCharacters.length === 0) return 'ready' const charactersByScript = new Map() for (const character of missingCharacters) { - const script = fontFallbackScriptForCharacter(character) + const script = fontFallbackScriptForCharacter(character, languageForCharacter(node, character)) if (!script) continue const characters = charactersByScript.get(script) ?? [] characters.push(character) charactersByScript.set(script, characters) } - let ready = true + let pending = false + let exhausted = charactersByScript.size === 0 for (const [script, characters] of charactersByScript) { + if (demandRemoteCoverage(r, node, characters)) { + pending = true + continue + } + const demand = fontCoverageDemand(script, characters) const state = fontResolver.state(demand).state if (state === 'loaded') { fontResolver.exhaust(demand) + exhausted = true continue } - if (state === 'exhausted') continue - ready = false + if (state === 'exhausted' || state === 'failed') { + exhausted = true + continue + } + pending = true if (state === 'idle') { - void fontResolver.demand(demand, r.onFontResolutionSettled) + r.trackFontDemand?.(node, demand.key) + void fontResolver.demandForNode(demand, node.id, r.onFontResolutionSettled) } } - return ready + if (pending) return 'pending' + return exhausted ? 'exhausted' : 'ready' } function canObserveGlyphCoverage(r: FontReadinessRenderer): r is TextRenderer { return r.ck !== undefined && r.fontProvider != null && r.fontsLoaded !== undefined } +export function nodeFontReadiness(r: FontReadinessRenderer, node: SceneNode): NodeFontReadiness { + if (node.type !== 'TEXT') return 'ready' + const faces = requiredFacesReadiness(r, node) + if (faces !== 'ready') return faces + if (!node.text || !canObserveGlyphCoverage(r)) return 'ready' + return observedGlyphReadiness(r, node) +} + export function isNodeFontLoaded(r: FontReadinessRenderer, node: SceneNode): boolean { - if (node.type !== 'TEXT') return true - if (!hasRequiredFaces(r, node)) return false - if (!node.text || !canObserveGlyphCoverage(r)) return true - return hasObservedGlyphCoverage(r, node) + return nodeFontReadiness(r, node) === 'ready' } export function measureTextNode( @@ -188,13 +235,19 @@ function resolveParagraphFontFamilies( cjkFallbacks: readonly string[] ): string[] { const renderPrimary = fontManager.renderFamily(primary, style) - const key = `${renderPrimary}\0${arabicFallbacks.join('\0')}\0${cjkFallbacks.join('\0')}` + const renderArabicFallbacks = arabicFallbacks.map((family) => + fontManager.renderFamily(family, 'Regular') + ) + const renderCJKFallbacks = cjkFallbacks.map((family) => + fontManager.renderFamily(family, 'Regular') + ) + const key = `${renderPrimary}\0${renderArabicFallbacks.join('\0')}\0${renderCJKFallbacks.join('\0')}` const cached = fontFamilyCache.get(key) if (cached) return cached const families = [renderPrimary] if (primary !== DEFAULT_FONT_FAMILY) families.push(DEFAULT_FONT_FAMILY) - families.push(...arabicFallbacks, ...cjkFallbacks) + families.push(...renderArabicFallbacks, ...renderCJKFallbacks) const resolved = uniq(families) fontFamilyCache.set(key, resolved) @@ -293,6 +346,13 @@ function styleRunColor( return ck.Color4f(color.r, color.g, color.b, color.a * visibleFill.opacity) } +function styleRunLanguage( + style: SceneNode['styleRuns'][number]['style'], + node: SceneNode +): string | undefined { + return style.textLanguage ?? node.textLanguage ?? undefined +} + function pushStyleRun( r: TextRenderer, builder: ReturnType, @@ -317,6 +377,7 @@ function pushStyleRun( style.italic ?? node.italic ), fontSize: runFontSize, + locale: styleRunLanguage(style, node), fontStyle: { weight: { value: style.fontWeight ?? node.fontWeight } as FontWeight, slant: (style.italic ?? node.italic) ? ck.FontSlant.Italic : ck.FontSlant.Upright @@ -401,6 +462,7 @@ export function buildParagraph( node.italic ), fontSize: baseFontSize, + locale: node.textLanguage ?? undefined, fontStyle: { weight: { value: node.fontWeight } as FontWeight, slant: node.italic ? ck.FontSlant.Italic : ck.FontSlant.Upright @@ -436,100 +498,3 @@ export function buildParagraph( builder.delete() return paragraph } - -function addShapedRunGlyphs( - run: ReturnType[number]['runs'][number], - glyphs: ClipboardShapedGlyph[], - logicalIndexToCharacterOffsetMap: number[], - fallbackLineY: number, - fallbackLineWidth: number -): void { - const positions = run.positions - for (let i = 0; i < run.glyphs.length; i++) { - const x = positions[i * 2] ?? 0 - const y = positions[i * 2 + 1] ?? fallbackLineY - const nextX = positions[(i + 1) * 2] ?? x - const glyphCharacter = run.offsets[i] ?? i - glyphs.push({ - glyphIndex: i, - firstCharacter: glyphCharacter, - x, - y, - advance: nextX - x - }) - if (glyphCharacter >= 0 && glyphCharacter < logicalIndexToCharacterOffsetMap.length) { - logicalIndexToCharacterOffsetMap[glyphCharacter] = x - } - } - - const finalOffset = run.offsets[run.offsets.length - 1] - const finalX = positions[positions.length - 2] ?? fallbackLineWidth - if (finalOffset >= 0 && finalOffset < logicalIndexToCharacterOffsetMap.length) { - logicalIndexToCharacterOffsetMap[finalOffset] = finalX - } -} - -function addLineBaseline( - metrics: ReturnType[number], - textLength: number, - baselines: NonNullable -): void { - if (metrics.startIndex >= textLength) return - baselines.push({ - firstCharacter: metrics.startIndex, - endCharacter: metrics.endIndex, - position: { x: 0, y: metrics.baseline }, - width: metrics.width, - lineY: metrics.startIndex === 0 ? 0 : metrics.baseline - Math.abs(metrics.ascent), - lineHeight: metrics.height, - lineAscent: Math.abs(metrics.ascent) - }) -} - -export async function shapeTextForClipboard(node: SceneNode): Promise { - const ck = await getCanvasKit() - const fontProvider = fontManager.provider() - if (!fontProvider) return null - - const paragraph = buildParagraph({ ck, fontProvider, fontsLoaded: true }, node) - paragraph.layout(node.textAutoResize === 'WIDTH_AND_HEIGHT' ? 1e6 : node.width) - const shapedLines = paragraph.getShapedLines() - const lineMetrics = paragraph.getLineMetrics() - if (shapedLines.length === 0 || lineMetrics.length === 0) { - paragraph.delete() - return null - } - const firstMetrics = lineMetrics[0] - - const glyphs: ClipboardShapedGlyph[] = [] - const baselines: NonNullable = [] - const logicalIndexToCharacterOffsetMap = Array.from({ length: node.text.length + 1 }, () => 0) - - for (let lineIdx = 0; lineIdx < shapedLines.length; lineIdx++) { - const line = shapedLines[lineIdx] - const metrics = lineMetrics[lineIdx] ?? firstMetrics - const lineY = metrics.baseline - for (const run of line.runs) { - addShapedRunGlyphs(run, glyphs, logicalIndexToCharacterOffsetMap, lineY, metrics.width) - } - addLineBaseline(metrics, node.text.length, baselines) - } - - for (let i = 1; i < logicalIndexToCharacterOffsetMap.length; i++) { - if (logicalIndexToCharacterOffsetMap[i] === 0) { - logicalIndexToCharacterOffsetMap[i] = logicalIndexToCharacterOffsetMap[i - 1] - } - } - - paragraph.delete() - - return { - lineHeight: firstMetrics.height, - lineAscent: Math.abs(firstMetrics.ascent), - lineWidth: firstMetrics.width, - baseline: firstMetrics.baseline, - baselines, - glyphs, - logicalIndexToCharacterOffsetMap - } -} diff --git a/packages/core/src/canvas/text-outlines.ts b/packages/core/src/canvas/text/outlines.ts similarity index 96% rename from packages/core/src/canvas/text-outlines.ts rename to packages/core/src/canvas/text/outlines.ts index 823013101..17388cb3b 100644 --- a/packages/core/src/canvas/text-outlines.ts +++ b/packages/core/src/canvas/text/outlines.ts @@ -2,11 +2,10 @@ import type { Path } from 'canvaskit-wasm' import type { SceneNode } from '@open-pencil/scene-graph' +import type { SkiaRenderer } from '#core/canvas/renderer' import type { OutlineCommand } from '#core/text/opentype' import { textNodeToOutlineLayout } from '#core/text/outlines' -import type { SkiaRenderer } from './renderer' - function appendOutlineCommand( path: Path, command: OutlineCommand, diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index 537c65160..b246fcfdd 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -5,7 +5,7 @@ import type { NodeChange as KiwiNodeChange } from '@open-pencil/kiwi/fig/codec' import { decodeBinarySchema, compileSchema, ByteBuffer } from '@open-pencil/kiwi/schema-runtime' import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' -import { shapeTextForClipboard } from './canvas/text' +import { shapeTextForClipboard } from './canvas/text/clipboard' import { populateAndApplyOverrides } from './kiwi/fig/instance-overrides' import type { InstanceNodeChange } from './kiwi/fig/instance-overrides' import { diff --git a/packages/core/src/editor/clipboard.ts b/packages/core/src/editor/clipboard.ts index db1984633..78d321b96 100644 --- a/packages/core/src/editor/clipboard.ts +++ b/packages/core/src/editor/clipboard.ts @@ -87,7 +87,8 @@ export function createClipboardActions(ctx: EditorContext) { async function pasteFromHTML(html: string, cursorPos?: Vector, options: PasteOptions = {}) { const openPencil = parseOpenPencilClipboard(html) if (openPencil) { - pasteOpenPencilNodes(openPencil.nodes, openPencil.images, cursorPos, options) + const created = pasteOpenPencilNodes(openPencil.nodes, openPencil.images, cursorPos, options) + await fontActions.loadFontsForNodes(created) return } @@ -106,7 +107,7 @@ export function createClipboardActions(ctx: EditorContext) { replacementTargets, prevSelection ) - void fontActions.loadFontsForNodes(created) + await fontActions.loadFontsForNodes(created) warnMissingImages(created) ctx.requestRender() return @@ -119,7 +120,7 @@ export function createClipboardActions(ctx: EditorContext) { ctx.setSelectedIds(new Set(created)) pushPasteUndo(created, prevSelection) - void fontActions.loadFontsForNodes(created) + await fontActions.loadFontsForNodes(created) warnMissingImages(created) ctx.requestRender() } @@ -151,7 +152,7 @@ export function createClipboardActions(ctx: EditorContext) { const pasteTarget = replacementTargets[0]?.parentId ?? resolvePasteTarget(ctx) for (const node of nodes) created.push(createNodeTree(node, pasteTarget)) - if (created.length === 0) return + if (created.length === 0) return created if (replacementTargets.length > 0) { replaceTargetsWithCreated( @@ -161,7 +162,7 @@ export function createClipboardActions(ctx: EditorContext) { replacementTargets, prevSelection ) - return + return created } if (cursorPos) placementActions.centerNodesAt(created, cursorPos.x, cursorPos.y) @@ -169,6 +170,7 @@ export function createClipboardActions(ctx: EditorContext) { ctx.setSelectedIds(new Set(created)) pushPasteUndo(created, prevSelection) + return created } function warnMissingImages(nodeIds: string[]) { diff --git a/packages/core/src/editor/clipboard/fonts.ts b/packages/core/src/editor/clipboard/fonts.ts index f8b733469..865bff451 100644 --- a/packages/core/src/editor/clipboard/fonts.ts +++ b/packages/core/src/editor/clipboard/fonts.ts @@ -1,16 +1,37 @@ import type { EditorContext } from '#core/editor/types' import { computeAllLayouts } from '#core/layout' import { fontManager } from '#core/text/fonts' +import { collectGraphFontRequirements } from '#core/text/requirements' +import { missingGraphFontScripts } from '#core/text/resolved-requirements' export function createClipboardFontActions(ctx: EditorContext) { async function loadFontsForNodes(nodeIds: string[]) { - const toLoad = fontManager.collectFontKeys(ctx.graph, nodeIds) - if (toLoad.length === 0) return [] - - const results = await Promise.all(toLoad.map(([family, style]) => ctx.loadFont(family, style))) - const failed = toLoad.filter((_, i) => results[i] === null) - computeAllLayouts(ctx.graph, ctx.state.currentPageId) - return failed + fontManager.blockNodesUntilFontsResolve(nodeIds) + try { + const requirements = collectGraphFontRequirements(ctx.graph, nodeIds) + const toLoad = fontManager.collectFontKeys(ctx.graph, nodeIds) + const results = await Promise.all( + toLoad.map(([family, style]) => ctx.loadFont(family, style, requirements.characters)) + ) + const failed = toLoad.filter((_, index) => results[index] === null) + const requiredFallbacks = missingGraphFontScripts(requirements) + const fallbacks = await fontManager.ensureFallbackPack( + requiredFallbacks, + requirements.characters + ) + const missingFallback = requiredFallbacks.some( + (script) => (fallbacks[script]?.length ?? 0) === 0 + ) + if (failed.length === 0 && !missingFallback) { + for (const node of requirements.nodes) if (node.type === 'TEXT') node.textPicture = null + } + computeAllLayouts(ctx.graph, ctx.state.currentPageId) + return failed + } finally { + fontManager.unblockNodes(nodeIds) + ctx.getRenderer()?.invalidateAllPictures() + ctx.requestRender() + } } return { loadFontsForNodes } diff --git a/packages/core/src/editor/pages.ts b/packages/core/src/editor/pages.ts index 98704b918..18600b599 100644 --- a/packages/core/src/editor/pages.ts +++ b/packages/core/src/editor/pages.ts @@ -3,6 +3,8 @@ import type { Color } from '@open-pencil/scene-graph/primitives' import { populateLazyFigImportRoots } from '#core/kiwi/fig/lazy-import' import { computeAllLayouts } from '#core/layout' import { fontManager } from '#core/text/fonts' +import { collectGraphFontRequirements } from '#core/text/requirements' +import { missingGraphFontScripts } from '#core/text/resolved-requirements' import { createPageViewportStore } from './page-viewports' import type { EditorContext } from './types' @@ -26,12 +28,29 @@ export function createPageActions(ctx: EditorContext) { const populated = populateLazyFigImportRoots(ctx.graph, [pageId]) - const toLoad = fontManager.collectFontKeys( - ctx.graph, - ctx.graph.getChildren(pageId).map((n) => n.id) - ) - if (toLoad.length > 0) { - await Promise.all(toLoad.map(([family, style]) => ctx.loadFont(family, style))) + const childIds = ctx.graph.getChildren(pageId).map((node) => node.id) + const toLoad = fontManager.collectFontKeys(ctx.graph, childIds) + const requirements = collectGraphFontRequirements(ctx.graph, childIds) + fontManager.blockNodesUntilFontsResolve(childIds) + try { + const results = await Promise.all( + toLoad.map(([family, style]) => ctx.loadFont(family, style, requirements.characters)) + ) + const requiredFallbacks = missingGraphFontScripts(requirements) + const fallbacks = await fontManager.ensureFallbackPack( + requiredFallbacks, + requirements.characters + ) + const facesReady = results.every((result) => result !== null) + const fallbacksReady = requiredFallbacks.every( + (script) => (fallbacks[script]?.length ?? 0) > 0 + ) + if (facesReady && fallbacksReady) { + for (const node of requirements.nodes) if (node.type === 'TEXT') node.textPicture = null + } + } finally { + fontManager.unblockNodes(childIds) + ctx.getRenderer()?.invalidateAllPictures() } if (ctx.getRenderer() || populated) { computeAllLayouts(ctx.graph, pageId) diff --git a/packages/core/src/editor/types.ts b/packages/core/src/editor/types.ts index ee6de93b8..313ebf131 100644 --- a/packages/core/src/editor/types.ts +++ b/packages/core/src/editor/types.ts @@ -105,7 +105,7 @@ export type EditorEventName = keyof EditorEvents export interface EditorOptions { graph?: SceneGraph state?: EditorState - loadFont?: (family: string, style: string) => Promise + loadFont?: (family: string, style: string, characters?: string) => Promise getViewportSize?: () => { width: number; height: number } skipInitialGraphSetup?: boolean } @@ -115,7 +115,7 @@ export interface EditorContext { set graph(g: SceneGraph) undo: UndoManager state: EditorState - loadFont: (family: string, style: string) => Promise + loadFont: (family: string, style: string, characters?: string) => Promise getViewportSize: () => { width: number; height: number } getCk: () => CanvasKit | null getRenderer: () => SkiaRenderer | null diff --git a/packages/core/src/text/coverage.ts b/packages/core/src/text/coverage.ts index 4f312c447..a7ea77040 100644 --- a/packages/core/src/text/coverage.ts +++ b/packages/core/src/text/coverage.ts @@ -1,8 +1,8 @@ import type { SceneNode } from '@open-pencil/scene-graph' import { DEFAULT_FONT_FAMILY } from '#core/constants' -import type { FontFallbackScript } from '#core/text/fallbacks' -import { weightToStyle } from '#core/text/fonts' +import { cjkFallbackScriptForLanguage, type FontFallbackScript } from '#core/text/fallbacks' +import { weightToStyle } from '#core/text/font-style' import { fontGlyphCoverageSync } from '#core/text/opentype' const CJK_IDEOGRAPH_CHAR_RE = /\p{Script=Han}/u @@ -30,29 +30,39 @@ function scriptCharRegex(script: FontFallbackScript): RegExp { } } -export function fontFallbackScriptForCharacter(char: string): FontFallbackScript | null { +export function fontFallbackScriptForCharacter( + char: string, + language?: string | null +): FontFallbackScript | null { if (ARABIC_CHAR_RE.test(char)) return 'arabic' if (CJK_HANGUL_RE.test(char)) return 'cjk-kr' if (CJK_HIRAGANA_KATAKANA_RE.test(char)) return 'cjk-jp' + const languageScript = cjkFallbackScriptForLanguage(language) + if (languageScript && CJK_IDEOGRAPH_CHAR_RE.test(char)) return languageScript if (TRADITIONAL_CJK_CHAR_RE.test(char)) return 'cjk-tc' if (CJK_IDEOGRAPH_CHAR_RE.test(char)) return 'cjk-sc' return null } -function styleForCharacter(node: SceneNode, index: number): { family: string; style: string } { +function styleForCharacter( + node: SceneNode, + index: number +): { family: string; style: string; language: string | null } { const baseFamily = node.fontFamily || DEFAULT_FONT_FAMILY let family = baseFamily let weight = node.fontWeight let italic = node.italic + let language = node.textLanguage const run = node.styleRuns.find((item) => index >= item.start && index < item.start + item.length) if (run) { family = run.style.fontFamily ?? family weight = run.style.fontWeight ?? weight italic = run.style.italic ?? italic + language = run.style.textLanguage ?? language } - return { family, style: weightToStyle(weight, italic) } + return { family, style: weightToStyle(weight, italic), language } } /** @@ -84,13 +94,17 @@ export function textNeededFallbackScripts(node: SceneNode): FontFallbackScript[] let index = 0 for (const char of node.text) { if (CJK_CHAR_RE.test(char)) { - const { family, style } = styleForCharacter(node, index) + const { family, style, language } = styleForCharacter(node, index) if (fontGlyphCoverageSync(family, style, char) === 'missing') { if (CJK_IDEOGRAPH_CHAR_RE.test(char)) { - missingIdeograph = true - if (TRADITIONAL_CJK_CHAR_RE.test(char)) missingTraditionalIdeograph = true + const languageScript = cjkFallbackScriptForLanguage(language) + if (languageScript) scripts.add(languageScript) + else { + missingIdeograph = true + if (TRADITIONAL_CJK_CHAR_RE.test(char)) missingTraditionalIdeograph = true + } } else { - const script = fontFallbackScriptForCharacter(char) + const script = fontFallbackScriptForCharacter(char, language) if (script) scripts.add(script) } } diff --git a/packages/core/src/text/editor.ts b/packages/core/src/text/editor.ts index 062c6db66..2973affd5 100644 --- a/packages/core/src/text/editor.ts +++ b/packages/core/src/text/editor.ts @@ -19,6 +19,7 @@ export interface TextEditorState { cursor: number selectionAnchor: number | null paragraph: Paragraph | null + paragraphFontGeneration: number textDirection: 'LTR' | 'RTL' } @@ -26,6 +27,7 @@ export class TextEditor { private ck: CanvasKit private renderer: SkiaRenderer | null = null private _state: TextEditorState | null = null + private paragraphNode: SceneNode | null = null caretVisible = true constructor(ck: CanvasKit) { @@ -66,7 +68,16 @@ export class TextEditor { } get state(): TextEditorState | null { - return this._state + const state = this._state + if ( + state && + this.renderer && + this.paragraphNode && + state.paragraphFontGeneration !== this.renderer.fontGeneration + ) { + this.rebuildParagraph(this.paragraphNode) + } + return state } get isActive(): boolean { @@ -88,6 +99,7 @@ export class TextEditor { cursor: node.text.length, selectionAnchor: null, paragraph: null, + paragraphFontGeneration: -1, textDirection: resolveNodeTextDirection(node) } this.rebuildParagraph(node) @@ -98,6 +110,7 @@ export class TextEditor { const result = { nodeId: this._state.nodeId, text: this._state.text } this._state.paragraph?.delete() this._state = null + this.paragraphNode = null return result } @@ -105,8 +118,10 @@ export class TextEditor { const s = this._state if (!s || !this.renderer) return s.paragraph?.delete() + this.paragraphNode = node s.textDirection = resolveNodeTextDirection(node) - s.paragraph = this.renderer.buildParagraph(node) + s.paragraph = this.renderer.buildParagraph({ ...node, text: s.text }) + s.paragraphFontGeneration = this.renderer.fontGeneration } hasSelection(): boolean { diff --git a/packages/core/src/text/fallbacks.ts b/packages/core/src/text/fallbacks.ts index 376bb6266..9ec36275a 100644 --- a/packages/core/src/text/fallbacks.ts +++ b/packages/core/src/text/fallbacks.ts @@ -7,6 +7,22 @@ import { export type FontFallbackScript = 'cjk' | 'cjk-sc' | 'cjk-tc' | 'cjk-jp' | 'cjk-kr' | 'arabic' +export function cjkFallbackScriptForLanguage( + language: string | null | undefined +): Extract | null { + if (!language) return null + const normalized = language.toLowerCase().replaceAll('_', '-') + const [primary] = normalized.split('-') + if (primary === 'ja') return 'cjk-jp' + if (primary === 'ko') return 'cjk-kr' + if (primary !== 'zh') return null + const subtags = new Set(normalized.split('-').slice(1)) + if (subtags.has('hant') || subtags.has('tw') || subtags.has('hk') || subtags.has('mo')) { + return 'cjk-tc' + } + return 'cjk-sc' +} + export interface FontFallbackManifestEntry { script: FontFallbackScript localFamilies: string[] diff --git a/packages/core/src/text/font-sources.ts b/packages/core/src/text/font-sources.ts new file mode 100644 index 000000000..9c5ae1e5f --- /dev/null +++ b/packages/core/src/text/font-sources.ts @@ -0,0 +1,23 @@ +import type { WebFontProviderId } from '#core/text/web-fonts' + +export interface FontInfo { + family: string + fullName: string + style: string + postscriptName: string +} + +export type LocalFontAccessState = 'unsupported' | 'prompt' | 'granted' | 'denied' +export type FontFamilySource = 'local' | 'bundled' | 'fallback' | WebFontProviderId + +export interface FontFamilyOption { + family: string + source: FontFamilySource +} + +export interface DownloadedFontCache { + read(family: string, style: string, characters?: string): Promise + write(family: string, style: string, data: ArrayBuffer, characters?: string): Promise +} + +export type HostFontLoader = (family: string, style: string) => Promise diff --git a/packages/core/src/text/font-style.ts b/packages/core/src/text/font-style.ts new file mode 100644 index 000000000..fb7ea6af7 --- /dev/null +++ b/packages/core/src/text/font-style.ts @@ -0,0 +1,96 @@ +import { parseFontStyle } from '#core/text/face' + +interface LocalFontMatch { + family: string + style: string +} + +export function chooseLocalFontMatch( + fonts: T[], + family: string, + style?: string +): T | undefined { + const families = [family] + const normalized = normalizeFontFamily(family) + if (normalized !== family) families.push(normalized) + const requested = parseFontStyle(style) + + for (const candidateFamily of families) { + const exact = style + ? fonts.find((font) => font.family === candidateFamily && font.style === style) + : undefined + if (exact) return exact + + const candidates = fonts.filter((font) => font.family === candidateFamily) + const sameStyle = candidates.find((font) => { + const parsed = parseFontStyle(font.style) + return parsed.weight === requested.weight && parsed.italic === requested.italic + }) + if (sameStyle) return sameStyle + if (style) continue + + const sameSlant = candidates.filter( + (font) => parseFontStyle(font.style).italic === requested.italic + ) + if (sameSlant.length > 0) return sameSlant[0] + if (candidates.length > 0) return candidates[0] + } + + return undefined +} + +export const FONT_WEIGHT_NAMES: Record = { + 100: 'Thin', + 200: 'Extra Light', + 300: 'Light', + 400: 'Regular', + 500: 'Medium', + 600: 'Semi Bold', + 700: 'Bold', + 800: 'Extra Bold', + 900: 'Black' +} + +export function normalizeFontFamily(family: string): string { + return family.replace(/\s+(Variable|\d+(?:pt|px|em))$/i, '') +} + +export function styleToVariant(style: string): string { + const weight = styleToWeight(style) + const italic = style.toLowerCase().includes('italic') + if (weight === 400 && !italic) return 'regular' + if (weight === 400 && italic) return 'italic' + return italic ? `${weight}italic` : `${weight}` +} + +export function isVariableFont(data: ArrayBuffer): boolean { + if (data.byteLength < 12) return false + const view = new DataView(data) + const numTables = view.getUint16(4) + for (let i = 0; i < numTables && 12 + i * 16 + 4 <= data.byteLength; i++) { + const tag = String.fromCharCode( + view.getUint8(12 + i * 16), + view.getUint8(12 + i * 16 + 1), + view.getUint8(12 + i * 16 + 2), + view.getUint8(12 + i * 16 + 3) + ) + if (tag === 'fvar') return true + } + return false +} + +export function styleToWeight(style: string): number { + return parseFontStyle(style).weight +} + +export function weightToStyle(weight: number, italic = false): string { + const rounded = Math.round(weight / 100) * 100 + const label = (FONT_WEIGHT_NAMES[rounded] ?? 'Regular').replace(/ /g, '') + return italic ? `${label} Italic` : label +} + +export function weightToFigmaStyle(weight: number, italic = false): string { + const rounded = Math.round(weight / 100) * 100 + const label = FONT_WEIGHT_NAMES[rounded] ?? 'Regular' + return italic ? `${label} Italic` : label +} diff --git a/packages/core/src/text/fonts.ts b/packages/core/src/text/fonts.ts index bff89ef75..6127be85a 100644 --- a/packages/core/src/text/fonts.ts +++ b/packages/core/src/text/fonts.ts @@ -3,70 +3,32 @@ import type { CanvasKit, TypefaceFontProvider } from 'canvaskit-wasm' import type { SceneGraph } from '@open-pencil/scene-graph' import { DEFAULT_FONT_FAMILY, IS_BROWSER } from '#core/constants' -import { fontFaceRenderFamily, parseFontStyle } from '#core/text/face' +import { fontFaceRenderFamily } from '#core/text/face' +import { + chooseLocalFontMatch, + isVariableFont, + normalizeFontFamily, + styleToWeight, + weightToStyle +} from '#core/text/font-style' + +export * from '#core/text/font-sources' +export * from '#core/text/font-style' import { fontFallbackEntry } from '#core/text/fallbacks' import type { FontFallbackScript } from '#core/text/fallbacks' -import { WebFontResolver } from '#core/text/web-fonts' +import type { + DownloadedFontCache, + FontFamilyOption, + FontInfo, + HostFontLoader, + LocalFontAccessState +} from '#core/text/font-sources' +import { collectGraphFontKeys } from '#core/text/requirements' +import { normalizedCoverageText, WebFontResolver } from '#core/text/web-fonts' import type { WebFontFetch, WebFontProviderId } from '#core/text/web-fonts' -export interface FontInfo { - family: string - fullName: string - style: string - postscriptName: string -} - -export type LocalFontAccessState = 'unsupported' | 'prompt' | 'granted' | 'denied' -export type FontFamilySource = 'local' | 'bundled' | 'fallback' | WebFontProviderId - -export interface FontFamilyOption { - family: string - source: FontFamilySource -} - -export interface DownloadedFontCache { - read(family: string, style: string): Promise - write(family: string, style: string, data: ArrayBuffer): Promise -} - -export type HostFontLoader = (family: string, style: string) => Promise - type FindLocalFontOptions = { allowVariable?: boolean } -type LocalFontMatch = Pick - -export function chooseLocalFontMatch( - fonts: T[], - family: string, - style?: string -): T | undefined { - const families = [family] - const normalized = normalizeFontFamily(family) - if (normalized !== family) families.push(normalized) - const requested = parseFontStyle(style) - - for (const f of families) { - const exact = style ? fonts.find((x) => x.family === f && x.style === style) : undefined - if (exact) return exact - - const candidates = fonts.filter((x) => x.family === f) - const sameStyle = candidates.find((x) => { - const parsed = parseFontStyle(x.style) - return parsed.weight === requested.weight && parsed.italic === requested.italic - }) - if (sameStyle) return sameStyle - - if (style) continue - - const sameSlant = candidates.filter((x) => parseFontStyle(x.style).italic === requested.italic) - if (sameSlant.length > 0) return sameSlant[0] - - if (candidates.length > 0) return candidates[0] - } - - return undefined -} - const BUNDLED_FONTS: Record = { 'Inter|Regular': '/Inter-Regular.ttf', 'Inter|Medium': '/Inter-Medium.ttf', @@ -76,65 +38,14 @@ const BUNDLED_FONTS: Record = { 'Noto Naskh Arabic|Regular': '/NotoNaskhArabic-Regular.ttf' } -export const FONT_WEIGHT_NAMES: Record = { - 100: 'Thin', - 200: 'Extra Light', - 300: 'Light', - 400: 'Regular', - 500: 'Medium', - 600: 'Semi Bold', - 700: 'Bold', - 800: 'Extra Bold', - 900: 'Black' -} - -export function normalizeFontFamily(family: string): string { - return family.replace(/\s+(Variable|\d+(?:pt|px|em))$/i, '') -} - -export function styleToVariant(style: string): string { - const weight = styleToWeight(style) - const italic = style.toLowerCase().includes('italic') - if (weight === 400 && !italic) return 'regular' - if (weight === 400 && italic) return 'italic' - return italic ? `${weight}italic` : `${weight}` -} - -export function isVariableFont(data: ArrayBuffer): boolean { - if (data.byteLength < 12) return false - const view = new DataView(data) - const numTables = view.getUint16(4) - for (let i = 0; i < numTables && 12 + i * 16 + 4 <= data.byteLength; i++) { - const tag = String.fromCharCode( - view.getUint8(12 + i * 16), - view.getUint8(12 + i * 16 + 1), - view.getUint8(12 + i * 16 + 2), - view.getUint8(12 + i * 16 + 3) - ) - if (tag === 'fvar') return true - } - return false -} - -export function styleToWeight(style: string): number { - return parseFontStyle(style).weight -} - -export function weightToStyle(weight: number, italic = false): string { - const rounded = Math.round(weight / 100) * 100 - const label = (FONT_WEIGHT_NAMES[rounded] ?? 'Regular').replace(/ /g, '') - return italic ? `${label} Italic` : label -} - -export function weightToFigmaStyle(weight: number, italic = false): string { - const rounded = Math.round(weight / 100) * 100 - const label = FONT_WEIGHT_NAMES[rounded] ?? 'Regular' - return italic ? `${label} Italic` : label -} - export class FontManager { private loadedFamilies = new Map() + private supplementalFamilyData = new Map() + private remoteCoverage = new Map>() + private blockedNodeIds = new Set() private fontProvider: TypefaceFontProvider | null = null + private registrationGeneration = 0 + private providerRegistrations = new Map>() private localFonts: FontInfo[] | null = null private localFontAccessState: LocalFontAccessState = IS_BROWSER ? 'prompt' : 'unsupported' private downloadedFontCache: DownloadedFontCache | null = null @@ -142,6 +53,8 @@ export class FontManager { private hostFontLoader: HostFontLoader | null = null private webFonts = new WebFontResolver() private registeredRenderFamilies = new Set() + private renderFamilyAliases = new Map() + private renderFamilyRevisions = new Map() private cjkFallbackFamilies: string[] = [] private cjkFallbackPromise: Promise | null = null private arabicFallbackFamilies: string[] = [] @@ -149,10 +62,20 @@ export class FontManager { attachProvider(_canvasKit: CanvasKit, provider: TypefaceFontProvider): void { this.fontProvider = provider + this.registrationGeneration++ + this.providerRegistrations.clear() this.registeredRenderFamilies.clear() + this.renderFamilyAliases.clear() + this.renderFamilyRevisions.clear() for (const [cacheKey, data] of this.loadedFamilies) { - const family = cacheKey.slice(0, cacheKey.indexOf('|')) + const separator = cacheKey.indexOf('|') + const family = cacheKey.slice(0, separator) + const style = cacheKey.slice(separator + 1) this.registerFontInCanvasKit(family, data) + for (const supplemental of this.supplementalFamilyData.get(cacheKey) ?? []) { + this.registerFontInCanvasKit(family, supplemental) + } + this.registerInitialRenderFamily(family, style, data) } } @@ -164,6 +87,22 @@ export class FontManager { return this.fontProvider } + generation(): number { + return this.registrationGeneration + } + + blockNodesUntilFontsResolve(nodeIds: readonly string[]): void { + for (const nodeId of nodeIds) this.blockedNodeIds.add(nodeId) + } + + unblockNodes(nodeIds: readonly string[]): void { + for (const nodeId of nodeIds) this.blockedNodeIds.delete(nodeId) + } + + isNodeBlocked(nodeId: string): boolean { + return this.blockedNodeIds.has(nodeId) + } + localAccessState(): LocalFontAccessState { return this.localFontAccessState } @@ -197,8 +136,12 @@ export class FontManager { return this.webFonts.enabledProviders() } - async loadCachedFont(family: string, style = 'Regular'): Promise { - const cached = await this.readDownloadedFont(family, style) + async loadCachedFont( + family: string, + style = 'Regular', + characters = '' + ): Promise { + const cached = await this.readDownloadedFont(family, style, characters) if (!cached) return null return this.registerAndCache(family, style, cached) } @@ -305,32 +248,63 @@ export class FontManager { } } - async loadRemoteFont(family: string, style = 'Regular'): Promise { + async loadRemoteFont( + family: string, + style = 'Regular', + characters = '' + ): Promise { if (typeof fetch === 'undefined') return null + const coverage = this.remoteCoverage.get(`${family}|${style}`) + if ( + characters && + coverage && + Array.from(characters).every((character) => coverage.has(character)) + ) { + return this.loadedData(family, style) + } try { + const requestedCharacters = normalizedCoverageText( + `${coverage ? Array.from(coverage).join('') : ''}${characters}` + ) const normalized = normalizeFontFamily(family) const families = normalized === family ? [family] : [family, normalized] - const buffer = await this.webFonts.fetchFont(families, style) - if (!buffer) return null - await this.writeDownloadedFont(family, style, buffer) - return this.registerAndCache(family, style, buffer) + const buffers = await this.webFonts.fetchFont(families, style, requestedCharacters) + if (buffers.length === 0) return null + const primary = buffers[0] + await this.writeDownloadedFont(family, style, primary, requestedCharacters) + const registered = this.registerAndCache(family, style, primary) + const loadedCoverage = this.remoteCoverage.get(`${family}|${style}`) ?? new Set() + for (const character of requestedCharacters) loadedCoverage.add(character) + this.remoteCoverage.set(`${family}|${style}`, loadedCoverage) + for (const supplemental of buffers.slice(1)) { + this.registerSupplemental(family, style, supplemental) + } + return registered } catch (e) { console.warn(`Web font fetch failed for "${family}" ${style}:`, e) return null } } - async loadFont(family: string, style = 'Regular'): Promise { + async loadFont(family: string, style = 'Regular', characters = ''): Promise { const loaded = this.loadedData(family, style) if (loaded) { this.registerFontInCanvasKit(family, loaded) - return loaded + const remoteCoverage = this.remoteCoverage.get(`${family}|${style}`) + const missingRemoteCoverage = Boolean( + characters && + remoteCoverage && + Array.from(characters).some((character) => !remoteCoverage.has(character)) + ) + return missingRemoteCoverage + ? ((await this.loadRemoteFont(family, style, characters)) ?? loaded) + : loaded } return ( (await this.loadLocalFont(family, style)) ?? - (await this.loadCachedFont(family, style)) ?? - (await this.loadRemoteFont(family, style)) + (await this.loadCachedFont(family, style, characters)) ?? + (await this.loadRemoteFont(family, style, characters)) ) } @@ -339,8 +313,7 @@ export class FontManager { } markLoaded(family: string, style: string, data: ArrayBuffer): void { - this.loadedFamilies.set(`${family}|${style}`, data) - this.registerFontInCanvasKit(family, data) + this.registerAndCache(family, style, data) } isLoaded(family: string): boolean { @@ -351,6 +324,11 @@ export class FontManager { return this.loadedFamilies.has(`${family}|${style}`) } + remoteStyleNeedsCoverage(family: string, style: string, characters: readonly string[]): boolean { + const coverage = this.remoteCoverage.get(`${family}|${style}`) + return !!coverage && characters.some((character) => !coverage.has(character)) + } + loadedData(family: string, style: string): ArrayBuffer | null { return this.loadedFamilies.get(`${family}|${style}`) ?? null } @@ -359,9 +337,12 @@ export class FontManager { const data = this.loadedData(family, style) if (!data) return family - const renderFamily = fontFaceRenderFamily(family, style) + const key = `${family}|${style}` + const renderFamily = this.renderFamilyAliases.get(key) ?? fontFaceRenderFamily(family, style) if (!this.registeredRenderFamilies.has(renderFamily)) { if (this.registerFontInCanvasKit(renderFamily, data)) { + this.renderFamilyAliases.set(key, renderFamily) + this.renderFamilyRevisions.set(key, 1) this.registeredRenderFamilies.add(renderFamily) } else { return family @@ -371,25 +352,7 @@ export class FontManager { } collectFontKeys(graph: SceneGraph, nodeIds: string[]): Array<[string, string]> { - const fontKeys = new Set() - const collect = (id: string) => { - const node = graph.getNode(id) - if (!node) return - if (node.type === 'TEXT') { - const family = node.fontFamily || DEFAULT_FONT_FAMILY - fontKeys.add(`${family}\0${weightToStyle(node.fontWeight || 400, node.italic)}`) - for (const run of node.styleRuns) { - const f = run.style.fontFamily ?? family - const w = run.style.fontWeight ?? node.fontWeight - const i = run.style.italic ?? node.italic - fontKeys.add(`${f}\0${weightToStyle(w, i)}`) - } - } - for (const childId of node.childIds) collect(childId) - } - for (const id of nodeIds) collect(id) - - return [...fontKeys].map((k) => k.split('\0') as [string, string]) + return collectGraphFontKeys(graph, nodeIds) } async ensureCJKFallback(): Promise { @@ -421,14 +384,19 @@ export class FontManager { } async ensureFallbackPack( - scripts: FontFallbackScript[] = ['cjk', 'arabic'] + scripts: FontFallbackScript[] = ['cjk', 'arabic'], + characters = '' ): Promise>> { const result: Partial> = {} await Promise.all( scripts.map(async (script) => { - if (script === 'arabic') result[script] = await this.ensureArabicFallback() - else if (script === 'cjk') result[script] = await this.ensureCJKFallback() - else result[script] = await this.ensureFallbackFamilies(script, this.cjkFallbackFamilies) + if (script === 'arabic' && !characters) result[script] = await this.ensureArabicFallback() + else if (script === 'cjk' && !characters) result[script] = await this.ensureCJKFallback() + else { + const target = + script === 'arabic' ? this.arabicFallbackFamilies : this.cjkFallbackFamilies + result[script] = await this.ensureFallbackFamilies(script, target, {}, characters) + } }) ) return result @@ -447,7 +415,8 @@ export class FontManager { private async ensureFallbackFamilies( script: FontFallbackScript, targetFamilies: string[], - options: { allowVariableLocalFonts?: boolean } = {} + options: { allowVariableLocalFonts?: boolean } = {}, + characters = '' ): Promise { const manifest = fontFallbackEntry(script, this.fallbackUserAgent) @@ -457,20 +426,30 @@ export class FontManager { (await this.findLocalFont(family, undefined, { allowVariable: options.allowVariableLocalFonts })) - if (buffer && this.registerAndCache(family, 'Regular', buffer)) { + if ( + buffer && + this.registerAndCache(family, 'Regular', buffer) && + !targetFamilies.includes(family) + ) { targetFamilies.push(family) } } - if (targetFamilies.length === 0) { + if (targetFamilies.length === 0 || characters) { const results = await Promise.allSettled( manifest.remoteFamilies.map(async (family) => { - const data = await this.loadFont(family, 'Regular') + const data = await this.loadRemoteFont(family, 'Regular', characters) return data ? family : null }) ) for (const result of results) { - if (result.status === 'fulfilled' && result.value) targetFamilies.push(result.value) + if ( + result.status === 'fulfilled' && + result.value && + !targetFamilies.includes(result.value) + ) { + targetFamilies.push(result.value) + } } } @@ -487,10 +466,14 @@ export class FontManager { } } - private async readDownloadedFont(family: string, style: string): Promise { + private async readDownloadedFont( + family: string, + style: string, + characters = '' + ): Promise { if (!this.downloadedFontCache) return null try { - return await this.downloadedFontCache.read(family, style) + return await this.downloadedFontCache.read(family, style, characters) } catch (e) { console.warn(`Downloaded font cache read failed for "${family}" ${style}:`, e) return null @@ -500,11 +483,12 @@ export class FontManager { private async writeDownloadedFont( family: string, style: string, - data: ArrayBuffer + data: ArrayBuffer, + characters = '' ): Promise { if (!this.downloadedFontCache) return try { - await this.downloadedFontCache.write(family, style, data) + await this.downloadedFontCache.write(family, style, data, characters) } catch (e) { console.warn(`Downloaded font cache write failed for "${family}" ${style}:`, e) } @@ -531,17 +515,61 @@ export class FontManager { } } - private registerAndCache(family: string, style: string, buffer: ArrayBuffer): ArrayBuffer | null { - this.loadedFamilies.set(`${family}|${style}`, buffer) + private registerSupplemental(family: string, style: string, buffer: ArrayBuffer): void { + const key = `${family}|${style}` + const supplemental = this.supplementalFamilyData.get(key) ?? [] + if (supplemental.includes(buffer)) return + supplemental.push(buffer) + this.supplementalFamilyData.set(key, supplemental) this.registerFontInCanvasKit(family, buffer) this.registerFontInBrowser(family, style, buffer) + } + + private registerAndCache(family: string, style: string, buffer: ArrayBuffer): ArrayBuffer | null { + const key = `${family}|${style}` + const existing = this.loadedFamilies.get(key) + if (existing === buffer) { + this.registerFontInCanvasKit(family, buffer) + return buffer + } + if (existing) this.registerSupplemental(family, style, existing) + this.loadedFamilies.set(key, buffer) + this.registerFontInCanvasKit(family, buffer) + const currentRenderFamily = this.renderFamilyAliases.get(key) + if (currentRenderFamily) { + const revision = (this.renderFamilyRevisions.get(key) ?? 1) + 1 + const renderFamily = `${fontFaceRenderFamily(family, style)}__${revision}` + if (this.registerFontInCanvasKit(renderFamily, buffer)) { + this.renderFamilyAliases.set(key, renderFamily) + this.renderFamilyRevisions.set(key, revision) + this.registeredRenderFamilies.add(renderFamily) + } + } else { + this.registerInitialRenderFamily(family, style, buffer) + } + this.registerFontInBrowser(family, style, buffer) return buffer } + private registerInitialRenderFamily(family: string, style: string, data: ArrayBuffer): void { + const key = `${family}|${style}` + const renderFamily = fontFaceRenderFamily(family, style) + if (!this.registerFontInCanvasKit(renderFamily, data)) return + this.renderFamilyAliases.set(key, renderFamily) + this.renderFamilyRevisions.set(key, 1) + this.registeredRenderFamilies.add(renderFamily) + } + private registerFontInCanvasKit(family: string, data: ArrayBuffer): boolean { if (!this.fontProvider || data.byteLength < 4) return false + const registeredData = this.providerRegistrations.get(family) + if (registeredData?.has(data)) return true try { this.fontProvider.registerFont(data, family) + const familyRegistrations = registeredData ?? new Set() + familyRegistrations.add(data) + this.providerRegistrations.set(family, familyRegistrations) + this.registrationGeneration++ return true } catch { return false diff --git a/packages/core/src/text/index.ts b/packages/core/src/text/index.ts index ea35a6a0b..6709bc6e1 100644 --- a/packages/core/src/text/index.ts +++ b/packages/core/src/text/index.ts @@ -5,4 +5,6 @@ export * from './coverage' export * from './fonts' export * from './fallbacks' export * from './resolver' +export * from './requirements' +export * from './resolved-requirements' export * from './web-fonts' diff --git a/packages/core/src/text/opentype.ts b/packages/core/src/text/opentype.ts index 4b3255f8c..add0f0748 100644 --- a/packages/core/src/text/opentype.ts +++ b/packages/core/src/text/opentype.ts @@ -67,7 +67,7 @@ function getParsedFont(family: string, style: string): OutlineFont | null { } function glyphsForCodePoints(font: OutlineFont, text: string): OutlineGlyph[] { - return [...text].map((character) => font.charToGlyph(character)) + return Array.from(text, (character) => font.charToGlyph(character)) } function glyphAdvanceWidth(font: OutlineFont, glyph: OutlineGlyph, fontSize: number): number { diff --git a/packages/core/src/text/requirements.ts b/packages/core/src/text/requirements.ts new file mode 100644 index 000000000..f6b746577 --- /dev/null +++ b/packages/core/src/text/requirements.ts @@ -0,0 +1,77 @@ +import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' + +import { DEFAULT_FONT_FAMILY } from '#core/constants' +import { cjkFallbackScriptForLanguage, type FontFallbackScript } from '#core/text/fallbacks' +import { weightToStyle } from '#core/text/font-style' + +export function collectGraphFontKeys( + graph: SceneGraph, + nodeIds: readonly string[] +): Array<[string, string]> { + const fontKeys = new Set() + const collect = (nodeId: string) => { + const node = graph.getNode(nodeId) + if (!node) return + if (node.type === 'TEXT') { + const family = node.fontFamily || DEFAULT_FONT_FAMILY + fontKeys.add(`${family}\0${weightToStyle(node.fontWeight || 400, node.italic)}`) + for (const run of node.styleRuns) { + const runFamily = run.style.fontFamily ?? family + const weight = run.style.fontWeight ?? node.fontWeight + const italic = run.style.italic ?? node.italic + fontKeys.add(`${runFamily}\0${weightToStyle(weight, italic)}`) + } + } + for (const childId of node.childIds) collect(childId) + } + for (const nodeId of nodeIds) collect(nodeId) + return Array.from(fontKeys, (key) => key.split('\0') as [string, string]) +} + +function fallbackScriptForCharacter( + character: string, + language?: string | null +): FontFallbackScript | null { + if (/\p{Script=Arabic}/u.test(character)) return 'arabic' + if (/\p{Script=Hangul}/u.test(character)) return 'cjk-kr' + if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(character)) return 'cjk-jp' + if (/\p{Script=Han}/u.test(character)) return cjkFallbackScriptForLanguage(language) ?? 'cjk-sc' + return null +} + +function textLanguageAt(node: SceneNode, index: number): string | null { + const run = node.styleRuns.find((item) => index >= item.start && index < item.start + item.length) + return run?.style.textLanguage ?? node.textLanguage +} + +export interface GraphFontRequirements { + characters: string + nodes: SceneNode[] + scripts: FontFallbackScript[] +} + +export function collectGraphFontRequirements( + graph: SceneGraph, + nodeIds: readonly string[] +): GraphFontRequirements { + const characters = new Set() + const nodes: SceneNode[] = [] + const scripts = new Set() + const collect = (nodeId: string) => { + const node = graph.getNode(nodeId) + if (!node) return + nodes.push(node) + if (node.type === 'TEXT') { + let index = 0 + for (const character of node.text) { + characters.add(character) + const script = fallbackScriptForCharacter(character, textLanguageAt(node, index)) + if (script) scripts.add(script) + index += character.length + } + } + for (const childId of node.childIds) collect(childId) + } + for (const nodeId of nodeIds) collect(nodeId) + return { characters: Array.from(characters).join(''), nodes, scripts: Array.from(scripts) } +} diff --git a/packages/core/src/text/resolved-requirements.ts b/packages/core/src/text/resolved-requirements.ts new file mode 100644 index 000000000..f6f2db4ff --- /dev/null +++ b/packages/core/src/text/resolved-requirements.ts @@ -0,0 +1,12 @@ +import { textNeededFallbackScripts } from '#core/text/coverage' +import type { FontFallbackScript } from '#core/text/fallbacks' +import type { GraphFontRequirements } from '#core/text/requirements' + +export function missingGraphFontScripts(requirements: GraphFontRequirements): FontFallbackScript[] { + const scripts = new Set() + for (const node of requirements.nodes) { + if (node.type !== 'TEXT') continue + for (const script of textNeededFallbackScripts(node)) scripts.add(script) + } + return Array.from(scripts) +} diff --git a/packages/core/src/text/resolver/index.ts b/packages/core/src/text/resolver/index.ts index a78089cb0..ddd12db91 100644 --- a/packages/core/src/text/resolver/index.ts +++ b/packages/core/src/text/resolver/index.ts @@ -19,9 +19,14 @@ function faceCandidate( return { id: `${source}:${family}:${style}`, family, style, source } } -export function fontFaceDemand(family: string, style: string): FontResolutionDemand { +export function fontFaceDemand( + family: string, + style: string, + characters = '' +): FontResolutionDemand { return { key: `face:${family.trim().toLocaleLowerCase()}:${style.toLocaleLowerCase()}`, + characters, candidates: [ faceCandidate(family, style, 'registered'), faceCandidate(family, style, 'local'), @@ -31,6 +36,19 @@ export function fontFaceDemand(family: string, style: string): FontResolutionDem } } +export function fontRemoteCoverageDemand( + family: string, + style: string, + characters: readonly string[] +): FontResolutionDemand { + const coverageKey = [...new Set(characters)].sort((a, b) => a.localeCompare(b)).join('') + return { + key: `remote-coverage:${family.trim().toLocaleLowerCase()}:${style.toLocaleLowerCase()}:${coverageKey}`, + characters: coverageKey, + candidates: [faceCandidate(family, style, 'remote')] + } +} + export function fontCoverageDemand( script: FontFallbackScript, characters: readonly string[] = [] @@ -42,11 +60,12 @@ export function fontCoverageDemand( const coverageKey = [...new Set(codePoints)].sort((a, b) => a.localeCompare(b)).join(',') return { key: `coverage:${script}:${coverageKey}`, + characters: characters.join(''), candidates: [{ id: `fallback:${script}`, family: script, style: 'Regular', source: 'fallback' }] } } -const productionFontLoader: FontResolutionLoader = async (candidate) => { +const productionFontLoader: FontResolutionLoader = async (candidate, demand) => { switch (candidate.source) { case 'registered': return fontManager.isStyleLoaded(candidate.family, candidate.style) @@ -55,10 +74,13 @@ const productionFontLoader: FontResolutionLoader = async (candidate) => { case 'cache': return (await fontManager.loadCachedFont(candidate.family, candidate.style)) !== null case 'remote': - return (await fontManager.loadRemoteFont(candidate.family, candidate.style)) !== null + return ( + (await fontManager.loadRemoteFont(candidate.family, candidate.style, demand.characters)) !== + null + ) case 'fallback': { const script = candidate.family as FontFallbackScript - const families = await fontManager.ensureFallbackPack([script]) + const families = await fontManager.ensureFallbackPack([script], demand.characters) return (families[script]?.length ?? 0) > 0 } } diff --git a/packages/core/src/text/resolver/resolver.ts b/packages/core/src/text/resolver/resolver.ts index be178906c..f74fd2ccf 100644 --- a/packages/core/src/text/resolver/resolver.ts +++ b/packages/core/src/text/resolver/resolver.ts @@ -9,7 +9,8 @@ interface FontResolutionEntry { demand: FontResolutionDemand snapshot: FontResolutionSnapshot promise: Promise - callbacks: Set + callbacks: Map> + nodeIds: Set } function idleSnapshot(key: string): FontResolutionSnapshot { @@ -26,29 +27,25 @@ export class FontResolver { return this.entries.get(key)?.snapshot ?? idleSnapshot(key) } + pendingNodeIds(demand: FontResolutionDemand | string): string[] { + const key = typeof demand === 'string' ? demand : demand.key + const entry = this.entries.get(key) + return entry?.snapshot.state === 'loading' ? [...entry.nodeIds] : [] + } + demand( demand: FontResolutionDemand, onSettled?: FontResolutionSettled ): Promise { - const current = this.entries.get(demand.key) - if (current) { - if (current.snapshot.state === 'loading' && onSettled) current.callbacks.add(onSettled) - return current.promise - } + return this.request(demand, onSettled) + } - const callbacks = new Set() - if (onSettled) callbacks.add(onSettled) - - const snapshot: FontResolutionSnapshot = { key: demand.key, state: 'loading' } - const entry: FontResolutionEntry = { - demand, - snapshot, - callbacks, - promise: Promise.resolve(snapshot) - } - this.entries.set(demand.key, entry) - entry.promise = this.resolve(entry) - return entry.promise + demandForNode( + demand: FontResolutionDemand, + nodeId: string, + onSettled?: FontResolutionSettled + ): Promise { + return this.request(demand, onSettled, nodeId) } retry( @@ -67,13 +64,16 @@ export class FontResolver { if (current) { current.snapshot = snapshot current.promise = Promise.resolve(snapshot) + current.callbacks.clear() + current.nodeIds.clear() return snapshot } const entry: FontResolutionEntry = { demand, snapshot, promise: Promise.resolve(snapshot), - callbacks: new Set() + callbacks: new Map(), + nodeIds: new Set() } this.entries.set(demand.key, entry) return snapshot @@ -87,6 +87,43 @@ export class FontResolver { this.entries.delete(typeof demand === 'string' ? demand : demand.key) } + private request( + demand: FontResolutionDemand, + onSettled?: FontResolutionSettled, + nodeId?: string + ): Promise { + const current = this.entries.get(demand.key) + if (current) { + if (current.snapshot.state === 'loading') this.addConsumer(current, onSettled, nodeId) + return current.promise + } + + const snapshot: FontResolutionSnapshot = { key: demand.key, state: 'loading' } + const entry: FontResolutionEntry = { + demand, + snapshot, + callbacks: new Map(), + nodeIds: new Set(), + promise: Promise.resolve(snapshot) + } + this.addConsumer(entry, onSettled, nodeId) + this.entries.set(demand.key, entry) + entry.promise = this.resolve(entry) + return entry.promise + } + + private addConsumer( + entry: FontResolutionEntry, + onSettled?: FontResolutionSettled, + nodeId?: string + ): void { + if (nodeId) entry.nodeIds.add(nodeId) + if (!onSettled) return + const callbackNodes = entry.callbacks.get(onSettled) ?? new Set() + if (nodeId) callbackNodes.add(nodeId) + entry.callbacks.set(onSettled, callbackNodes) + } + private async resolve(entry: FontResolutionEntry): Promise { for (const candidate of entry.demand.candidates) { if (this.entries.get(entry.demand.key) !== entry) return idleSnapshot(entry.demand.key) @@ -120,14 +157,15 @@ export class FontResolver { ): FontResolutionSnapshot { if (this.entries.get(entry.demand.key) !== entry) return idleSnapshot(entry.demand.key) entry.snapshot = snapshot - for (const callback of entry.callbacks) { + for (const [callback, nodeIds] of entry.callbacks) { try { - callback(snapshot) + callback(snapshot, [...nodeIds]) } catch (error) { console.error('Font resolution callback failed:', error) } } entry.callbacks.clear() + entry.nodeIds.clear() return snapshot } } diff --git a/packages/core/src/text/resolver/types.ts b/packages/core/src/text/resolver/types.ts index a077013e2..7db854b03 100644 --- a/packages/core/src/text/resolver/types.ts +++ b/packages/core/src/text/resolver/types.ts @@ -12,6 +12,7 @@ export interface FontResolutionCandidate { export interface FontResolutionDemand { key: string candidates: readonly FontResolutionCandidate[] + characters?: string } export interface FontResolutionSnapshot { @@ -27,4 +28,7 @@ export type FontResolutionLoader = ( demand: FontResolutionDemand ) => Promise -export type FontResolutionSettled = (snapshot: FontResolutionSnapshot) => void +export type FontResolutionSettled = ( + snapshot: FontResolutionSnapshot, + nodeIds: readonly string[] +) => void diff --git a/packages/core/src/text/web-font/providers.ts b/packages/core/src/text/web-font/providers.ts index b0e2429a9..bcef15c56 100644 --- a/packages/core/src/text/web-font/providers.ts +++ b/packages/core/src/text/web-font/providers.ts @@ -1,6 +1,7 @@ import { createUnifont, providers, + type GoogleFamilyOptions, type RemoteFontSource, type ResolveFontOptions, type Unifont @@ -15,8 +16,8 @@ export type WebFontProvider = | ReturnType export type WebUnifont = Unifont<[WebFontProvider]> export type WebFontResolveOptions = Pick< - ResolveFontOptions, - 'weights' | 'styles' | 'formats' | 'subsets' + ResolveFontOptions<{ google?: GoogleFamilyOptions }>, + 'weights' | 'styles' | 'formats' | 'subsets' | 'options' > export const providerFactories = { diff --git a/packages/core/src/text/web-fonts.ts b/packages/core/src/text/web-fonts.ts index 60448cf11..4e67d6eb9 100644 --- a/packages/core/src/text/web-fonts.ts +++ b/packages/core/src/text/web-fonts.ts @@ -1,4 +1,4 @@ -import type { ResolveFontResult } from 'unifont' +import type { FontFaceData, RemoteFontSource, ResolveFontResult } from 'unifont' import { IS_BROWSER } from '#core/constants' import { parseFontStyle } from '#core/text/face' @@ -28,6 +28,67 @@ export const DEFAULT_WEB_FONT_PROVIDER_SETTINGS: Record Promise +const DEFAULT_WEB_FONT_SUBSETS = [ + 'latin', + 'latin-ext', + 'vietnamese', + 'cyrillic', + 'cyrillic-ext', + 'greek', + 'greek-ext' +] + +export function normalizedCoverageText(text: string): string { + return Array.from(new Set(text)).sort().join('') +} + +export function webFontSubsetsForText(text: string): string[] { + const subsets = new Set(DEFAULT_WEB_FONT_SUBSETS) + if (/\p{Script=Arabic}/u.test(text)) subsets.add('arabic') + if (/\p{Script=Hangul}/u.test(text)) subsets.add('korean') + if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(text)) subsets.add('japanese') + if (/\p{Script=Han}/u.test(text)) { + subsets.add('chinese-simplified') + subsets.add('chinese-traditional') + subsets.add('japanese') + } + return [...subsets] +} + +function preferredRemoteSource(face: FontFaceData): RemoteFontSource | undefined { + const sources = face.src.filter(isRemoteFontSource) + return ( + sources.find((source) => source.format === 'truetype' || source.format === 'ttf') ?? + sources.find((source) => source.format === 'opentype' || source.format === 'otf') ?? + sources.find((source) => source.format === 'woff2') ?? + sources.find((source) => source.format === 'woff') ?? + sources[0] + ) +} + +function resolvedRemoteFaces(result: ResolveFontResult): Array<{ + source: RemoteFontSource + init?: RequestInit +}> { + const candidates = result.fonts.flatMap((face) => { + const source = preferredRemoteSource(face) + return source ? [{ source, init: face.meta?.init, priority: face.meta?.priority ?? 0 }] : [] + }) + const preferredPriority = Math.min(...candidates.map((candidate) => candidate.priority)) + const seen = new Set() + const faces: Array<{ source: RemoteFontSource; init?: RequestInit }> = [] + for (const candidate of candidates) { + if (candidate.priority !== preferredPriority || seen.has(candidate.source.url)) continue + seen.add(candidate.source.url) + faces.push({ source: candidate.source, init: candidate.init }) + } + return faces +} + +function isArrayBuffer(value: ArrayBuffer | null): value is ArrayBuffer { + return value !== null +} + export class WebFontResolver { private enabled = new Set( WEB_FONT_PROVIDER_IDS.filter((provider) => DEFAULT_WEB_FONT_PROVIDER_SETTINGS[provider]) @@ -36,11 +97,13 @@ export class WebFontResolver { private familiesCache = new Map() private familiesPromises = new Map>() private failedFonts = new Set() - private fontPromises = new Map>() + private fontPromises = new Map>() private remoteFetch: WebFontFetch | null = null + private fetchProxyQueue: Promise = Promise.resolve() setEnabled(settings: Partial>): void { this.enabled = new Set(WEB_FONT_PROVIDER_IDS.filter((provider) => settings[provider] === true)) + this.failedFonts.clear() } setRemoteFetch(fetcher: WebFontFetch | null): void { @@ -48,6 +111,7 @@ export class WebFontResolver { this.unifontPromises.clear() this.familiesPromises.clear() this.familiesCache.clear() + this.failedFonts.clear() } enabledProviders(): WebFontProviderId[] { @@ -71,23 +135,30 @@ export class WebFontResolver { return promise } - async fetchFont(families: string[], style: string): Promise { + async fetchFont(families: string[], style: string, characters = ''): Promise { const providers = this.enabledProviders() - if (providers.length === 0 || (IS_BROWSER && !this.remoteFetch)) return null + if (providers.length === 0 || (IS_BROWSER && !this.remoteFetch)) return [] for (const family of families) { for (const provider of providers) { - const buffer = await this.fetchFromProvider(family, style, provider) - if (buffer) return buffer + const buffers = await this.fetchFromProvider(family, style, provider, characters) + if (buffers.length > 0) return buffers } } - return null + return [] } private async withFetchProxy(operation: () => Promise): Promise { if (!this.remoteFetch) return operation() + const previous = this.fetchProxyQueue + let release: (() => void) | undefined + this.fetchProxyQueue = new Promise((resolve) => { + release = () => resolve() + }) + await previous + const originalFetch = globalThis.fetch globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === 'string' || input instanceof URL ? input.toString() : input.url @@ -103,6 +174,7 @@ export class WebFontResolver { return await operation() } finally { globalThis.fetch = originalFetch + release?.() } } @@ -126,7 +198,9 @@ export class WebFontResolver { try { const unifont = await this.unifont(provider) const listedFamilies = await this.withFetchProxy(() => unifont.listFonts()) - const families = listedFamilies ? [...new Set(listedFamilies)].sort() : [] + const families = listedFamilies + ? [...new Set(listedFamilies)].sort((a, b) => a.localeCompare(b)) + : [] this.familiesCache.set(provider, families) return families } catch { @@ -138,53 +212,56 @@ export class WebFontResolver { private async fetchFromProvider( family: string, style: string, - provider: WebFontProviderId - ): Promise { - const key = `${provider}|${family}|${style}` - if (this.failedFonts.has(key)) return null + provider: WebFontProviderId, + characters: string + ): Promise { + const coverage = normalizedCoverageText(characters) + const key = `${provider}|${family}|${style}|${coverage}` + if (this.failedFonts.has(key)) return [] let promise = this.fontPromises.get(key) if (!promise) { - promise = this.loadFromProvider(family, style, provider) + promise = this.loadFromProvider(family, style, provider, coverage) this.fontPromises.set(key, promise) } const result = await promise this.fontPromises.delete(key) - if (!result) this.failedFonts.add(key) + if (result.length === 0) this.failedFonts.add(key) return result } private async loadFromProvider( family: string, style: string, - provider: WebFontProviderId - ): Promise { + provider: WebFontProviderId, + characters: string + ): Promise { try { const parsed = parseFontStyle(style) const unifont = await this.unifont(provider) const options = { weights: [String(parsed.weight)], styles: [parsed.italic ? 'italic' : 'normal'], - formats: ['ttf'], - subsets: ['latin'] + formats: ['ttf', 'otf', 'woff2', 'woff'], + subsets: webFontSubsetsForText(characters), + ...(provider === 'google' && characters + ? { options: { google: { experimental: { glyphs: [characters] } } } } + : {}) } satisfies WebFontResolveOptions const result = await this.withFetchProxy(() => unifont.resolveFont(family, options) ) - - const sources = result.fonts - .toSorted((a, b) => (a.meta?.priority ?? 0) - (b.meta?.priority ?? 0)) - .flatMap((font) => font.src.filter(isRemoteFontSource)) - if (sources.length === 0) return null - const source = - sources.find((item) => item.format === 'truetype' || item.format === 'ttf') ?? sources[0] - - const response = await this.fetchRemote(source.url) - if (!response.ok) return null - return await response.arrayBuffer() + const faces = resolvedRemoteFaces(result) + const buffers = await Promise.all( + faces.map(async ({ source, init }) => { + const response = await this.fetchRemote(source.url, init) + return response.ok ? response.arrayBuffer() : null + }) + ) + return buffers.filter(isArrayBuffer) } catch { - return null + return [] } } } diff --git a/packages/scene-graph/src/node-defaults.ts b/packages/scene-graph/src/node-defaults.ts index e34fea4e1..aa79033ae 100644 --- a/packages/scene-graph/src/node-defaults.ts +++ b/packages/scene-graph/src/node-defaults.ts @@ -56,6 +56,7 @@ export function createDefaultNode( italic: false, textAlignHorizontal: 'LEFT', textDirection: 'AUTO', + textLanguage: null, leadingTrim: 'NONE', lineHeight: null, letterSpacing: 0, diff --git a/packages/scene-graph/src/types.ts b/packages/scene-graph/src/types.ts index 882898094..975d7acda 100644 --- a/packages/scene-graph/src/types.ts +++ b/packages/scene-graph/src/types.ts @@ -221,6 +221,7 @@ export interface CharacterStyleOverride { fills?: Fill[] fontVariations?: FontVariation[] fontFeatures?: FontFeature[] + textLanguage?: string | null } export interface StyleRun { @@ -363,6 +364,7 @@ export interface SceneNode { italic: boolean textAlignHorizontal: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED' textDirection: TextDirection + textLanguage: string | null textAlignVertical: TextAlignVertical textAutoResize: TextAutoResize textCase: TextCase diff --git a/src/app/ai/tools/index.ts b/src/app/ai/tools/index.ts index 2436b37d9..6cbdfd1f4 100644 --- a/src/app/ai/tools/index.ts +++ b/src/app/ai/tools/index.ts @@ -99,7 +99,7 @@ export function createAITools(store: EditorStore) { if (def.mutates) { const pageId = store.state.currentPageId const pageNode = store.graph.getNode(pageId) - if (pageNode) await ensureGraphFonts(store.graph, pageNode.childIds) + if (pageNode) await ensureGraphFonts(store.graph, pageNode.childIds, store.renderer) computeAllLayouts(store.graph, pageId) store.requestRender() if (beforeSnapshot) { diff --git a/src/app/automation/bridge/tool-handlers.ts b/src/app/automation/bridge/tool-handlers.ts index b202fc621..735233eb3 100644 --- a/src/app/automation/bridge/tool-handlers.ts +++ b/src/app/automation/bridge/tool-handlers.ts @@ -21,7 +21,7 @@ export function createAutomationToolHandler(makeFigma: FigmaFactory) { x: toolArgs.x as number | undefined, y: toolArgs.y as number | undefined }) - await ensureGraphFonts(store.graph, [result.id]) + await ensureGraphFonts(store.graph, [result.id], store.renderer) computeAllLayouts(store.graph, target.pageId) store.requestRender() store.flashNodes([result.id]) @@ -48,7 +48,7 @@ export function createAutomationToolHandler(makeFigma: FigmaFactory) { if (def.mutates) { const pageNode = store.graph.getNode(figma.currentPageId) - if (pageNode) await ensureGraphFonts(store.graph, pageNode.childIds) + if (pageNode) await ensureGraphFonts(store.graph, pageNode.childIds, store.renderer) computeAllLayouts(store.graph, figma.currentPageId) store.requestRender() store.flashNodes(extractNodeIds(result)) diff --git a/src/app/editor/fonts/cache.ts b/src/app/editor/fonts/cache.ts index 2fc122915..e12770778 100644 --- a/src/app/editor/fonts/cache.ts +++ b/src/app/editor/fonts/cache.ts @@ -34,8 +34,8 @@ const FILE_DIR = `${CACHE_DIR}/files` const EMPTY_MANIFEST: FontCacheManifest = { version: 1, entries: {} } const textEncoder = new TextEncoder() -async function cacheKey(family: string, style: string) { - return hashText(`${family}\0${style}`) +async function cacheKey(family: string, style: string, characters = '') { + return hashText(`${family}\0${style}\0${Array.from(new Set(characters)).sort().join('')}`) } async function hashText(value: string) { @@ -80,9 +80,9 @@ export async function clearDownloadedFontCache(): Promise { export function createTauriDownloadedFontCache(): DownloadedFontCache { return { - async read(family, style) { + async read(family, style, characters) { const manifest = await readManifest() - const entry = manifest.entries[await cacheKey(family, style)] + const entry = manifest.entries[await cacheKey(family, style, characters)] if (!entry) return null const buffer = await readCacheBytes(`${FILE_DIR}/${entry.file}`) @@ -92,8 +92,8 @@ export function createTauriDownloadedFontCache(): DownloadedFontCache { return buffer }, - async write(family, style, data) { - const key = await cacheKey(family, style) + async write(family, style, data, characters) { + const key = await cacheKey(family, style, characters) const sha256 = await hashBytes(data) const file = `${key}.ttf` await writeCacheBytes(`${FILE_DIR}/${file}`, data) diff --git a/src/app/editor/fonts/index.ts b/src/app/editor/fonts/index.ts index c5eca3523..2f842a98a 100644 --- a/src/app/editor/fonts/index.ts +++ b/src/app/editor/fonts/index.ts @@ -4,8 +4,9 @@ import { watch } from 'vue' import { DEFAULT_WEB_FONT_PROVIDER_SETTINGS, WEB_FONT_PROVIDER_IDS, + collectGraphFontRequirements, fontManager, - textNeededFallbackScripts, + missingGraphFontScripts, type FontFamilyOption, type LocalFontAccessState, type WebFontProviderId @@ -160,39 +161,46 @@ export async function listFonts(): Promise { return [] } -export async function ensureGraphFonts(graph: SceneGraph, nodeIds: string[]): Promise { - const fontKeys = fontManager.collectFontKeys(graph, nodeIds) - const missing = fontKeys.filter(([family, style]) => !fontManager.isStyleLoaded(family, style)) - const results = await Promise.all(missing.map(([family, style]) => loadFont(family, style))) - const loaded = results.some((result) => result !== null) - const fallbackScripts = neededFallbackScriptsForNodes(graph, nodeIds) - if (fallbackScripts.length > 0) { - const fallbacks = await fontManager.ensureFallbackPack(fallbackScripts) - if (Object.values(fallbacks).some((families) => families.length > 0)) clearTextPictures(graph) - } else if (loaded) { - clearTextPictures(graph) - } - return loaded || fallbackScripts.length > 0 +interface FontRenderInvalidator { + invalidateAllPictures(): void } -function neededFallbackScriptsForNodes(graph: SceneGraph, nodeIds: string[]) { - const scripts = new Set[number]>() - const collect = (id: string) => { +export async function ensureGraphFonts( + graph: SceneGraph, + nodeIds: string[], + renderer?: FontRenderInvalidator | null +): Promise { + fontManager.blockNodesUntilFontsResolve(nodeIds) + try { + const generationBefore = fontManager.generation() + const fontKeys = fontManager.collectFontKeys(graph, nodeIds) + const requirements = collectGraphFontRequirements(graph, nodeIds) + const { characters } = requirements + await Promise.all(fontKeys.map(([family, style]) => loadFont(family, style, characters))) + const fallbackScripts = missingGraphFontScripts(requirements) + if (fallbackScripts.length > 0) { + const fallbacks = await fontManager.ensureFallbackPack(fallbackScripts, characters) + if (Object.values(fallbacks).some((families) => families.length > 0)) { + clearTextPictures(graph, nodeIds) + } + } else if (fontManager.generation() !== generationBefore) { + clearTextPictures(graph, nodeIds) + } + return fontManager.generation() !== generationBefore || fallbackScripts.length > 0 + } finally { + fontManager.unblockNodes(nodeIds) + renderer?.invalidateAllPictures() + } +} + +function clearTextPictures(graph: SceneGraph, nodeIds: string[]): void { + const clear = (id: string) => { const node = graph.getNode(id) if (!node) return - if (node.type === 'TEXT') { - for (const script of textNeededFallbackScripts(node)) scripts.add(script) - } - for (const childId of node.childIds) collect(childId) - } - for (const id of nodeIds) collect(id) - return [...scripts] -} - -function clearTextPictures(graph: SceneGraph): void { - for (const [, node] of graph.nodes) { if (node.type === 'TEXT') node.textPicture = null + for (const childId of node.childIds) clear(childId) } + for (const id of nodeIds) clear(id) } async function loadSystemFont(family: string, style = 'Regular'): Promise { @@ -206,9 +214,13 @@ async function loadSystemFont(family: string, style = 'Regular'): Promise { +export async function loadFont( + family: string, + style = 'Regular', + characters = '' +): Promise { configureTauriFontCache() - const loaded = await fontManager.loadFont(family, style) + const loaded = await fontManager.loadFont(family, style, characters) if (!loaded) showWebFontUnavailableToast() return loaded } diff --git a/tests/e2e/fonts/cjk-fallback.spec.ts b/tests/e2e/fonts/cjk-fallback.spec.ts index 9fb701807..c2f64c630 100644 --- a/tests/e2e/fonts/cjk-fallback.spec.ts +++ b/tests/e2e/fonts/cjk-fallback.spec.ts @@ -64,44 +64,30 @@ test('CJK text waits for fallback fonts and repaints after they load', async ({ const result = await page.evaluate(async () => { const store = window.openPencil?.getStore?.() - if (!store) throw new Error('OpenPencil store not initialized') + if (!store?.renderer) throw new Error('OpenPencil renderer not initialized') const renderer = store.renderer - if (!renderer) throw new Error('OpenPencil renderer not initialized') - + const response = await fetch('/tests/fixtures/fonts/NotoSansSC-Regular.ttf') + const fallbackData = await response.arrayBuffer() const { fontManager } = await import('/packages/core/src/text/fonts.ts') - const manager = fontManager as typeof fontManager & { - cjkFallbackFamilies: string[] - cjkFallbackPromise: Promise | null - arabicFallbackFamilies: string[] - arabicFallbackPromise: Promise | null - } - const originalCJKFamilies = [...manager.cjkFallbackFamilies] - const originalCJKPromise = manager.cjkFallbackPromise - const originalArabicFamilies = [...manager.arabicFallbackFamilies] - const originalArabicPromise = manager.arabicFallbackPromise - const originalEnsureCJKFallback = fontManager.ensureCJKFallback.bind(fontManager) - const originalEnsureArabicFallback = fontManager.ensureArabicFallback.bind(fontManager) + const manager = fontManager as typeof fontManager & { cjkFallbackFamilies: string[] } + const originalFamilies = [...manager.cjkFallbackFamilies] + const originalEnsureFallbackPack = fontManager.ensureFallbackPack.bind(fontManager) - let releaseCJKFallback: (() => void) | null = null + let releaseFallback = () => undefined const fallbackGate = new Promise((resolve) => { - releaseCJKFallback = resolve + releaseFallback = resolve }) - manager.cjkFallbackFamilies = [] - manager.cjkFallbackPromise = null - manager.arabicFallbackFamilies = [] - manager.arabicFallbackPromise = null let fallbackRenderCount = 0 let renderCount = 0 const originalRender = renderer.renderFromEditorState.bind(renderer) - - fontManager.ensureCJKFallback = async () => { + fontManager.ensureFallbackPack = async (scripts = ['cjk', 'arabic']) => { await fallbackGate + fontManager.markLoaded('Regression CJK Fallback', 'Regular', fallbackData) fontManager.setCJKFallbackFamily('Regression CJK Fallback') - return ['Regression CJK Fallback'] + return Object.fromEntries(scripts.map((script) => [script, ['Regression CJK Fallback']])) } - fontManager.ensureArabicFallback = async () => [] renderer.renderFromEditorState = ( ...args: Parameters ) => { @@ -109,15 +95,14 @@ test('CJK text waits for fallback fonts and repaints after they load', async ({ return originalRender(...args) } - const pageNode = store.graph.getNode(store.state.currentPageId) - if (!pageNode) throw new Error(`Page ${store.state.currentPageId} not found`) - const text = store.graph.createNode('TEXT', pageNode.id, { + const text = store.graph.createNode('TEXT', store.state.currentPageId, { name: 'CJK Regression', x: 80, y: 80, width: 300, height: 60, text: '上班打卡App', + textLanguage: 'zh-Hans', fontSize: 32, fontFamily: 'Inter', fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }] @@ -136,37 +121,28 @@ test('CJK text waits for fallback fonts and repaints after they load', async ({ 'full' ) }) - const loadedBeforeFallback = renderer.isNodeFontLoaded(text) - const beforeFallbackRenderCount = fallbackRenderCount - - releaseCJKFallback?.() - await new Promise((resolve) => { + releaseFallback() + await fallbackGate + await new Promise((resolve) => { setTimeout(resolve, 0) }) await new Promise(requestAnimationFrame) - return { loadedBeforeFallback, loadedAfterFallback: renderer.isNodeFontLoaded(text), - beforeFallbackRenderCount, fallbackRenderCount, renderCount } } finally { - manager.cjkFallbackFamilies = originalCJKFamilies - manager.cjkFallbackPromise = originalCJKPromise - manager.arabicFallbackFamilies = originalArabicFamilies - manager.arabicFallbackPromise = originalArabicPromise - fontManager.ensureCJKFallback = originalEnsureCJKFallback - fontManager.ensureArabicFallback = originalEnsureArabicFallback + manager.cjkFallbackFamilies = originalFamilies + fontManager.ensureFallbackPack = originalEnsureFallbackPack renderer.renderFromEditorState = originalRender } }) expect(result.loadedBeforeFallback).toBe(false) expect(result.loadedAfterFallback).toBe(true) - expect(result.beforeFallbackRenderCount).toBe(0) expect(result.fallbackRenderCount).toBe(1) expect(result.renderCount).toBeGreaterThan(0) canvas.assertNoErrors() diff --git a/tests/engine/io/fig/export/text.test.ts b/tests/engine/io/fig/export/text.test.ts index b22a2d71a..cd2b18683 100644 --- a/tests/engine/io/fig/export/text.test.ts +++ b/tests/engine/io/fig/export/text.test.ts @@ -42,6 +42,25 @@ describe('text node export', () => { expect(textNode.fontSize).toBe(16) }) + test('keeps OpenPencil text language hints out of the .fig schema', async () => { + await initCodec() + + const graph = new SceneGraph() + const page = graph.getPages()[0] + graph.createNode('TEXT', page.id, { + name: 'Localized Han', + text: '骨', + textLanguage: 'ja-JP', + fontFamily: 'Inter' + }) + + const reimported = await parseFigFile((await exportFigFile(graph)).buffer as ArrayBuffer) + const textNode = reimported.getAllNodes().find((node) => node.name === 'Localized Han') + + expect(expectDefined(textNode, 'textNode').text).toBe('骨') + expect(textNode?.textLanguage).toBeNull() + }) + test('text node has lines in textData', async () => { await initCodec() diff --git a/tests/engine/render/canvas/derived-text.test.ts b/tests/engine/render/canvas/derived-text.test.ts index 477156a74..f5d8d63c2 100644 --- a/tests/engine/render/canvas/derived-text.test.ts +++ b/tests/engine/render/canvas/derived-text.test.ts @@ -7,7 +7,7 @@ import { derivedUnderlineRect, shouldUseHardFigmaDerivedGlyphCoverage, snapFigmaDerivedGlyphBaseline -} from '#core/canvas/text-derived' +} from '#core/canvas/text/derived' import { expectDefined } from '#tests/helpers/assert' diff --git a/tests/engine/render/canvas/text.test.ts b/tests/engine/render/canvas/text.test.ts index e28f4f9a8..27b4aa487 100644 --- a/tests/engine/render/canvas/text.test.ts +++ b/tests/engine/render/canvas/text.test.ts @@ -69,6 +69,8 @@ function createMockRenderer(overrides: Partial> = {}) { }, DEFAULT_FONT_SIZE: 14, isNodeFontLoaded: mock(() => true), + nodeFontReadiness: mock(() => 'ready'), + isTextPictureCurrent: mock(() => true), buildParagraph: mock(() => paragraph), _paragraph: paragraph, ...overrides @@ -116,7 +118,7 @@ describe('renderText', () => { }) test('skips text while the node font is not available', () => { - const r = createMockRenderer({ isNodeFontLoaded: mock(() => false) }) + const r = createMockRenderer({ nodeFontReadiness: mock(() => 'pending') }) const canvas = createMockCanvas() renderText(r, canvas as never, textNode()) @@ -170,13 +172,24 @@ describe('renderText', () => { expect(canvas.saveLayer).not.toHaveBeenCalled() }) - test('prefers textPicture over paragraph', () => { + test('prefers resolved fonts over baked text pictures', () => { const r = createMockRenderer() const canvas = createMockCanvas() const node = textNode({ textPicture: new Uint8Array([1, 2, 3]) }) renderText(r, canvas as never, node) + expect(canvas.drawPicture).not.toHaveBeenCalled() + expect(r.buildParagraph).toHaveBeenCalledTimes(1) + }) + + test('uses baked text pictures after font resolution is exhausted', () => { + const r = createMockRenderer({ nodeFontReadiness: mock(() => 'exhausted') }) + const canvas = createMockCanvas() + const node = textNode({ textPicture: new Uint8Array([1, 2, 3]) }) + + renderText(r, canvas as never, node) + expect(canvas.drawPicture).toHaveBeenCalledTimes(1) expect(r.buildParagraph).not.toHaveBeenCalled() }) @@ -295,13 +308,15 @@ describe('renderText headless visual', () => { fontManager.attachProvider(ck, fontProvider) const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer() - fontProvider.registerFont(interData, 'Inter') fontManager.markLoaded('Inter', 'Regular', interData) const notoPath = repoPath('tests/fixtures/fonts/NotoSansSC-Regular.ttf') const notoData = await Bun.file(notoPath).arrayBuffer() - fontProvider.registerFont(notoData, 'Noto Sans SC') + fontManager.markLoaded('Noto Sans SC', 'Regular', notoData) fontManager.setCJKFallbackFamily('Noto Sans SC') + for (let attempt = 0; attempt < 5; attempt++) { + fontManager.markLoaded('Noto Sans SC', 'Regular', notoData) + } const graph = new SceneGraph() const page = graph.getPages()[0] diff --git a/tests/engine/text/coverage.test.ts b/tests/engine/text/coverage.test.ts index 18318b681..137752cd3 100644 --- a/tests/engine/text/coverage.test.ts +++ b/tests/engine/text/coverage.test.ts @@ -22,6 +22,28 @@ describe('font fallback coverage indexing', () => { expect(textNeededFallbackScripts(node)).toContain('cjk-sc') }) + test('uses BCP-47 language hints for Han fallback selection', async () => { + const family = `LanguageHint_${Date.now()}` + const data = await Bun.file('public/Inter-Regular.ttf').arrayBuffer() + fontManager.markLoaded(family, 'Regular', data) + const graph = new SceneGraph() + const node = graph.createNode('TEXT', pageId(graph), { + text: '骨骨', + textLanguage: 'ja-JP', + fontFamily: family, + fontWeight: 400, + styleRuns: [ + { + start: 1, + length: 1, + style: { textLanguage: 'zh-Hant-TW' } + } + ] + }) + + expect(textNeededFallbackScripts(node)).toEqual(['cjk-jp', 'cjk-tc']) + }) + test('uses UTF-16 style-run indices after a surrogate pair', async () => { const cjkData = await Bun.file('tests/fixtures/fonts/NotoSansSC-Regular.ttf').arrayBuffer() const latinData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer() diff --git a/tests/engine/text/fonts/lifecycle.test.ts b/tests/engine/text/fonts/lifecycle.test.ts new file mode 100644 index 000000000..0f53816d8 --- /dev/null +++ b/tests/engine/text/fonts/lifecycle.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'bun:test' + +import type { CanvasKit, TypefaceFontProvider } from 'canvaskit-wasm' + +import { FontManager } from '@open-pencil/core/text' + +describe('font lifecycle', () => { + test('advances generation only for provider epochs and unique registrations', () => { + const manager = new FontManager() + const registrations: string[] = [] + const provider = { + registerFont(_data: ArrayBuffer, family: string) { + registrations.push(family) + } + } as TypefaceFontProvider + const data = new ArrayBuffer(12) + + expect(manager.generation()).toBe(0) + manager.attachProvider({} as CanvasKit, provider) + const providerGeneration = manager.generation() + manager.markLoaded('Generation Test', 'Regular', data) + const registrationGeneration = manager.generation() + manager.markLoaded('Generation Test', 'Regular', data) + + expect(providerGeneration).toBeGreaterThan(0) + expect(registrationGeneration).toBeGreaterThan(providerGeneration) + expect(manager.generation()).toBe(registrationGeneration) + expect(registrations).toEqual(['Generation Test', '__op_font__Generation_Test__Regular']) + }) + + test('moves replaced subset fonts to a fresh render family', () => { + const manager = new FontManager() + const registrations: string[] = [] + const provider = { + registerFont(_data: ArrayBuffer, family: string) { + registrations.push(family) + } + } as TypefaceFontProvider + + manager.attachProvider({} as CanvasKit, provider) + manager.markLoaded('Subset Font', 'Regular', new ArrayBuffer(8)) + const firstRenderFamily = manager.renderFamily('Subset Font', 'Regular') + manager.markLoaded('Subset Font', 'Regular', new ArrayBuffer(12)) + const secondRenderFamily = manager.renderFamily('Subset Font', 'Regular') + + expect(firstRenderFamily).toBe('__op_font__Subset_Font__Regular') + expect(secondRenderFamily).toBe('__op_font__Subset_Font__Regular__2') + expect(registrations).toContain(secondRenderFamily) + }) + + test('tracks nodes gated by pre-render font resolution', () => { + const manager = new FontManager() + manager.blockNodesUntilFontsResolve(['first', 'second']) + expect(manager.isNodeBlocked('first')).toBe(true) + expect(manager.isNodeBlocked('second')).toBe(true) + manager.unblockNodes(['first']) + expect(manager.isNodeBlocked('first')).toBe(false) + expect(manager.isNodeBlocked('second')).toBe(true) + }) +}) diff --git a/tests/engine/text/fonts/loading.test.ts b/tests/engine/text/fonts/loading.test.ts index 1b53a2afb..fb7b28d0a 100644 --- a/tests/engine/text/fonts/loading.test.ts +++ b/tests/engine/text/fonts/loading.test.ts @@ -132,10 +132,16 @@ describe('FontManager loaded font cache', () => { manager.attachProvider(canvasKit, first.provider) manager.markLoaded('ProviderLifecycle', 'Regular', new ArrayBuffer(12)) - expect(first.registrations).toEqual([{ family: 'ProviderLifecycle', byteLength: 12 }]) + expect(first.registrations).toEqual([ + { family: 'ProviderLifecycle', byteLength: 12 }, + { family: '__op_font__ProviderLifecycle__Regular', byteLength: 12 } + ]) manager.attachProvider(canvasKit, second.provider) - expect(second.registrations).toEqual([{ family: 'ProviderLifecycle', byteLength: 12 }]) + expect(second.registrations).toEqual([ + { family: 'ProviderLifecycle', byteLength: 12 }, + { family: '__op_font__ProviderLifecycle__Regular', byteLength: 12 } + ]) manager.detachProvider(first.provider) expect(manager.provider()).toBe(second.provider) @@ -200,7 +206,10 @@ describe('FontManager loaded font cache', () => { }) await expect(manager.loadFont('DownloadedCache', 'Regular')).resolves.toBe(data) - expect(recording.registrations).toEqual([{ family: 'DownloadedCache', byteLength: 16 }]) + expect(recording.registrations).toEqual([ + { family: 'DownloadedCache', byteLength: 16 }, + { family: '__op_font__DownloadedCache__Regular', byteLength: 16 } + ]) expect(writes).toBe(0) }) @@ -217,7 +226,10 @@ describe('FontManager loaded font cache', () => { const data = await manager.loadFont('Inter', 'ExtraBold') expect(data?.byteLength).toBeGreaterThan(0) - expect(recording.registrations).toEqual([{ family: 'Inter', byteLength: data?.byteLength }]) + expect(recording.registrations).toEqual([ + { family: 'Inter', byteLength: data?.byteLength }, + { family: '__op_font__Inter__ExtraBold', byteLength: data?.byteLength } + ]) } finally { globalThis.fetch = originalFetch } diff --git a/tests/engine/text/fonts/web-fonts.test.ts b/tests/engine/text/fonts/web-fonts.test.ts new file mode 100644 index 000000000..d2dc37029 --- /dev/null +++ b/tests/engine/text/fonts/web-fonts.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'bun:test' + +import { normalizedCoverageText, webFontSubsetsForText } from '@open-pencil/core/text' + +describe('web font coverage requests', () => { + test('normalizes coverage without splitting supplementary code points', () => { + expect(normalizedCoverageText('界A界𠀀A')).toBe(normalizedCoverageText('A界𠀀')) + expect(Array.from(normalizedCoverageText('𠀀'))).toEqual(['𠀀']) + }) + + test('requests script-specific subsets instead of Latin only', () => { + expect(webFontSubsetsForText('مرحبا')).toContain('arabic') + expect(webFontSubsetsForText('한글')).toContain('korean') + expect(webFontSubsetsForText('かな')).toContain('japanese') + expect(webFontSubsetsForText('你好')).toEqual( + expect.arrayContaining(['chinese-simplified', 'chinese-traditional', 'japanese']) + ) + }) +}) diff --git a/tests/engine/text/resolver.test.ts b/tests/engine/text/resolver.test.ts index a199b9d2e..ecd73c3ac 100644 --- a/tests/engine/text/resolver.test.ts +++ b/tests/engine/text/resolver.test.ts @@ -74,6 +74,30 @@ describe('FontResolver', () => { expect(settled).toEqual(['first', 'second']) }) + test('settles only nodes that depend on the resolved key', async () => { + let release: ((loaded: boolean) => void) | undefined + const resolver = new FontResolver( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const demand = { key: 'node-aware', candidates: [candidate('remote')] } + const settled: string[][] = [] + + const onSettled = (_snapshot: unknown, nodeIds: readonly string[]) => { + settled.push([...nodeIds]) + } + const first = resolver.demandForNode(demand, 'first', onSettled) + const second = resolver.demandForNode(demand, 'second', onSettled) + expect(resolver.pendingNodeIds(demand)).toEqual(['first', 'second']) + + release?.(true) + await Promise.all([first, second]) + expect(settled).toEqual([['first', 'second']]) + expect(resolver.pendingNodeIds(demand)).toEqual([]) + }) + test('exhausts after every candidate is unavailable', async () => { const resolver = new FontResolver(async () => false) const demand = faceDemand() diff --git a/vite/aliases.ts b/vite/aliases.ts index 9466e8a2a..8506a7eaa 100644 --- a/vite/aliases.ts +++ b/vite/aliases.ts @@ -43,7 +43,7 @@ export function createOpenPencilAliases(rootDir: string) { { find: '@open-pencil/core', replacement: resolve(rootDir, 'packages/core/src') }, { find: 'opentype.js', - replacement: resolve(rootDir, 'node_modules/opentype.js/dist/opentype.module.js') + replacement: resolve(rootDir, 'node_modules/opentype.js/dist/opentype.mjs') }, { find: 'mermaid', replacement: resolve(rootDir, 'src/app/shell/markdown/index.ts') }, { find: 'beautiful-mermaid', replacement: resolve(rootDir, 'src/app/shell/markdown/index.ts') }