diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c276a4f3..5cf688449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/knip.json b/knip.json index 809f7da63..5b1c329db 100644 --- a/knip.json +++ b/knip.json @@ -9,6 +9,7 @@ "ignoreUnresolved": [ "~icons/*", "/src/app/editor/fonts/index.ts", + "/packages/core/src/text/index.ts", "/src/app/tabs/index.ts", "#core/*" ], diff --git a/packages/core/src/canvas/labels/draw.ts b/packages/core/src/canvas/labels/draw.ts index 6f4d52f07..5673381c0 100644 --- a/packages/core/src/canvas/labels/draw.ts +++ b/packages/core/src/canvas/labels/draw.ts @@ -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, 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 + ) } } diff --git a/packages/core/src/canvas/labels/paragraph-cache.ts b/packages/core/src/canvas/labels/paragraph-cache.ts new file mode 100644 index 000000000..aaf94addb --- /dev/null +++ b/packages/core/src/canvas/labels/paragraph-cache.ts @@ -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() + 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) + } + } +} diff --git a/packages/core/src/canvas/labels/selection.ts b/packages/core/src/canvas/labels/selection.ts index a3dee423b..1605452fc 100644 --- a/packages/core/src/canvas/labels/selection.ts +++ b/packages/core/src/canvas/labels/selection.ts @@ -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 + 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 } diff --git a/packages/core/src/canvas/renderer.ts b/packages/core/src/canvas/renderer.ts index 74824491b..855b26950 100644 --- a/packages/core/src/canvas/renderer.ts +++ b/packages/core/src/canvas/renderer.ts @@ -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 { diff --git a/packages/core/src/canvas/renderer/lifecycle.ts b/packages/core/src/canvas/renderer/lifecycle.ts index 2b6ff7c1d..373e00b49 100644 --- a/packages/core/src/canvas/renderer/lifecycle.ts +++ b/packages/core/src/canvas/renderer/lifecycle.ts @@ -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() diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index 652776420..78c0911d0 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -754,6 +754,19 @@ function shouldClipTextToLayoutBox(node: SceneNode): boolean { ) } +function drawSubstitutedPathText( + r: SkiaRenderer, + canvas: Canvas, + node: SceneNode, + fontReadiness: ReturnType +): 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 } diff --git a/packages/core/src/canvas/text/index.ts b/packages/core/src/canvas/text/index.ts index a606aaf99..554622b20 100644 --- a/packages/core/src/canvas/text/index.ts +++ b/packages/core/src/canvas/text/index.ts @@ -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( diff --git a/packages/core/src/editor/pages.ts b/packages/core/src/editor/pages.ts index 2208836bb..1fb1e8e41 100644 --- a/packages/core/src/editor/pages.ts +++ b/packages/core/src/editor/pages.ts @@ -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 | undefined diff --git a/packages/core/src/text/fonts.ts b/packages/core/src/text/fonts.ts index 5f33c73bf..622579aee 100644 --- a/packages/core/src/text/fonts.ts +++ b/packages/core/src/text/fonts.ts @@ -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() } diff --git a/packages/core/src/text/web-fonts.ts b/packages/core/src/text/web-fonts.ts index 4d946e887..1be0127fd 100644 --- a/packages/core/src/text/web-fonts.ts +++ b/packages/core/src/text/web-fonts.ts @@ -89,6 +89,33 @@ function isArrayBuffer(value: ArrayBuffer | null): value is ArrayBuffer { return value !== null } +function waitForFontOperation(operation: Promise, signal?: AbortSignal): Promise { + if (!signal) return operation + signal.throwIfAborted() + return new Promise((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(operation: () => Promise): Promise { + private async withFetchProxy(operation: () => Promise, signal?: AbortSignal): Promise { if (!this.remoteFetch) return operation() const previous = this.fetchProxyQueue @@ -169,23 +205,32 @@ export class WebFontResolver { this.fetchProxyQueue = new Promise((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 { + private async unifont(provider: WebFontProviderId, signal?: AbortSignal): Promise { 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 { @@ -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(() => - unifont.resolveFont(family, options) + const result = await this.withFetchProxy( + () => unifont.resolveFont(family, options), + signal ) + signal?.throwIfAborted() const faces = resolvedRemoteFaces(result) const buffers = await Promise.all( faces.map(async ({ source, init }) => { diff --git a/src/app/editor/fonts/browser-fetch.ts b/src/app/editor/fonts/browser-fetch.ts new file mode 100644 index 000000000..c57f18dd4 --- /dev/null +++ b/src/app/editor/fonts/browser-fetch.ts @@ -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 : '' +) diff --git a/src/app/editor/fonts/index.ts b/src/app/editor/fonts/index.ts index 1266dbab3..1c7382146 100644 --- a/src/app/editor/fonts/index.ts +++ b/src/app/editor/fonts/index.ts @@ -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 { 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 { configureTauriFontCache() - const loaded = await fontManager.loadFont(family, style, characters, signal) - if (!loaded) showWebFontUnavailableToast() - return loaded + return fontManager.loadFont(family, style, characters, signal) } diff --git a/src/app/editor/fonts/status.ts b/src/app/editor/fonts/status.ts index b50501db3..abcda85d3 100644 --- a/src/app/editor/fonts/status.ts +++ b/src/app/editor/fonts/status.ts @@ -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()}` ) diff --git a/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-containment-openpencil-darwin.png b/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-containment-openpencil-darwin.png index 560c5b39d..7379a3094 100644 Binary files a/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-containment-openpencil-darwin.png and b/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-containment-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-multi-rotation-openpencil-darwin.png b/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-multi-rotation-openpencil-darwin.png index c5eb01d5a..a28d49087 100644 Binary files a/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-multi-rotation-openpencil-darwin.png and b/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-multi-rotation-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-overlap-openpencil-darwin.png b/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-overlap-openpencil-darwin.png index a1701be82..cd71c593c 100644 Binary files a/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-overlap-openpencil-darwin.png and b/tests/e2e/canvas/distance-measurement-edge-cases.spec.ts-snapshots/distance-overlap-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/distance-measurement.spec.ts-snapshots/distance-measurement-openpencil-darwin.png b/tests/e2e/canvas/distance-measurement.spec.ts-snapshots/distance-measurement-openpencil-darwin.png index fd7a7bfbe..6c1193758 100644 Binary files a/tests/e2e/canvas/distance-measurement.spec.ts-snapshots/distance-measurement-openpencil-darwin.png and b/tests/e2e/canvas/distance-measurement.spec.ts-snapshots/distance-measurement-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/labels/shaping-visual.spec.ts b/tests/e2e/canvas/labels/shaping-visual.spec.ts new file mode 100644 index 000000000..22ebf25c4 --- /dev/null +++ b/tests/e2e/canvas/labels/shaping-visual.spec.ts @@ -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 { + 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') +}) diff --git a/tests/e2e/canvas/labels/shaping-visual.spec.ts-snapshots/shaped-labels-zoom-125-openpencil-darwin.png b/tests/e2e/canvas/labels/shaping-visual.spec.ts-snapshots/shaped-labels-zoom-125-openpencil-darwin.png new file mode 100644 index 000000000..23156b532 Binary files /dev/null and b/tests/e2e/canvas/labels/shaping-visual.spec.ts-snapshots/shaped-labels-zoom-125-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/labels/shaping-visual.spec.ts-snapshots/shaped-labels-zoom-200-openpencil-darwin.png b/tests/e2e/canvas/labels/shaping-visual.spec.ts-snapshots/shaped-labels-zoom-200-openpencil-darwin.png new file mode 100644 index 000000000..81780b0ed Binary files /dev/null and b/tests/e2e/canvas/labels/shaping-visual.spec.ts-snapshots/shaped-labels-zoom-200-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/text-path-visual.spec.ts-snapshots/text-path-rendering-and-selection-openpencil-darwin.png b/tests/e2e/canvas/text-path-visual.spec.ts-snapshots/text-path-rendering-and-selection-openpencil-darwin.png index a34484805..5e8b5d15a 100644 Binary files a/tests/e2e/canvas/text-path-visual.spec.ts-snapshots/text-path-rendering-and-selection-openpencil-darwin.png and b/tests/e2e/canvas/text-path-visual.spec.ts-snapshots/text-path-rendering-and-selection-openpencil-darwin.png differ diff --git a/tests/e2e/fonts/browser-provider.spec.ts b/tests/e2e/fonts/browser-provider.spec.ts new file mode 100644 index 000000000..a1f87c800 --- /dev/null +++ b/tests/e2e/fonts/browser-provider.spec.ts @@ -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) +}) diff --git a/tests/e2e/fonts/picker.spec.ts b/tests/e2e/fonts/picker.spec.ts index ab5d27678..5377cea2d 100644 --- a/tests/e2e/fonts/picker.spec.ts +++ b/tests/e2e/fonts/picker.spec.ts @@ -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() }) diff --git a/tests/e2e/fonts/substitution/editing-visual.spec.ts b/tests/e2e/fonts/substitution/editing-visual.spec.ts new file mode 100644 index 000000000..6aa147f6b --- /dev/null +++ b/tests/e2e/fonts/substitution/editing-visual.spec.ts @@ -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 { + 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') +}) diff --git a/tests/e2e/fonts/substitution/editing-visual.spec.ts-snapshots/missing-font-baked-before-edit-openpencil-darwin.png b/tests/e2e/fonts/substitution/editing-visual.spec.ts-snapshots/missing-font-baked-before-edit-openpencil-darwin.png new file mode 100644 index 000000000..f7509aa6a Binary files /dev/null and b/tests/e2e/fonts/substitution/editing-visual.spec.ts-snapshots/missing-font-baked-before-edit-openpencil-darwin.png differ diff --git a/tests/e2e/fonts/substitution/editing-visual.spec.ts-snapshots/missing-font-live-substitution-after-first-edit-openpencil-darwin.png b/tests/e2e/fonts/substitution/editing-visual.spec.ts-snapshots/missing-font-live-substitution-after-first-edit-openpencil-darwin.png new file mode 100644 index 000000000..12ad916f1 Binary files /dev/null and b/tests/e2e/fonts/substitution/editing-visual.spec.ts-snapshots/missing-font-live-substitution-after-first-edit-openpencil-darwin.png differ diff --git a/tests/e2e/fonts/substitution/editing-visual.spec.ts-snapshots/missing-font-substitution-after-undo-openpencil-darwin.png b/tests/e2e/fonts/substitution/editing-visual.spec.ts-snapshots/missing-font-substitution-after-undo-openpencil-darwin.png new file mode 100644 index 000000000..28d52fb59 Binary files /dev/null and b/tests/e2e/fonts/substitution/editing-visual.spec.ts-snapshots/missing-font-substitution-after-undo-openpencil-darwin.png differ diff --git a/tests/e2e/text/formatting.spec.ts-snapshots/typography-panel-openpencil-darwin.png b/tests/e2e/text/formatting.spec.ts-snapshots/typography-panel-openpencil-darwin.png index a3d604fd8..1bfb081ee 100644 Binary files a/tests/e2e/text/formatting.spec.ts-snapshots/typography-panel-openpencil-darwin.png and b/tests/e2e/text/formatting.spec.ts-snapshots/typography-panel-openpencil-darwin.png differ diff --git a/tests/engine/app/editor/fonts/browser-fetch.test.ts b/tests/engine/app/editor/fonts/browser-fetch.test.ts new file mode 100644 index 000000000..04ae673c4 --- /dev/null +++ b/tests/engine/app/editor/fonts/browser-fetch.test.ts @@ -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') + }) +}) diff --git a/tests/engine/render/canvas/labels/paragraph-cache.test.ts b/tests/engine/render/canvas/labels/paragraph-cache.test.ts new file mode 100644 index 000000000..f84d85c7a --- /dev/null +++ b/tests/engine/render/canvas/labels/paragraph-cache.test.ts @@ -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> + +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) + }) +}) diff --git a/tests/engine/render/canvas/renderer-lifecycle.test.ts b/tests/engine/render/canvas/renderer-lifecycle.test.ts index 24e003269..63d7cd416 100644 --- a/tests/engine/render/canvas/renderer-lifecycle.test.ts +++ b/tests/engine/render/canvas/renderer-lifecycle.test.ts @@ -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, diff --git a/tests/engine/render/canvas/text.test.ts b/tests/engine/render/canvas/text.test.ts index 9cfb79686..13d21b659 100644 --- a/tests/engine/render/canvas/text.test.ts +++ b/tests/engine/render/canvas/text.test.ts @@ -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> = {}) { 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() diff --git a/tests/engine/text/fonts/web-fonts.test.ts b/tests/engine/text/fonts/web-fonts.test.ts index d2dc37029..11cba2fe2 100644 --- a/tests/engine/text/fonts/web-fonts.test.ts +++ b/tests/engine/text/fonts/web-fonts.test.ts @@ -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((resolve) => { + requestStarted = resolve + }) + const blocked = new Promise((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((resolve) => { + providerRequestStarted = resolve + }) + const blocked = new Promise((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') diff --git a/tests/helpers/canvas.ts b/tests/helpers/canvas.ts index 4e6ebffe9..995603c95 100644 --- a/tests/helpers/canvas.ts +++ b/tests/helpers/canvas.ts @@ -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?')