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:
parent
35af00433f
commit
47fe43c3ec
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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']>,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
127
packages/core/src/canvas/text/clipboard.ts
Normal file
127
packages/core/src/canvas/text/clipboard.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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[]) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
23
packages/core/src/text/font-sources.ts
Normal file
23
packages/core/src/text/font-sources.ts
Normal 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>
|
||||
96
packages/core/src/text/font-style.ts
Normal file
96
packages/core/src/text/font-style.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
77
packages/core/src/text/requirements.ts
Normal file
77
packages/core/src/text/requirements.ts
Normal 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) }
|
||||
}
|
||||
12
packages/core/src/text/resolved-requirements.ts
Normal file
12
packages/core/src/text/resolved-requirements.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export function createDefaultNode(
|
|||
italic: false,
|
||||
textAlignHorizontal: 'LEFT',
|
||||
textDirection: 'AUTO',
|
||||
textLanguage: null,
|
||||
leadingTrim: 'NONE',
|
||||
lineHeight: null,
|
||||
letterSpacing: 0,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
derivedUnderlineRect,
|
||||
shouldUseHardFigmaDerivedGlyphCoverage,
|
||||
snapFigmaDerivedGlyphBaseline
|
||||
} from '#core/canvas/text-derived'
|
||||
} from '#core/canvas/text/derived'
|
||||
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
60
tests/engine/text/fonts/lifecycle.test.ts
Normal file
60
tests/engine/text/fonts/lifecycle.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
19
tests/engine/text/fonts/web-fonts.test.ts
Normal file
19
tests/engine/text/fonts/web-fonts.test.ts
Normal 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'])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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') }
|
||||
|
|
|
|||
Loading…
Reference in a new issue