fix(text): finalize font readiness and label shaping (#593)

* fix(text): prepare browser fonts atomically

- Fetch approved Fontsource resources directly in browsers with bounded responses and retryable provider failures
- Limit page font resolution concurrency and retain live Inter substitution paragraphs after baked glyph invalidation
- Shape frame, section, and component labels through a bounded native Paragraph cache
- Cover real Geist, Geist Mono, and Roboto Mono browser loading plus substitution and cache lifecycle behavior

* test(canvas): cover substitution and label shaping

- Capture baked missing-font text before editing, live Inter substitution on first input, and visible undo output
- Assert text-picture and derived-glyph invalidation with finalized substituted readiness
- Cover shaped frame, rotated frame, section, component, component-set, ellipsis, kerning, and zoom label presentation

* fix(text): preserve same-origin fetches during font resolution

- Route same-origin application and fixture requests through native fetch while Unifont temporarily proxies global HTTP requests
- Keep the external provider HTTPS allowlist enforced for cross-origin font resources
- Cover the production race discovered while loading gold-preview.fig during provider initialization

* fix(text): preserve substituted path glyphs

- Keep imported derived curved glyphs for text-on-path layers when exact fonts finalize as substituted
- Cover substituted path rendering through the runtime renderer and refresh the corrected visual oracle
- Update shaped Inter measurement badges and merged typography panel snapshots after visual inspection
- Search virtualized font catalogs explicitly and restore the canvas screenshot helper used by broad E2E coverage

* fix(text): use browser font transport without desktop warning

- Keep the browser provider implementation aligned with current settings behavior after splitting from preparation

* test(text): inject same-origin browser font context

- Keep the transport test deterministic outside a Window global

* fix(text): preserve final browser font transport hardening

- Carry the same-origin large-document bypass and bounded cross-origin provider checks from the preserved integration snapshot

* fix(text): initialize font transport outside Window contexts

- Keep unit and headless module imports safe while browsers use their current origin

* test(text): satisfy async visual fixture contract

- Return from the resolver setup continuation after requesting render

* test(canvas): include paragraph cache lifecycle

* fix(text): cancel queued browser font loads

- Race serialized provider work against preparation cancellation\n- Release queue slots after cancelled waiters without disturbing active requests\n- Reuse one section-title paragraph cache entry for measurement and drawing

* fix(text): close font queue cancellation races

- Release reserved proxy queue slots when cancellation lands after queue acquisition\n- Skip paragraph work for zero-width section labels

* fix(text): abort active provider resolution

- Race active provider resolution against its preparation signal and restore the temporary fetch proxy promptly\n- Forward cancellation through proxied provider requests\n- Align the renderer font-readiness facade with substituted text
This commit is contained in:
Danila Poyarkov 2026-08-30 14:33:32 +03:00 committed by GitHub
parent 6f5638380a
commit 0f64cecc49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 777 additions and 111 deletions

View file

@ -46,6 +46,7 @@
### Changed
- Load supported online fonts in the browser before revealing imported pages, preserve substituted text during editing, and shape canvas labels with bundled Inter typography.
- Upgrade CanvasKit to 0.41 and migrate renderer geometry to immutable paths built through `PathBuilder`.
- Upgrade direct model chat providers and transports to AI SDK 7 while retaining the local ACP execution path.
- Localize file, clipboard, collaboration, chat, vectorization, storage, recovery, and component-library notifications in every supported language.

View file

@ -9,6 +9,7 @@
"ignoreUnresolved": [
"~icons/*",
"/src/app/editor/fonts/index.ts",
"/packages/core/src/text/index.ts",
"/src/app/tabs/index.ts",
"#core/*"
],

View file

@ -1,4 +1,4 @@
import type { Canvas, Font } from 'canvaskit-wasm'
import type { Canvas } from 'canvaskit-wasm'
import type { SceneNode, SceneGraph } from '@open-pencil/scene-graph'
@ -8,81 +8,62 @@ import {
SECTION_TITLE_PADDING_X,
SECTION_TITLE_RADIUS,
SECTION_TITLE_GAP,
SECTION_TITLE_FONT_SIZE,
COMPONENT_LABEL_FONT_SIZE,
COMPONENT_LABEL_GAP,
COMPONENT_LABEL_ICON_SIZE,
COMPONENT_LABEL_ICON_GAP
} from '#core/constants'
import { ellipsizeLabelText } from './text'
export function drawSectionTitles(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph): void {
if (!r.sectionTitleFont) return
const provider = r.fontProvider
if (!r.sectionTitleFont || !provider) return
const sections = r.labelCache.getSections(graph, r.worldViewport)
if (sections.length === 0) return
const font = r.sectionTitleFont
const ellipsis = '…'
const ellipsisGlyphs = font.getGlyphIDs(ellipsis)
const ellipsisWidth = font.getGlyphWidths(ellipsisGlyphs)[0]
for (const { node, absX, absY, nested } of sections) {
drawSectionTitle(r, canvas, font, node, graph, absX, absY, nested, ellipsis, ellipsisWidth)
drawSectionTitle(r, canvas, provider, node, graph, absX, absY, nested)
}
}
function drawSectionTitle(
r: SkiaRenderer,
canvas: Canvas,
font: Font,
provider: NonNullable<SkiaRenderer['fontProvider']>,
node: SceneNode,
graph: SceneGraph,
absX: number,
absY: number,
nested: boolean,
ellipsis: string,
ellipsisWidth: number
nested: boolean
): void {
const screenX = absX * r.zoom + r.panX
const screenY = absY * r.zoom + r.panY
const screenW = node.width * r.zoom
const maxPillW = Math.max(screenW, 0)
const glyphIds = font.getGlyphIDs(node.name)
const widths = font.getGlyphWidths(glyphIds)
let fullTextWidth = 0
for (const w of widths) fullTextWidth += w
const maxTextW = maxPillW - SECTION_TITLE_PADDING_X * 2
let displayText = node.name
let textWidth = fullTextWidth
if (textWidth > maxTextW && maxTextW > ellipsisWidth) {
let truncW = 0
let truncIdx = 0
for (let i = 0; i < widths.length; i++) {
if (truncW + widths[i] + ellipsisWidth > maxTextW) break
truncW += widths[i]
truncIdx = i + 1
}
displayText = node.name.slice(0, truncIdx) + ellipsis
textWidth = truncW + ellipsisWidth
} else if (maxTextW <= ellipsisWidth) {
displayText = ellipsis
textWidth = ellipsisWidth
}
const pillW = Math.min(textWidth + SECTION_TITLE_PADDING_X * 2, maxPillW)
const pillH = SECTION_TITLE_HEIGHT
const localPillX = 0
const localPillY = nested ? SECTION_TITLE_GAP : -pillH - SECTION_TITLE_GAP
if (maxPillW <= 0) return
const pillColor =
node.fills.length > 0 && node.fills[0].visible
? r.resolveFillColor(node.fills[0], 0, node, graph)
: { r: 0.37, g: 0.37, b: 0.37, a: 1 }
const lum = 0.299 * pillColor.r + 0.587 * pillColor.g + 0.114 * pillColor.b
const textColor = lum > 0.5 ? r.ck.BLACK : r.ck.WHITE
const maxTextW = Math.max(1, maxPillW - SECTION_TITLE_PADDING_X * 2)
const textWidth = r.labelParagraphCache.measure(
r.ck,
provider,
node.name,
SECTION_TITLE_FONT_SIZE,
maxTextW,
textColor,
r.fontGeneration
)
const pillW = Math.min(textWidth + SECTION_TITLE_PADDING_X * 2, maxPillW)
const pillH = SECTION_TITLE_HEIGHT
const localPillX = 0
const localPillY = nested ? SECTION_TITLE_GAP : -pillH - SECTION_TITLE_GAP
canvas.save()
canvas.translate(screenX, screenY)
@ -94,20 +75,29 @@ function drawSectionTitle(
const pillRect = r.ck.LTRBRect(localPillX, localPillY, localPillX + pillW, localPillY + pillH)
canvas.drawRRect(r.ck.RRectXY(pillRect, SECTION_TITLE_RADIUS, SECTION_TITLE_RADIUS), r.auxFill)
const lum = 0.299 * pillColor.r + 0.587 * pillColor.g + 0.114 * pillColor.b
r.auxFill.setColor(lum > 0.5 ? r.ck.BLACK : r.ck.WHITE)
const textY = localPillY + pillH * 0.7
canvas.drawText(displayText, localPillX + SECTION_TITLE_PADDING_X, textY, r.auxFill, font)
r.auxFill.setColor(textColor)
r.labelParagraphCache.draw(
r.ck,
canvas,
provider,
node.name,
SECTION_TITLE_FONT_SIZE,
maxTextW,
textColor,
r.fontGeneration,
localPillX + SECTION_TITLE_PADDING_X,
localPillY
)
canvas.restore()
}
export function drawComponentLabels(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph): void {
if (!r.componentLabelFont) return
if (!r.componentLabelFont || !r.fontProvider) return
const components = r.labelCache.getComponents(graph, r.worldViewport)
if (components.length === 0) return
const font = r.componentLabelFont
const provider = r.fontProvider
const compColor = r.compColor()
const iconS = COMPONENT_LABEL_ICON_SIZE
@ -124,8 +114,7 @@ export function drawComponentLabels(r: SkiaRenderer, canvas: Canvas, graph: Scen
}
const maxTextWidth = node.width * r.zoom - iconS - COMPONENT_LABEL_ICON_GAP
const displayText = ellipsizeLabelText(font, node.name, maxTextWidth)
if (!displayText) continue
if (maxTextWidth <= 0) continue
const iconX = labelX
const iconY = labelY - COMPONENT_LABEL_FONT_SIZE * 0.75
@ -168,6 +157,17 @@ export function drawComponentLabels(r: SkiaRenderer, canvas: Canvas, graph: Scen
immutablePath.delete()
}
canvas.drawText(displayText, labelX + iconS + COMPONENT_LABEL_ICON_GAP, labelY, r.auxFill, font)
r.labelParagraphCache.draw(
r.ck,
canvas,
provider,
node.name,
COMPONENT_LABEL_FONT_SIZE,
maxTextWidth,
compColor,
r.fontGeneration,
labelX + iconS + COMPONENT_LABEL_ICON_GAP,
labelY - COMPONENT_LABEL_FONT_SIZE
)
}
}

View file

@ -0,0 +1,97 @@
import type { Canvas, CanvasKit, Paragraph, TypefaceFontProvider } from 'canvaskit-wasm'
interface LabelParagraphEntry {
paragraph: Paragraph
width: number
}
const MAX_LABEL_PARAGRAPHS = 512
export class LabelParagraphCache {
private readonly entries = new Map<string, LabelParagraphEntry>()
private fontGeneration = -1
measure(
ck: CanvasKit,
provider: TypefaceFontProvider,
text: string,
fontSize: number,
maxWidth: number,
color: Float32Array,
generation: number
): number {
return this.entry(ck, provider, text, fontSize, maxWidth, color, generation).width
}
draw(
ck: CanvasKit,
canvas: Canvas,
provider: TypefaceFontProvider,
text: string,
fontSize: number,
maxWidth: number,
color: Float32Array,
generation: number,
x: number,
y: number
): number {
const entry = this.entry(ck, provider, text, fontSize, maxWidth, color, generation)
canvas.drawParagraph(entry.paragraph, x, y)
return entry.width
}
clear(): void {
for (const entry of this.entries.values()) entry.paragraph.delete()
this.entries.clear()
}
size(): number {
return this.entries.size
}
private entry(
ck: CanvasKit,
provider: TypefaceFontProvider,
text: string,
fontSize: number,
maxWidth: number,
color: Float32Array,
generation: number
): LabelParagraphEntry {
if (generation !== this.fontGeneration) {
this.clear()
this.fontGeneration = generation
}
const boundedWidth = Math.max(1, maxWidth)
const key = `${fontSize}\0${boundedWidth}\0${Array.from(color).join(',')}\0${text}`
let entry = this.entries.get(key)
if (!entry) {
const style = new ck.ParagraphStyle({
maxLines: 1,
ellipsis: '…',
textStyle: { color, fontFamilies: ['Inter'], fontSize }
})
const builder = ck.ParagraphBuilder.MakeFromFontProvider(style, provider)
builder.addText(text)
const paragraph = builder.build()
builder.delete()
paragraph.layout(boundedWidth)
entry = { paragraph, width: Math.min(paragraph.getLongestLine(), boundedWidth) }
this.entries.set(key, entry)
this.evict()
} else {
this.entries.delete(key)
this.entries.set(key, entry)
}
return entry
}
private evict(): void {
while (this.entries.size > MAX_LABEL_PARAGRAPHS) {
const oldestKey = this.entries.keys().next().value
if (!oldestKey) return
this.entries.get(oldestKey)?.paragraph.delete()
this.entries.delete(oldestKey)
}
}
}

View file

@ -6,6 +6,7 @@ import { rotatedCorners } from '@open-pencil/scene-graph/geometry'
import type { SkiaRenderer, RenderOverlays } from '#core/canvas/renderer'
import {
LABEL_FONT_SIZE,
LABEL_OFFSET_Y,
SIZE_PILL_PADDING_X,
SIZE_PILL_PADDING_Y,
@ -14,8 +15,6 @@ import {
SIZE_PILL_TEXT_OFFSET_Y
} from '#core/constants'
import { ellipsizeLabelText } from './text'
function getOverlayRotation(node: SceneNode, overlays?: RenderOverlays): number {
return overlays?.rotationPreview?.nodeId === node.id
? overlays.rotationPreview.angle
@ -63,12 +62,12 @@ function drawSingleFrameTitle(
canvas: Canvas,
graph: SceneGraph,
node: SceneNode,
overlays: RenderOverlays,
labelFont: NonNullable<SkiaRenderer['labelFont']>
overlays: RenderOverlays
): void {
const parentNode = node.parentId ? graph.getNode(node.parentId) : null
const isTopLevel = !parentNode || parentNode.type === 'CANVAS' || parentNode.type === 'SECTION'
if (node.type !== 'FRAME' || !isTopLevel) return
const provider = r.fontProvider
if (node.type !== 'FRAME' || !isTopLevel || !provider) return
const overlayRotation = getOverlayRotation(node, overlays) // degrees
@ -78,13 +77,24 @@ function drawSingleFrameTitle(
r.auxFill.setColor(r.selColor())
const displayText = ellipsizeLabelText(labelFont, node.name, node.width * r.zoom)
if (!displayText) return
const maxTextWidth = node.width * r.zoom
if (maxTextWidth <= 0) return
canvas.save()
canvas.translate(origin[0] * r.zoom + r.panX, origin[1] * r.zoom + r.panY)
if (overlayRotation !== 0) canvas.rotate(overlayRotation, 0, 0)
canvas.drawText(displayText, 0, -LABEL_OFFSET_Y, r.auxFill, labelFont)
r.labelParagraphCache.draw(
r.ck,
canvas,
provider,
node.name,
LABEL_FONT_SIZE,
maxTextWidth,
r.selColor(),
r.fontGeneration,
0,
-LABEL_OFFSET_Y - LABEL_FONT_SIZE
)
canvas.restore()
}
@ -194,7 +204,7 @@ export function drawSelectionLabels(
if (nodes.length === 0) return
if (nodes.length === 1) {
drawSingleFrameTitle(r, canvas, graph, nodes[0], activeOverlays, labelFont)
drawSingleFrameTitle(r, canvas, graph, nodes[0], activeOverlays)
drawSingleSelectionSize(r, canvas, graph, nodes[0], activeOverlays, sizeFont)
return
}

View file

@ -22,6 +22,7 @@ import type { FontResolutionSnapshot } from '#core/text/resolver'
import { LabelCache } from './labels/cache'
import * as LabelHitTest from './labels/hit-test'
import { LabelParagraphCache } from './labels/paragraph-cache'
import * as RenderColors from './renderer/colors'
import * as RendererFonts from './renderer/fonts'
import { destroyRenderer } from './renderer/lifecycle'
@ -158,6 +159,7 @@ export class SkiaRenderer {
subtreePictureCachePositionPreviewVersion = -1
subtreePictureCacheFontGeneration = -1
readonly labelCache = new LabelCache()
readonly labelParagraphCache = new LabelParagraphCache()
readonly profiler: RenderProfiler
declare rulerBgPaint: Paint
@ -617,7 +619,7 @@ export class SkiaRenderer {
}
isNodeFontLoaded(node: SceneNode): boolean {
return this.nodeFontReadiness(node) === 'ready'
return RenderText.isNodeFontLoaded(this, node)
}
buildTextPicture(node: SceneNode): Uint8Array | null {

View file

@ -72,6 +72,7 @@ export function destroyRenderer(r: SkiaRenderer): void {
r.maskFilterCache.clear()
for (const pic of r.nodePictureCache.values()) pic?.delete()
r.nodePictureCache.clear()
r.labelParagraphCache.clear()
clearSubtreePictureCache(r)
clearRetainedSceneState(r)
r._flashPaint?.delete()

View file

@ -754,6 +754,19 @@ function shouldClipTextToLayoutBox(node: SceneNode): boolean {
)
}
function drawSubstitutedPathText(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
fontReadiness: ReturnType<SkiaRenderer['nodeFontReadiness']>
): boolean {
return (
fontReadiness === 'substituted' &&
node.textPathData !== null &&
drawDerivedText(r, canvas, node)
)
}
export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode, fill?: Fill): void {
const text = node.text
if (!text) return
@ -764,22 +777,28 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode, fil
}
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 (drawDerivedText(r, canvas, node)) {
if (fontReadiness === 'pending') {
canvas.restore()
return
}
if (drawSubstitutedPathText(r, canvas, node, fontReadiness)) {
canvas.restore()
return
}
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 (drawDerivedText(r, canvas, node)) {
canvas.restore()
return
}
canvas.restore()
return
}

View file

@ -76,7 +76,7 @@ function languageForCharacter(node: SceneNode, character: string): string | null
return run?.style.textLanguage ?? node.textLanguage
}
export type NodeFontReadiness = 'ready' | 'pending' | 'exhausted'
export type NodeFontReadiness = 'ready' | 'substituted' | 'pending' | 'exhausted'
function requiredFacesReadiness(r: FontReadinessRenderer, node: SceneNode): NodeFontReadiness {
let pending = false
@ -96,6 +96,7 @@ function requiredFacesReadiness(r: FontReadinessRenderer, node: SceneNode): Node
}
}
if (pending) return 'pending'
if (exhausted && fontManager.isStyleLoaded(DEFAULT_FONT_FAMILY, 'Regular')) return 'substituted'
return exhausted ? 'exhausted' : 'ready'
}
@ -173,7 +174,8 @@ export function nodeFontReadiness(r: FontReadinessRenderer, node: SceneNode): No
}
export function isNodeFontLoaded(r: FontReadinessRenderer, node: SceneNode): boolean {
return nodeFontReadiness(r, node) === 'ready'
const readiness = nodeFontReadiness(r, node)
return readiness === 'ready' || readiness === 'substituted'
}
export function measureTextNode(

View file

@ -39,7 +39,6 @@ function throwIfAborted(signal?: AbortSignal): void {
}
const MAX_CONCURRENT_FONT_LOADS = 4
export function createPageActions(ctx: EditorContext) {
const pageViewportStore = createPageViewportStore(ctx)
let populationWorkerInstance: ReturnType<typeof createFigPopulationWorker> | undefined

View file

@ -132,6 +132,10 @@ export class FontManager {
this.webFonts.setRemoteFetch(fetcher)
}
resetWebFontFailures(family?: string, style?: string): void {
this.webFonts.resetFailures(family, style)
}
enabledOnlineFontProviders(): WebFontProviderId[] {
return this.webFonts.enabledProviders()
}

View file

@ -89,6 +89,33 @@ function isArrayBuffer(value: ArrayBuffer | null): value is ArrayBuffer {
return value !== null
}
function waitForFontOperation<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return operation
signal.throwIfAborted()
return new Promise<T>((resolve, reject) => {
const abort = () => {
try {
signal.throwIfAborted()
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)))
}
}
signal.addEventListener('abort', abort, { once: true })
void operation.then(
(value) => {
signal.removeEventListener('abort', abort)
resolve(value)
return undefined
},
(error: unknown) => {
signal.removeEventListener('abort', abort)
reject(error instanceof Error ? error : new Error(String(error)))
return undefined
}
)
})
}
export interface ResolvedWebFont {
buffers: ArrayBuffer[]
provider: WebFontProviderId
@ -119,6 +146,15 @@ export class WebFontResolver {
this.failedFonts.clear()
}
resetFailures(family?: string, style?: string): void {
for (const key of this.failedFonts) {
const [, failedFamily, failedStyle] = key.split('|')
if (!family || (failedFamily === family && (!style || failedStyle === style))) {
this.failedFonts.delete(key)
}
}
}
enabledProviders(): WebFontProviderId[] {
return WEB_FONT_PROVIDER_IDS.filter((provider) => this.enabled.has(provider))
}
@ -161,7 +197,7 @@ export class WebFontResolver {
return null
}
private async withFetchProxy<T>(operation: () => Promise<T>): Promise<T> {
private async withFetchProxy<T>(operation: () => Promise<T>, signal?: AbortSignal): Promise<T> {
if (!this.remoteFetch) return operation()
const previous = this.fetchProxyQueue
@ -169,23 +205,32 @@ export class WebFontResolver {
this.fetchProxyQueue = new Promise<void>((resolve) => {
release = () => resolve()
})
await previous
try {
await waitForFontOperation(previous, signal)
signal?.throwIfAborted()
} catch (error) {
void previous.finally(() => release?.())
throw error
}
const originalFetch = globalThis.fetch
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const proxyFetch = (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' || input instanceof URL ? input.toString() : input.url
if (url.startsWith('https://') || url.startsWith('http://')) {
signal?.throwIfAborted()
return (
this.remoteFetch?.(url, init) ?? Promise.reject(new TypeError('No font proxy fetcher'))
this.remoteFetch?.(url, { ...init, signal: signal ?? init?.signal }) ??
Promise.reject(new TypeError('No font proxy fetcher'))
)
}
return originalFetch(input, init)
}) as typeof fetch
}
globalThis.fetch = proxyFetch as typeof fetch
try {
return await operation()
return await waitForFontOperation(operation(), signal)
} finally {
globalThis.fetch = originalFetch
if (globalThis.fetch === proxyFetch) globalThis.fetch = originalFetch
release?.()
}
}
@ -195,13 +240,13 @@ export class WebFontResolver {
return fetch(url, init)
}
private async unifont(provider: WebFontProviderId): Promise<WebUnifont> {
private async unifont(provider: WebFontProviderId, signal?: AbortSignal): Promise<WebUnifont> {
let promise = this.unifontPromises.get(provider)
if (!promise) {
promise = this.withFetchProxy(() => createProviderUnifont(provider))
this.unifontPromises.set(provider, promise)
}
return promise
return waitForFontOperation(promise, signal)
}
private async loadFamilies(provider: WebFontProviderId): Promise<string[]> {
@ -263,7 +308,7 @@ export class WebFontResolver {
try {
signal?.throwIfAborted()
const parsed = parseFontStyle(style)
const unifont = await this.unifont(provider)
const unifont = await this.unifont(provider, signal)
const options = {
weights: [String(parsed.weight)],
styles: [parsed.italic ? 'italic' : 'normal'],
@ -273,9 +318,11 @@ export class WebFontResolver {
? { options: { google: { experimental: { glyphs: [characters] } } } }
: {})
} satisfies WebFontResolveOptions
const result = await this.withFetchProxy<ResolveFontResult>(() =>
unifont.resolveFont(family, options)
const result = await this.withFetchProxy<ResolveFontResult>(
() => unifont.resolveFont(family, options),
signal
)
signal?.throwIfAborted()
const faces = resolvedRemoteFaces(result)
const buffers = await Promise.all(
faces.map(async ({ source, init }) => {

View file

@ -0,0 +1,46 @@
import type { FetchFunction } from '@/app/http/types'
const MAX_WEB_FONT_RESPONSE_BYTES = 8 * 1024 * 1024
const ALLOWED_WEB_FONT_HOSTS = new Set([
'api.fontsource.org',
'cdn.jsdelivr.net',
'fonts.googleapis.com',
'fonts.google.com',
'fonts.gstatic.com'
])
export function createBrowserWebFontFetch(
nativeFetch: typeof globalThis.fetch,
origin = typeof process === 'undefined' ? globalThis.location.origin : ''
): FetchFunction {
return async (input, init) => {
const request = new Request(input, init)
const url = new URL(request.url)
if (url.origin === origin) return nativeFetch(request)
if (url.protocol !== 'https:' || !ALLOWED_WEB_FONT_HOSTS.has(url.hostname)) {
throw new Error(`Unsupported web font host: ${url.hostname}`)
}
const response = await nativeFetch(request)
const contentLength = Number(response.headers.get('content-length') ?? 0)
if (contentLength > MAX_WEB_FONT_RESPONSE_BYTES) {
throw new Error('Web font response exceeds the size limit')
}
const bytes = await response.arrayBuffer()
if (bytes.byteLength > MAX_WEB_FONT_RESPONSE_BYTES) {
throw new Error('Web font response exceeds the size limit')
}
const bounded = new Response(bytes, {
status: response.status,
statusText: response.statusText,
headers: response.headers
})
Object.defineProperty(bounded, 'url', { value: response.url })
return bounded
}
}
export const browserWebFontFetch = createBrowserWebFontFetch(
globalThis.fetch.bind(globalThis),
typeof process === 'undefined' ? globalThis.location.origin : ''
)

View file

@ -12,14 +12,13 @@ import {
type WebFontProviderId
} from '@open-pencil/core/text'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { dialogMessages } from '@open-pencil/vue'
import { browserWebFontFetch } from '@/app/editor/fonts/browser-fetch'
import {
clearDownloadedFontCache as clearTauriDownloadedFontCache,
createTauriDownloadedFontCache,
downloadedFontCacheSummary as tauriDownloadedFontCacheSummary
} from '@/app/editor/fonts/cache'
import { toast } from '@/app/shell/ui'
import { isTauri } from '@/app/tauri/env'
import { tauriFetch } from '@/app/tauri/http'
import { IS_TAURI } from '@/constants'
@ -44,7 +43,7 @@ watch(
? Object.fromEntries(
WEB_FONT_PROVIDER_IDS.map((provider) => [
provider,
fontProviderSettings.value[provider]
fontProviderSettings.value[provider] && (isTauri() || provider !== 'google')
])
)
: {}
@ -54,14 +53,6 @@ watch(
)
let tauriFontCacheConfigured = false
let webFontUnavailableToastShown = false
function showWebFontUnavailableToast(): void {
if (webFontUnavailableToastShown || isTauri() || !onlineFontsEnabled.value) return
if (!WEB_FONT_PROVIDER_IDS.some((provider) => fontProviderSettings.value[provider])) return
webFontUnavailableToastShown = true
toast.warning(dialogMessages.get().webFontProvidersRequireDesktopApp)
}
function configureTauriFontCache() {
if (tauriFontCacheConfigured || !isTauri()) return
@ -72,6 +63,7 @@ function configureTauriFontCache() {
}
configureTauriFontCache()
if (!isTauri()) fontManager.setWebFontFetch(browserWebFontFetch)
interface TauriFontFamily {
family: string
@ -150,7 +142,6 @@ export async function listFamilies(): Promise<FontFamilyOption[]> {
byFamily.set(font.family, { family: font.family, source: 'local' })
return [...byFamily.values()].sort((a, b) => a.family.localeCompare(b.family))
}
showWebFontUnavailableToast()
return fontManager.listFamilyOptions()
}
@ -225,7 +216,5 @@ export async function loadFont(
signal?: AbortSignal
): Promise<ArrayBuffer | null> {
configureTauriFontCache()
const loaded = await fontManager.loadFont(family, style, characters, signal)
if (!loaded) showWebFontUnavailableToast()
return loaded
return fontManager.loadFont(family, style, characters, signal)
}

View file

@ -40,6 +40,7 @@ export function useDocumentFontStatus() {
const issues = status.value.issues
await Promise.all(
issues.map(async ({ family, style }) => {
fontManager.resetWebFontFailures(family, style)
fontResolver.reset(
`face:${family.trim().toLocaleLowerCase()}:${style.toLocaleLowerCase()}`
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View file

@ -0,0 +1,84 @@
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers')
async function expectCanvas(name: string): Promise<void> {
await editor.canvas.waitForRender()
editor.canvas.assertNoErrors()
expect(await editor.canvas.screenshotCanvasRegion()).toMatchSnapshot(`${name}.png`)
}
test('canvas labels use shaped Inter text, ellipsis, and fixed-size zoom presentation', async () => {
await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const pageId = store.state.currentPageId
store.graph.createNode('FRAME', pageId, {
name: 'AVATAR To',
x: 110,
y: 130,
width: 280,
height: 110,
fills: [{ type: 'SOLID', color: { r: 0.95, g: 0.97, b: 1, a: 1 }, visible: true, opacity: 1 }]
})
const rotated = store.graph.createNode('FRAME', pageId, {
name: 'Rotated Typography',
x: 470,
y: 140,
width: 250,
height: 100,
rotation: 16,
fills: [
{ type: 'SOLID', color: { r: 0.93, g: 0.95, b: 0.99, a: 1 }, visible: true, opacity: 1 }
]
})
const section = store.graph.createNode('SECTION', pageId, {
name: 'A very long section title that must ellipsize correctly',
x: 100,
y: 330,
width: 300,
height: 170,
fills: [
{ type: 'SOLID', color: { r: 0.96, g: 0.78, b: 0.2, a: 1 }, visible: true, opacity: 1 }
]
})
const set = store.graph.createNode('COMPONENT_SET', pageId, {
name: 'Button collection',
x: 470,
y: 350,
width: 280,
height: 140,
fills: [],
strokes: []
})
store.graph.createNode('COMPONENT', set.id, {
name: 'Primary AVATAR To',
x: 24,
y: 54,
width: 190,
height: 48,
cornerRadius: 10,
fills: [
{ type: 'SOLID', color: { r: 0.38, g: 0.2, b: 0.92, a: 1 }, visible: true, opacity: 1 }
]
})
store.state.zoom = 1.25
store.state.panX = 40
store.state.panY = 20
store.select([rotated.id])
store.requestRender()
void section
})
await expectCanvas('shaped-labels-zoom-125')
await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
store.state.zoom = 2
store.state.panX = -90
store.state.panY = -180
store.clearSelection()
store.requestRender()
})
await expectCanvas('shaped-labels-zoom-200')
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

After

Width:  |  Height:  |  Size: 350 KiB

View file

@ -0,0 +1,33 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
const editor = useEditorSetup('/?test&no-chrome&no-rulers')
test('loads document font families from the browser provider before rendering', async () => {
test.setTimeout(60_000)
const result = await editor.page.evaluate(async () => {
const { fontManager } = await import('/packages/core/src/text/index.ts')
fontManager.setOnlineFontProviders({
google: false,
fontsource: true,
bunny: false,
fontshare: false
})
return Promise.all(
['Geist', 'Geist Mono', 'Roboto Mono'].map(async (family) => {
const data = await fontManager.loadRemoteFont(family, 'Regular', 'Browser font')
return {
family,
bytes: data?.byteLength ?? 0,
source: fontManager.loadedFontSource(family, 'Regular')
}
})
)
})
expect(result).toEqual([
{ family: 'Geist', bytes: expect.any(Number), source: 'fontsource' },
{ family: 'Geist Mono', bytes: expect.any(Number), source: 'fontsource' },
{ family: 'Roboto Mono', bytes: expect.any(Number), source: 'fontsource' }
])
for (const face of result) expect(face.bytes).toBeGreaterThan(1_000)
})

View file

@ -21,6 +21,10 @@ async function openFontPicker(page: Page) {
await page.getByTestId('font-picker-trigger').click()
}
async function searchFonts(page: Page, query: string) {
await page.getByRole('combobox', { name: 'Search fonts…' }).fill(query)
}
async function installGoogleFontsMock(page: Page, families = ['Inter', 'OpenPencil Google Font']) {
await page.addInitScript((googleFamilies) => {
const win = window as Window & {
@ -88,6 +92,7 @@ test('font picker selects local fonts without browser web-font access', async ({
const textId = await openTypographyForText(page)
await openFontPicker(page)
await searchFonts(page, 'OpenPencil Local Font')
await expect(
page.getByTestId('font-picker-item').filter({ hasText: 'OpenPencil Local Font' })
@ -121,8 +126,11 @@ test('font picker keeps bundled fonts when local and web fonts are unavailable',
await openTypographyForText(page)
await openFontPicker(page)
await searchFonts(page, 'Inter')
await expect(page.getByTestId('font-picker-item').filter({ hasText: 'Inter' })).toBeVisible()
await expect(
page.getByTestId('font-picker-item').filter({ hasText: /^Interbundled$/ })
).toBeVisible()
await expect(
page.getByTestId('font-picker-item').filter({ hasText: 'OpenPencil Google Font' })
).toHaveCount(0)
@ -146,8 +154,11 @@ test('font picker keeps bundled fonts when local font permission is rejected', a
await openTypographyForText(page)
await openFontPicker(page)
await searchFonts(page, 'Inter')
await expect(page.getByTestId('font-picker-item').filter({ hasText: 'Inter' })).toBeVisible()
await expect(
page.getByTestId('font-picker-item').filter({ hasText: /^Interbundled$/ })
).toBeVisible()
await expect(
page.getByTestId('font-picker-item').filter({ hasText: 'OpenPencil Google Font' })
).toHaveCount(0)
@ -168,6 +179,9 @@ test('font picker keeps bundled Inter available when local and Google fonts are
await openTypographyForText(page)
await openFontPicker(page)
await searchFonts(page, 'Inter')
await expect(page.getByTestId('font-picker-item').filter({ hasText: 'Inter' })).toBeVisible()
await expect(
page.getByTestId('font-picker-item').filter({ hasText: /^Interbundled$/ })
).toBeVisible()
})

View file

@ -0,0 +1,85 @@
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers')
async function expectCanvas(name: string): Promise<void> {
await editor.canvas.waitForRender()
editor.canvas.assertNoErrors()
expect(await editor.canvas.screenshotCanvasRegion()).toMatchSnapshot(`${name}.png`)
}
test('first edit replaces baked missing-font glyphs with visible live substitution', async () => {
const textId = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store?.renderer) throw new Error('OpenPencil renderer not initialized')
const pageId = store.state.currentPageId
const node = store.graph.createNode('TEXT', pageId, {
name: 'Missing font text',
x: 120,
y: 150,
width: 520,
height: 90,
text: 'Missing font stays visible',
fontFamily: 'Inter',
fontSize: 40,
fontWeight: 400,
textAutoResize: 'NONE',
fills: [
{ type: 'SOLID', color: { r: 0.06, g: 0.08, b: 0.14, a: 1 }, visible: true, opacity: 1 }
]
})
const baked = store.renderer.buildTextPicture(node)
if (!baked) throw new Error('Could not build baked text fixture')
node.fontFamily = 'Unavailable Visual Test Face'
const fontModuleURL = performance
.getEntriesByType('resource')
.map((entry) => entry.name)
.find((url) => url.includes('/packages/core/src/text/resolver/index.ts'))
if (!fontModuleURL) throw new Error('Active font resolver module not found')
void import(/* @vite-ignore */ fontModuleURL).then(({ fontFaceDemand, fontResolver }) => {
fontResolver.exhaust(fontFaceDemand(node.fontFamily, 'Regular', node.text))
store.requestRender()
return node.id
})
node.textPicture = baked
store.select([node.id])
store.requestRender()
return node.id
})
await editor.page.waitForFunction((id) => {
const store = window.openPencil?.getStore?.()
const node = store?.graph.getNode(id)
return node ? store?.renderer?.nodeFontReadiness(node) === 'substituted' : false
}, textId)
await expectCanvas('missing-font-baked-before-edit')
await editor.page.keyboard.press('Enter')
const textarea = editor.page.locator('textarea[aria-hidden="true"]')
await textarea.fill('!')
await textarea.dispatchEvent('input')
await expectCanvas('missing-font-live-substitution-after-first-edit')
const edited = await editor.page.evaluate((id) => {
const store = window.openPencil?.getStore?.()
const node = store?.graph.getNode(id)
return node?.type === 'TEXT'
? {
text: node.text,
textPicture: node.textPicture,
derivedTextGlyphs: node.derivedTextGlyphs,
readiness: store?.renderer?.nodeFontReadiness(node)
}
: null
}, textId)
expect(edited).toEqual({
text: '!',
textPicture: null,
derivedTextGlyphs: null,
readiness: 'substituted'
})
await editor.page.keyboard.press('Escape')
await editor.canvas.undo()
await expectCanvas('missing-font-substitution-after-undo')
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -0,0 +1,80 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
import { browserWebFontFetch, createBrowserWebFontFetch } from '@/app/editor/fonts/browser-fetch'
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
describe('browser web font fetch', () => {
test('rejects unapproved and non-HTTPS provider URLs', async () => {
await expect(browserWebFontFetch('https://example.com/font.ttf')).rejects.toThrow(
'Unsupported web font host'
)
await expect(browserWebFontFetch('http://fonts.gstatic.com/font.ttf')).rejects.toThrow(
'Unsupported web font host'
)
})
test('allows same-origin application resources through the temporary font proxy', async () => {
const fontFetch = createBrowserWebFontFetch(
mock(
async () =>
new Response(new Uint8Array([1]), {
headers: { 'content-length': String(36 * 1024 * 1024) }
})
) as typeof fetch,
'http://127.0.0.1:4301'
)
await expect(fontFetch('http://127.0.0.1:4301/gold-preview.fig')).resolves.toBeInstanceOf(
Response
)
})
test('returns bounded responses from approved provider hosts', async () => {
const fontFetch = createBrowserWebFontFetch(
mock(
async () =>
new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { 'content-type': 'font/ttf' }
})
) as typeof fetch
)
const response = await fontFetch('https://fonts.gstatic.com/font.ttf')
expect(response.status).toBe(200)
expect([...new Uint8Array(await response.arrayBuffer())]).toEqual([1, 2, 3])
})
test('passes cancellation through browser font downloads', async () => {
let observedSignal: AbortSignal | null = null
const fontFetch = createBrowserWebFontFetch(
mock(async (request: Request) => {
observedSignal = request.signal
return new Response(new Uint8Array([1, 2, 3]))
}) as typeof fetch
)
const abort = new AbortController()
await fontFetch('https://fonts.gstatic.com/font.ttf', { signal: abort.signal })
abort.abort()
expect(observedSignal?.aborted).toBe(true)
})
test('rejects provider responses over the font size limit', async () => {
const fontFetch = createBrowserWebFontFetch(
mock(
async () =>
new Response(null, {
status: 200,
headers: { 'content-length': String(9 * 1024 * 1024) }
})
) as typeof fetch
)
await expect(fontFetch('https://cdn.jsdelivr.net/font.ttf')).rejects.toThrow('size limit')
})
})

View file

@ -0,0 +1,38 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'
import { initCanvasKit } from '#cli/headless'
import { LabelParagraphCache } from '#core/canvas/labels/paragraph-cache'
import { repoPath } from '#tests/helpers/paths'
let ck: Awaited<ReturnType<typeof initCanvasKit>>
beforeAll(async () => {
ck = await initCanvasKit()
})
describe('label paragraph cache', () => {
test('shapes labels with Inter and invalidates native paragraphs by font generation', () => {
const bytes = readFileSync(repoPath('public/Inter-Regular.ttf'))
const data = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
const provider = ck.TypefaceFontProvider.Make()
provider.registerFont(data, 'Inter')
const cache = new LabelParagraphCache()
try {
const width = cache.measure(ck, provider, 'Primitives', 12, 200, ck.BLACK, 1)
expect(width).toBeGreaterThan(40)
expect(width).toBeLessThan(80)
expect(cache.size()).toBe(1)
cache.measure(ck, provider, 'Primitives', 12, 200, ck.BLACK, 1)
expect(cache.size()).toBe(1)
cache.measure(ck, provider, 'Primitives', 12, 200, ck.BLACK, 2)
expect(cache.size()).toBe(1)
} finally {
cache.clear()
provider.delete()
}
expect(cache.size()).toBe(0)
})
})

View file

@ -50,6 +50,7 @@ function createRenderer() {
imageFilterCache: new Map(),
maskFilterCache: new Map(),
nodePictureCache: new Map(),
labelParagraphCache: { clear: mock() } as SkiaRenderer['labelParagraphCache'],
subtreePictureCache: new Map(),
scenePicture: null,
sceneBacking: null,

View file

@ -31,7 +31,9 @@ function createMockCanvas() {
saveLayer: mock(() => undefined),
restore: mock(() => undefined),
clipRect: mock(() => undefined),
translate: mock(() => undefined)
translate: mock(() => undefined),
scale: mock(() => undefined),
rotate: mock(() => undefined)
}
}
@ -63,8 +65,12 @@ function createMockRenderer(overrides: Partial<Record<string, unknown>> = {}) {
cubicTo = mock(() => this)
quadTo = mock(() => this)
close = mock(() => this)
setFillType = mock(() => this)
delete = mock(() => undefined)
detachAndDelete = mock(() => ({ delete: mock(() => undefined) }))
detachAndDelete = mock(() => ({
delete: mock(() => undefined),
setFillType: mock(() => undefined)
}))
},
LTRBRect: mock((...args: number[]) => args),
Color4f: mock((...args: number[]) => new Float32Array(args)),
@ -208,6 +214,52 @@ describe('renderText', () => {
expect(r.buildParagraph).toHaveBeenCalledTimes(1)
})
test('renders a live paragraph for finalized default-family substitution', () => {
const r = createMockRenderer({ nodeFontReadiness: mock(() => 'substituted') })
const canvas = createMockCanvas()
const node = textNode({
fontFamily: 'Geist',
text: 'Edited text',
textPicture: null,
derivedTextGlyphs: null
})
renderText(r, canvas as never, node)
expect(r.buildParagraph).toHaveBeenCalledTimes(1)
expect(canvas.drawParagraph).toHaveBeenCalledTimes(1)
})
test('keeps derived path-text glyphs when its face is finalized as substituted', () => {
const base = createMockRenderer()
const r = createMockRenderer({
nodeFontReadiness: mock(() => 'substituted'),
ck: { ...base.ck, FillType: { EvenOdd: 0, Winding: 1 } }
})
const canvas = createMockCanvas()
const node = textNode({
fontFamily: 'Missing Path Font',
textPathData: {
network: { vertices: [], segments: [], regions: [] },
normalizedSize: { x: 100, y: 20 },
tValue: 0,
forward: true
},
derivedTextGlyphs: [
{
commandsBlob: new Uint8Array(),
x: 0,
y: 0,
rotation: 0,
fontSize: 12
}
]
})
renderText(r, canvas as never, node)
expect(r.buildParagraph).not.toHaveBeenCalled()
})
test('uses baked text pictures after font resolution is exhausted', () => {
const r = createMockRenderer({ nodeFontReadiness: mock(() => 'exhausted') })
const canvas = createMockCanvas()

View file

@ -1,6 +1,10 @@
import { describe, expect, test } from 'bun:test'
import { normalizedCoverageText, webFontSubsetsForText } from '@open-pencil/core/text'
import {
normalizedCoverageText,
WebFontResolver,
webFontSubsetsForText
} from '@open-pencil/core/text'
describe('web font coverage requests', () => {
test('normalizes coverage without splitting supplementary code points', () => {
@ -8,6 +12,58 @@ describe('web font coverage requests', () => {
expect(Array.from(normalizedCoverageText('𠀀'))).toEqual(['𠀀'])
})
test('aborts a font load queued behind an active provider request', async () => {
const resolver = new WebFontResolver()
resolver.setEnabled({ google: true })
let requestStarted: (() => void) | null = null
let releaseRequest: (() => void) | null = null
const started = new Promise<void>((resolve) => {
requestStarted = resolve
})
const blocked = new Promise<Response>((resolve) => {
releaseRequest = () => resolve(new Response('{}', { status: 200 }))
})
resolver.setRemoteFetch(async () => {
requestStarted?.()
return blocked
})
const first = resolver.listFamilies('google')
await started
const abort = new AbortController()
const queued = resolver.fetchFont(['Inter'], 'Regular', '', abort.signal)
abort.abort()
await expect(queued).rejects.toHaveProperty('name', 'AbortError')
releaseRequest?.()
await first
})
test('aborts promptly while provider resolution is pending', async () => {
const resolver = new WebFontResolver()
resolver.setEnabled({ google: true })
let providerRequestStarted: (() => void) | null = null
let releaseProviderRequest: (() => void) | null = null
const started = new Promise<void>((resolve) => {
providerRequestStarted = resolve
})
const blocked = new Promise<Response>((resolve) => {
releaseProviderRequest = () => resolve(new Response('{}', { status: 200 }))
})
resolver.setRemoteFetch(async () => {
providerRequestStarted?.()
return blocked
})
const abort = new AbortController()
const loading = resolver.fetchFont(['Inter'], 'Regular', '', abort.signal)
await started
abort.abort()
await expect(loading).rejects.toHaveProperty('name', 'AbortError')
releaseProviderRequest?.()
})
test('requests script-specific subsets instead of Latin only', () => {
expect(webFontSubsetsForText('مرحبا')).toContain('arabic')
expect(webFontSubsetsForText('한글')).toContain('korean')

View file

@ -41,6 +41,10 @@ export class CanvasHelper {
await this.waitForRender()
}
async screenshotCanvas() {
return this.canvas.screenshot()
}
async screenshotCanvasRegion(width = 900, height = 700) {
const box = await this.canvas.boundingBox()
if (!box) throw new Error('Canvas has no bounding box — is it visible?')