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
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"ignoreUnresolved": [
|
||||
"~icons/*",
|
||||
"/src/app/editor/fonts/index.ts",
|
||||
"/packages/core/src/text/index.ts",
|
||||
"/src/app/tabs/index.ts",
|
||||
"#core/*"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
97
packages/core/src/canvas/labels/paragraph-cache.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
46
src/app/editor/fonts/browser-fetch.ts
Normal 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 : ''
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()}`
|
||||
)
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 8 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 7 KiB After Width: | Height: | Size: 6.6 KiB |
84
tests/e2e/canvas/labels/shaping-visual.spec.ts
Normal 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')
|
||||
})
|
||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 350 KiB |
33
tests/e2e/fonts/browser-provider.spec.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
85
tests/e2e/fonts/substitution/editing-visual.spec.ts
Normal 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')
|
||||
})
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
80
tests/engine/app/editor/fonts/browser-fetch.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
38
tests/engine/render/canvas/labels/paragraph-cache.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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?')
|
||||
|
|
|
|||