feat(text): harden font resolution lifecycle

- Gate ingest rendering until required faces and script fallbacks resolve
- Key CanvasKit artifacts by font registration generation and invalidate affected nodes
- Load character-aware remote subsets with cumulative coverage and exact render aliases
- Use BCP-47 language hints for shaping and CJK fallback order
- Keep baked .fig glyphs as an exhaustion-only fallback
This commit is contained in:
Danila Poyarkov 2026-07-17 14:52:35 +03:00
parent 35af00433f
commit 47fe43c3ec
49 changed files with 1356 additions and 555 deletions

View file

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

View file

@ -108,15 +108,7 @@ fn load_system_font_blocking(family: String, style: String) -> Result<Vec<u8>, 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]

View file

@ -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<SceneNode['booleanOperation']>,

View file

@ -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<string>
}
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<string, PendingFontNode>()
textPictureGenerations = new Map<string, { data: Uint8Array; generation: number }>()
imageCache = new Map<string, CKImage>()
vectorPathCache = new Map<string, Path[]>()
vectorStrokePathCache = new Map<string, Path[]>()
@ -87,6 +99,7 @@ export class SkiaRenderer {
strokeGeometryCache = new Map<string, Path[]>()
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<string, SkPicture | null>()
nodePictureCacheGenerations = new Map<string, number>()
subtreePictureCache = new Map<string, SubtreePictureCacheEntry>()
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 {

View file

@ -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<string>() }
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<void> {
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)

View file

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

View file

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

View file

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

View file

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

View file

@ -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<NodeChange['derivedTextData']>['baselines']
glyphs: ClipboardShapedGlyph[]
logicalIndexToCharacterOffsetMap: number[]
}
function addShapedRunGlyphs(
run: ReturnType<Paragraph['getShapedLines']>[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<Paragraph['getLineMetrics']>[number],
textLength: number,
baselines: NonNullable<ClipboardShapedText['baselines']>
): 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<ClipboardShapedText | null> {
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<ClipboardShapedText['baselines']> = []
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
}
}

View file

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

View file

@ -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<NodeChange['derivedTextData']>['baselines']
glyphs: ClipboardShapedGlyph[]
logicalIndexToCharacterOffsetMap: number[]
}
const FONT_FAMILY_CACHE_LIMIT = 256
const fontFamilyCache = new Map<string, string[]>()
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<string, { family: string; style: string }>()
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<FontFallbackScript, string[]>()
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<CanvasKit['ParagraphBuilder']['MakeFromFontProvider']>,
@ -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<Paragraph['getShapedLines']>[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<Paragraph['getLineMetrics']>[number],
textLength: number,
baselines: NonNullable<ClipboardShapedText['baselines']>
): 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<ClipboardShapedText | null> {
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<ClipboardShapedText['baselines']> = []
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
}
}

View file

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

View file

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

View file

