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:
parent
a3236a08a0
commit
ef78ffd2e4
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
23
src/composables/use-font-status.ts
Normal file
23
src/composables/use-font-status.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue