From ef78ffd2e4d45d558693aeb12e04100b34e26a43 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 2 Mar 2026 19:33:00 +0300 Subject: [PATCH] Render text from SkPicture cache when fonts are missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/core/src/clipboard.ts | 42 ++++++++++++++--- packages/core/src/fonts.ts | 4 ++ packages/core/src/index.ts | 4 +- packages/core/src/renderer.ts | 47 +++++++++++++++++-- packages/core/src/scene-graph.ts | 3 ++ .../properties/TypographySection.vue | 11 ++++- src/composables/use-font-status.ts | 23 +++++++++ src/stores/editor.ts | 5 +- 8 files changed, 125 insertions(+), 14 deletions(-) create mode 100644 src/composables/use-font-status.ts diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index 554f39106..90813bd17 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -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>): 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 `` } function collectNodeTree( nodes: SceneNode[], - graph: SceneGraph -): Array { + graph: SceneGraph, + textPictureBuilder?: TextPictureBuilder +): Array> { return nodes.map((node) => { const children = graph.getChildren(node.id) - return { - ...node, - children: children.length > 0 ? collectNodeTree(children, graph) : undefined + const serialized: Record = { ...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 }) } diff --git a/packages/core/src/fonts.ts b/packages/core/src/fonts.ts index 647a6687e..bc4594849 100644 --- a/packages/core/src/fonts.ts +++ b/packages/core/src/fonts.ts @@ -133,6 +133,10 @@ export async function ensureNodeFont(family: string, weight: number): Promise k.startsWith(`${family}|`)) +} + export function weightToStyle(weight: number, italic = false): string { let label = 'Regular' if (weight <= 100) label = 'Thin' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f72f10d4e..4fcee02f4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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' diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index db3df7474..d3973d1d2 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -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() + 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 diff --git a/packages/core/src/scene-graph.ts b/packages/core/src/scene-graph.ts index 5cae0a0cb..c4c5d96c0 100644 --- a/packages/core/src/scene-graph.ts +++ b/packages/core/src/scene-graph.ts @@ -265,6 +265,8 @@ export interface SceneNode { overrides: Record boundVariables: Record + + textPicture: Uint8Array | null } export type VariableType = 'COLOR' | 'FLOAT' | 'STRING' | 'BOOLEAN' @@ -385,6 +387,7 @@ function createDefaultNode(type: NodeType, overrides: Partial = {}): componentId: null, overrides: {}, boundVariables: {}, + textPicture: null, ...overrides } } diff --git a/src/components/properties/TypographySection.vue b/src/components/properties/TypographySection.vue index b72d4a8d2..25f3fb091 100644 --- a/src/components/properties/TypographySection.vue +++ b/src/components/properties/TypographySection.vue @@ -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 () => {
-
- +
+ +
diff --git a/src/composables/use-font-status.ts b/src/composables/use-font-status.ts new file mode 100644 index 000000000..8628c32a0 --- /dev/null +++ b/src/composables/use-font-status.ts @@ -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() + 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 } +} diff --git a/src/stores/editor.ts b/src/stores/editor.ts index 0df7fc413..5ff908714 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -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