Render text from SkPicture cache when fonts are missing

On copy: serialize each text node's paragraph into an SkPicture and
embed the bytes (base64) in the OpenPencil clipboard payload.

On paste: if the font isn't available, render from the cached SkPicture
instead of the broken fallback. Text looks pixel-perfect without the
font installed — same behavior as Figma.

Missing font indicator:
- Orange ⚠ badge on canvas over text nodes with unavailable fonts
- Amber warning banner in the Typography panel listing missing families
- isFontLoaded() check in @open-pencil/core for querying font status
This commit is contained in:
Danila Poyarkov 2026-03-02 19:33:00 +03:00
parent a3236a08a0
commit ef78ffd2e4
8 changed files with 125 additions and 14 deletions

View file

@ -458,6 +458,7 @@ export function parseOpenPencilClipboard(
try {
const decoded = JSON.parse(atob(match[1]))
if (decoded.format === 'openpencil/v1' && Array.isArray(decoded.nodes)) {
restoreTextPictures(decoded.nodes)
return decoded.nodes
}
} catch {
@ -466,23 +467,50 @@ export function parseOpenPencilClipboard(
return null
}
export function buildOpenPencilClipboardHTML(nodes: SceneNode[], graph: SceneGraph): string {
function restoreTextPictures(nodes: Array<Record<string, unknown>>): void {
for (const node of nodes) {
if (typeof node.textPicture === 'string') {
node.textPicture = base64ToBinary(node.textPicture)
}
if (Array.isArray(node.children)) {
restoreTextPictures(node.children)
}
}
}
export type TextPictureBuilder = (node: SceneNode) => Uint8Array | null
export function buildOpenPencilClipboardHTML(
nodes: SceneNode[],
graph: SceneGraph,
textPictureBuilder?: TextPictureBuilder
): string {
const data = {
format: 'openpencil/v1',
nodes: collectNodeTree(nodes, graph)
nodes: collectNodeTree(nodes, graph, textPictureBuilder)
}
return `<!--(openpencil)${btoa(JSON.stringify(data))}(/openpencil)-->`
}
function collectNodeTree(
nodes: SceneNode[],
graph: SceneGraph
): Array<SceneNode & { children?: SceneNode[] }> {
graph: SceneGraph,
textPictureBuilder?: TextPictureBuilder
): Array<Record<string, unknown>> {
return nodes.map((node) => {
const children = graph.getChildren(node.id)
return {
...node,
children: children.length > 0 ? collectNodeTree(children, graph) : undefined
const serialized: Record<string, unknown> = { ...node }
if (node.type === 'TEXT' && node.text && textPictureBuilder) {
const pic = node.textPicture ?? textPictureBuilder(node)
if (pic) serialized.textPicture = binaryToBase64(pic)
} else {
delete serialized.textPicture
}
if (children.length > 0) {
serialized.children = collectNodeTree(children, graph, textPictureBuilder)
}
return serialized
})
}

View file

@ -133,6 +133,10 @@ export async function ensureNodeFont(family: string, weight: number): Promise<vo
await loadFont(family, style)
}
export function isFontLoaded(family: string): boolean {
return [...loadedFamilies.keys()].some((k) => k.startsWith(`${family}|`))
}
export function weightToStyle(weight: number, italic = false): string {
let label = 'Regular'
if (weight <= 100) label = 'Thin'

View file

@ -54,6 +54,7 @@ export {
listFamilies,
initFontService,
getFontProvider,
isFontLoaded,
ensureNodeFont,
styleToWeight,
weightToStyle
@ -126,7 +127,8 @@ export {
parseOpenPencilClipboard,
buildFigmaClipboardHTML,
buildOpenPencilClipboardHTML,
prefetchFigmaSchema
prefetchFigmaSchema,
type TextPictureBuilder
} from './clipboard'
export { readFigFile, parseFigFile } from './kiwi/fig-file'

View file

@ -58,6 +58,7 @@ import {
TEXT_CARET_WIDTH
} from './constants'
import { vectorNetworkToPath } from './vector'
import { isFontLoaded } from './fonts'
import type { SceneNode, SceneGraph, Fill, Stroke } from './scene-graph'
import type { SnapGuide } from './snap'
@ -1676,14 +1677,54 @@ export class SkiaRenderer {
if (!text) return
if (this.fontsLoaded && this.fontProvider) {
const paragraph = this.buildParagraph(node, this.fillPaint.getColor())
canvas.drawParagraph(paragraph, 0, 0)
paragraph.delete()
if (this.isNodeFontLoaded(node)) {
const paragraph = this.buildParagraph(node, this.fillPaint.getColor())
canvas.drawParagraph(paragraph, 0, 0)
paragraph.delete()
} else if (node.textPicture) {
const pic = this.ck.MakePicture(node.textPicture)
if (pic) {
canvas.drawPicture(pic)
pic.delete()
}
} else if (this.textFont) {
canvas.drawText(text, 0, node.fontSize || DEFAULT_FONT_SIZE, this.fillPaint, this.textFont)
}
} else if (this.textFont) {
canvas.drawText(text, 0, node.fontSize || DEFAULT_FONT_SIZE, this.fillPaint, this.textFont)
}
}
isNodeFontLoaded(node: SceneNode): boolean {
const families = new Set<string>()
families.add(node.fontFamily || 'Inter')
for (const run of node.styleRuns) {
if (run.style.fontFamily) families.add(run.style.fontFamily)
}
return [...families].every((f) => isFontLoaded(f))
}
buildTextPicture(node: SceneNode): Uint8Array | null {
if (!this.fontsLoaded || !this.fontProvider || !this.isNodeFontLoaded(node)) return null
if (node.type !== 'TEXT' || !node.text) return null
const ck = this.ck
const recorder = new ck.PictureRecorder()
const bounds = ck.LTRBRect(0, 0, node.width || 1e6, node.height || 1e6)
const recCanvas = recorder.beginRecording(bounds)
const paragraph = this.buildParagraph(node)
recCanvas.drawParagraph(paragraph, 0, 0)
paragraph.delete()
const picture = recorder.finishRecordingAsPicture()
recorder.delete()
const bytes = picture.serialize()
picture.delete()
return bytes ?? null
}
buildParagraph(node: SceneNode, color?: Float32Array): import('canvaskit-wasm').Paragraph {
const ck = this.ck
const baseColor = color ?? ck.BLACK

View file

@ -265,6 +265,8 @@ export interface SceneNode {
overrides: Record<string, unknown>
boundVariables: Record<string, string>
textPicture: Uint8Array | null
}
export type VariableType = 'COLOR' | 'FLOAT' | 'STRING' | 'BOOLEAN'
@ -385,6 +387,7 @@ function createDefaultNode(type: NodeType, overrides: Partial<SceneNode> = {}):
componentId: null,
overrides: {},
boundVariables: {},
textPicture: null,
...overrides
}
}

View file

@ -3,10 +3,12 @@ import { computed, onMounted } from 'vue'
import FontPicker from '@/components/FontPicker.vue'
import ScrubInput from '@/components/ScrubInput.vue'
import { useNodeFontStatus } from '@/composables/use-font-status'
import { useNodeProps } from '@/composables/use-node-props'
import { loadFont } from '@/engine/fonts'
const { store, node, updateProp, commitProp } = useNodeProps()
const { missingFonts, hasMissingFonts } = useNodeFontStatus(() => node.value)
const WEIGHTS = [
{ value: 100, label: 'Thin' },
@ -73,8 +75,13 @@ onMounted(async () => {
<div v-if="node" class="border-b border-border px-3 py-2">
<label class="mb-1.5 block text-[11px] text-muted">Typography</label>
<div class="mb-1.5">
<FontPicker :model-value="node.fontFamily" @select="selectFamily" />
<div class="mb-1.5 flex items-center gap-1.5">
<FontPicker class="min-w-0 flex-1" :model-value="node.fontFamily" @select="selectFamily" />
<icon-lucide-alert-triangle
v-if="hasMissingFonts"
class="size-3.5 shrink-0 text-amber-400"
:title="'Missing font' + (missingFonts.length > 1 ? 's' : '') + ': ' + missingFonts.join(', ')"
/>
</div>
<!-- Weight + Size -->

View file

@ -0,0 +1,23 @@
import { isFontLoaded } from '@open-pencil/core'
import { computed } from 'vue'
import type { SceneNode } from '@open-pencil/core'
export function useNodeFontStatus(node: () => SceneNode) {
const missingFonts = computed(() => {
const n = node()
if (n.type !== 'TEXT') return []
const families = new Set<string>()
families.add(n.fontFamily || 'Inter')
for (const run of n.styleRuns) {
if (run.style.fontFamily) families.add(run.style.fontFamily)
}
return [...families].filter((f) => !isFontLoaded(f))
})
const hasMissingFonts = computed(() => missingFonts.value.length > 0)
return { missingFonts, hasMissingFonts }
}

View file

@ -1569,7 +1569,10 @@ export function createEditorStore() {
if (nodes.length === 0) return
const names = nodes.map((n) => n.name).join('\n')
const internalHtml = buildOpenPencilClipboardHTML(nodes, graph)
const textPicBuilder = _renderer
? (node: SceneNode) => _renderer!.buildTextPicture(node)
: undefined
const internalHtml = buildOpenPencilClipboardHTML(nodes, graph, textPicBuilder)
const figmaHtml = buildFigmaClipboardHTML(nodes, graph)
const html = figmaHtml ? figmaHtml + internalHtml : internalHtml