@ -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[]) {

View file

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

View file

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

View file

@ -105,7 +105,7 @@ export type EditorEventName = keyof EditorEvents
export interface EditorOptions {
graph?: SceneGraph
state?: EditorState
loadFont?: (family: string, style: string) => Promise<ArrayBuffer | null>
loadFont?: (family: string, style: string, characters?: string) => Promise<ArrayBuffer | null>
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<ArrayBuffer | null>
loadFont: (family: string, style: string, characters?: string) => Promise<ArrayBuffer | null>
getViewportSize: () => { width: number; height: number }
getCk: () => CanvasKit | null
getRenderer: () => SkiaRenderer | null

View file

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

View file

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

View file

@ -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<FontFallbackScript, 'cjk-sc' | 'cjk-tc' | 'cjk-jp' | 'cjk-kr'> | 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[]

View file

@ -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<ArrayBuffer | null>
write(family: string, style: string, data: ArrayBuffer, characters?: string): Promise<void>
}
export type HostFontLoader = (family: string, style: string) => Promise<ArrayBuffer | null>

View file

@ -0,0 +1,96 @@
import { parseFontStyle } from '#core/text/face'
interface LocalFontMatch {
family: string
style: string
}
export function chooseLocalFontMatch<T extends LocalFontMatch>(
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<number, string> = {
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
}

View file

@ -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<ArrayBuffer | null>
write(family: string, style: string, data: ArrayBuffer): Promise<void>
}
export type HostFontLoader = (family: string, style: string) => Promise<ArrayBuffer | null>
type FindLocalFontOptions = { allowVariable?: boolean }
type LocalFontMatch = Pick<FontInfo, 'family' | 'style'>
export function chooseLocalFontMatch<T extends LocalFontMatch>(
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<string, string> = {
'Inter|Regular': '/Inter-Regular.ttf',
'Inter|Medium': '/Inter-Medium.ttf',
@ -76,65 +38,14 @@ const BUNDLED_FONTS: Record<string, string> = {
'Noto Naskh Arabic|Regular': '/NotoNaskhArabic-Regular.ttf'
}
export const FONT_WEIGHT_NAMES: Record<number, string> = {
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<string, ArrayBuffer>()
private supplementalFamilyData = new Map<string, ArrayBuffer[]>()
private remoteCoverage = new Map<string, Set<string>>()
private blockedNodeIds = new Set<string>()
private fontProvider: TypefaceFontProvider | null = null
private registrationGeneration = 0
private providerRegistrations = new Map<string, Set<ArrayBuffer>>()
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<string>()
private renderFamilyAliases = new Map<string, string>()
private renderFamilyRevisions = new Map<string, number>()
private cjkFallbackFamilies: string[] = []
private cjkFallbackPromise: Promise<string[]> | 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<ArrayBuffer | null> {
const cached = await this.readDownloadedFont(family, style)
async loadCachedFont(
family: string,
style = 'Regular',
characters = ''
): Promise<ArrayBuffer | null> {
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<ArrayBuffer | null> {
async loadRemoteFont(
family: string,
style = 'Regular',
characters = ''
): Promise<ArrayBuffer | null> {
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<string>()
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<ArrayBuffer | null> {
async loadFont(family: string, style = 'Regular', characters = ''): Promise<ArrayBuffer | null> {
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<string>()
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<string[]> {
@ -421,14 +384,19 @@ export class FontManager {
}
async ensureFallbackPack(
scripts: FontFallbackScript[] = ['cjk', 'arabic']
scripts: FontFallbackScript[] = ['cjk', 'arabic'],
characters = ''
): Promise<Partial<Record<FontFallbackScript, string[]>>> {
const result: Partial<Record<FontFallbackScript, string[]>> = {}
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<string[]> {
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<ArrayBuffer | null> {
private async readDownloadedFont(
family: string,
style: string,
characters = ''
): Promise<ArrayBuffer | null> {
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<void> {
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<ArrayBuffer>()
familyRegistrations.add(data)
this.providerRegistrations.set(family, familyRegistrations)
this.registrationGeneration++
return true
} catch {
return false

View file

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

View file

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

View file

@ -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<string>()
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<string>()
const nodes: SceneNode[] = []
const scripts = new Set<FontFallbackScript>()
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) }
}

View file

@ -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<FontFallbackScript>()
for (const node of requirements.nodes) {
if (node.type !== 'TEXT') continue
for (const script of textNeededFallbackScripts(node)) scripts.add(script)
}
return Array.from(scripts)
}

View file

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

View file

@ -9,7 +9,8 @@ interface FontResolutionEntry {
demand: FontResolutionDemand
snapshot: FontResolutionSnapshot
promise: Promise<FontResolutionSnapshot>
callbacks: Set<FontResolutionSettled>
callbacks: Map<FontResolutionSettled, Set<string>>
nodeIds: Set<string>
}
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<FontResolutionSnapshot> {
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<FontResolutionSettled>()
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<FontResolutionSnapshot> {
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<FontResolutionSnapshot> {
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<string>()
if (nodeId) callbackNodes.add(nodeId)
entry.callbacks.set(onSettled, callbackNodes)
}
private async resolve(entry: FontResolutionEntry): Promise<FontResolutionSnapshot> {
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
}
}

View file

@ -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<boolean>
export type FontResolutionSettled = (snapshot: FontResolutionSnapshot) => void
export type FontResolutionSettled = (
snapshot: FontResolutionSnapshot,
nodeIds: readonly string[]
) => void

View file

@ -1,6 +1,7 @@
import {
createUnifont,
providers,
type GoogleFamilyOptions,
type RemoteFontSource,
type ResolveFontOptions,
type Unifont
@ -15,8 +16,8 @@ export type WebFontProvider =
| ReturnType<typeof providers.fontshare>
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 = {

View file

@ -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<WebFontProviderId, boole
export type WebFontFetch = (url: string, init?: RequestInit) => Promise<Response>
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<string>()
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<WebFontProviderId>(
WEB_FONT_PROVIDER_IDS.filter((provider) => DEFAULT_WEB_FONT_PROVIDER_SETTINGS[provider])
@ -36,11 +97,13 @@ export class WebFontResolver {
private familiesCache = new Map<WebFontProviderId, string[]>()
private familiesPromises = new Map<WebFontProviderId, Promise<string[]>>()
private failedFonts = new Set<string>()
private fontPromises = new Map<string, Promise<ArrayBuffer | null>>()
private fontPromises = new Map<string, Promise<ArrayBuffer[]>>()
private remoteFetch: WebFontFetch | null = null
private fetchProxyQueue: Promise<void> = Promise.resolve()
setEnabled(settings: Partial<Record<WebFontProviderId, boolean>>): 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<ArrayBuffer | null> {
async fetchFont(families: string[], style: string, characters = ''): Promise<ArrayBuffer[]> {
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<T>(operation: () => Promise<T>): Promise<T> {
if (!this.remoteFetch) return operation()
const previous = this.fetchProxyQueue
let release: (() => void) | undefined
this.fetchProxyQueue = new Promise<void>((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<ArrayBuffer | null> {
const key = `${provider}|${family}|${style}`
if (this.failedFonts.has(key)) return null
provider: WebFontProviderId,
characters: string
): Promise<ArrayBuffer[]> {
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<ArrayBuffer | null> {
provider: WebFontProviderId,
characters: string
): Promise<ArrayBuffer[]> {
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<ResolveFontResult>(() =>
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 []
}
}
}

View file

@ -56,6 +56,7 @@ export function createDefaultNode(
italic: false,
textAlignHorizontal: 'LEFT',
textDirection: 'AUTO',
textLanguage: null,
leadingTrim: 'NONE',
lineHeight: null,
letterSpacing: 0,

View file

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

View file

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

View file

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

View file

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

View file

@ -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<TauriFontFamily[]> {
return []
}
export async function ensureGraphFonts(graph: SceneGraph, nodeIds: string[]): Promise<boolean> {
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<ReturnType<typeof textNeededFallbackScripts>[number]>()
const collect = (id: string) => {
export async function ensureGraphFonts(
graph: SceneGraph,
nodeIds: string[],
renderer?: FontRenderInvalidator | null
): Promise<boolean> {
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<ArrayBuffer | null> {
@ -206,9 +214,13 @@ async function loadSystemFont(family: string, style = 'Regular'): Promise<ArrayB
}
}
export async function loadFont(family: string, style = 'Regular'): Promise<ArrayBuffer | null> {
export async function loadFont(
family: string,
style = 'Regular',
characters = ''
): Promise<ArrayBuffer | null> {
configureTauriFontCache()
const loaded = await fontManager.loadFont(family, style)
const loaded = await fontManager.loadFont(family, style, characters)
if (!loaded) showWebFontUnavailableToast()
return loaded
}

View file

@ -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<string[]> | null
arabicFallbackFamilies: string[]
arabicFallbackPromise: Promise<string[]> | 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<void>((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<typeof renderer.renderFromEditorState>
) => {
@ -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<void>((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()

View file

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

View file

@ -7,7 +7,7 @@ import {
derivedUnderlineRect,
shouldUseHardFigmaDerivedGlyphCoverage,
snapFigmaDerivedGlyphBaseline
} from '#core/canvas/text-derived'
} from '#core/canvas/text/derived'
import { expectDefined } from '#tests/helpers/assert'

View file

@ -69,6 +69,8 @@ function createMockRenderer(overrides: Partial<Record<string, unknown>> = {}) {
},
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]

View file

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

View file

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

View file

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

View file

@ -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'])
)
})
})

View file

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

View file

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