perf(canvas): reuse labels with shared font fallback
Acquire section paragraphs once, reuse proven fitting layouts, and bound retained text with borrowed native-resource lifetimes. Share document text family selection and Arabic/CJK coverage resolution rather than preserving missing glyphs. Validate exact zoom parity with real existing font fixtures.
This commit is contained in:
parent
11baf4f8f5
commit
b458e3c3ae
|
|
@ -50,7 +50,7 @@
|
|||
- Match page-list density to the layer tree and add subtle, reduced-motion-aware dialog transitions.
|
||||
- Fade in streaming Markdown list items and code lines without animating completed responses.
|
||||
- Vertically center shaped section titles and allow renaming a section by double-clicking its canvas label.
|
||||
- Load supported online fonts before revealing imported pages, preserve substituted text during editing, and shape canvas labels with bundled Inter typography.
|
||||
- Load supported online fonts before revealing imported pages, preserve substituted text during editing, and shape canvas labels with Inter typography and shared Arabic/CJK font fallback.
|
||||
- Upgrade CanvasKit to 0.41 and use immutable renderer paths 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.
|
||||
|
|
@ -129,7 +129,7 @@
|
|||
### Performance
|
||||
|
||||
- Reduce pauses after repeated frame creation without leaving hidden property edits or popups active.
|
||||
- Reduce repeated text shaping and scene invalidation while moving and resizing objects.
|
||||
- Reduce repeated text shaping and scene invalidation while moving and resizing objects, and reuse fitting canvas labels during zoom.
|
||||
- Scope automation and Figma API layout reconciliation to graph nodes and parent containers actually changed by each mutation.
|
||||
- Keep rapid trackpad zoom reversals and effect-heavy document navigation responsive by cancelling obsolete reconstruction and reusing safe raster snapshots.
|
||||
- Show the FIG page list from a lightweight Kiwi scan before materializing the full document.
|
||||
|
|
|
|||
|
|
@ -35,54 +35,47 @@ function drawSectionTitle(
|
|||
nested: boolean,
|
||||
overlays?: RenderOverlays
|
||||
): void {
|
||||
let layout = labelLayout('section', node.width * r.zoom, nested)
|
||||
if (!layout) return
|
||||
const initialLayout = labelLayout('section', node.width * r.zoom, nested)
|
||||
if (!initialLayout) return
|
||||
const { background, foreground, border, hover } = sectionLabelColors(r, graph, node)
|
||||
const metrics = r.labelParagraphCache.measure(
|
||||
r.labelParagraphCache.use(
|
||||
r.ck,
|
||||
provider,
|
||||
node.name,
|
||||
layout.fontSize,
|
||||
layout.maxTextWidth,
|
||||
initialLayout.fontSize,
|
||||
initialLayout.maxTextWidth,
|
||||
foreground,
|
||||
r.fontGeneration,
|
||||
layout.fontWeight
|
||||
({ paragraph, metrics }) => {
|
||||
const layout = labelLayout('section', node.width * r.zoom, nested, metrics)
|
||||
if (!layout) return
|
||||
canvas.save()
|
||||
try {
|
||||
canvas.concat(labelScreenMatrix(labelTransform(node, graph, overlays?.rotationPreview), r))
|
||||
r.auxFill.setColor(r.ck.Color4f(background.r, background.g, background.b, background.a))
|
||||
const { x, y, width, height } = layout.bounds
|
||||
const bounds = r.ck.RRectXY(
|
||||
r.ck.LTRBRect(x, y, x + width, y + height),
|
||||
SECTION_TITLE_RADIUS,
|
||||
SECTION_TITLE_RADIUS
|
||||
)
|
||||
canvas.drawRRect(bounds, r.auxFill)
|
||||
if (overlays?.hoveredNodeId === node.id) {
|
||||
r.auxFill.setColor(hover)
|
||||
canvas.drawRRect(bounds, r.auxFill)
|
||||
}
|
||||
r.auxStroke.setColor(border)
|
||||
r.auxStroke.setStrokeWidth(1)
|
||||
r.auxStroke.setPathEffect(null)
|
||||
canvas.drawRRect(bounds, r.auxStroke)
|
||||
r.auxFill.setColor(foreground)
|
||||
canvas.drawParagraph(paragraph, layout.text.x, layout.text.y)
|
||||
} finally {
|
||||
canvas.restore()
|
||||
}
|
||||
},
|
||||
initialLayout.fontWeight
|
||||
)
|
||||
layout = labelLayout('section', node.width * r.zoom, nested, metrics)
|
||||
if (!layout) return
|
||||
canvas.save()
|
||||
canvas.concat(labelScreenMatrix(labelTransform(node, graph, overlays?.rotationPreview), r))
|
||||
r.auxFill.setColor(r.ck.Color4f(background.r, background.g, background.b, background.a))
|
||||
const { x, y, width, height } = layout.bounds
|
||||
const bounds = r.ck.RRectXY(
|
||||
r.ck.LTRBRect(x, y, x + width, y + height),
|
||||
SECTION_TITLE_RADIUS,
|
||||
SECTION_TITLE_RADIUS
|
||||
)
|
||||
canvas.drawRRect(bounds, r.auxFill)
|
||||
if (overlays?.hoveredNodeId === node.id) {
|
||||
r.auxFill.setColor(hover)
|
||||
canvas.drawRRect(bounds, r.auxFill)
|
||||
}
|
||||
r.auxStroke.setColor(border)
|
||||
r.auxStroke.setStrokeWidth(1)
|
||||
r.auxStroke.setPathEffect(null)
|
||||
canvas.drawRRect(bounds, r.auxStroke)
|
||||
r.auxFill.setColor(foreground)
|
||||
r.labelParagraphCache.draw(
|
||||
r.ck,
|
||||
canvas,
|
||||
provider,
|
||||
node.name,
|
||||
layout.fontSize,
|
||||
layout.maxTextWidth,
|
||||
foreground,
|
||||
r.fontGeneration,
|
||||
layout.text.x,
|
||||
layout.text.y,
|
||||
layout.fontWeight
|
||||
)
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
export function drawComponentLabels(
|
||||
|
|
|
|||
|
|
@ -7,15 +7,40 @@ import type {
|
|||
TypefaceFontProvider
|
||||
} from 'canvaskit-wasm'
|
||||
|
||||
import { ResourceCache } from '#core/cache/resource'
|
||||
import type { Size } from '@open-pencil/scene-graph'
|
||||
|
||||
interface LabelParagraphEntry {
|
||||
paragraph: Paragraph
|
||||
width: number
|
||||
height: number
|
||||
import { ResourceCache } from '#core/cache/resource'
|
||||
import { resolveParagraphFontFamilies } from '#core/canvas/text/font-families'
|
||||
import { DEFAULT_FONT_FAMILY } from '#core/constants'
|
||||
import { textHasFallbackScript } from '#core/text/coverage'
|
||||
import { detectTextDirection } from '#core/text/direction'
|
||||
import { weightToStyle } from '#core/text/fonts'
|
||||
import { missingGlyphOccurrences, type MissingGlyphOccurrence } from '#core/text/resolver'
|
||||
|
||||
export type LabelParagraphMetrics = Readonly<Size>
|
||||
|
||||
/** Borrowed only for the synchronous use() callback; do not delete or retain the paragraph. */
|
||||
export interface BorrowedLabelParagraph {
|
||||
readonly paragraph: Paragraph
|
||||
readonly metrics: LabelParagraphMetrics
|
||||
}
|
||||
|
||||
export interface LabelFontOptions {
|
||||
families?: (weight: number) => string[]
|
||||
onMissingGlyphs?: (missing: readonly MissingGlyphOccurrence[]) => void
|
||||
}
|
||||
|
||||
interface LabelParagraphEntry extends BorrowedLabelParagraph {
|
||||
units: number
|
||||
layoutWidth: number
|
||||
reuseFrom: number | null
|
||||
users: number
|
||||
retired: boolean
|
||||
}
|
||||
|
||||
const MAX_LABEL_PARAGRAPHS = 512
|
||||
const MAX_LABEL_TEXT_UNITS = 262_144
|
||||
const HARD_BREAK = /[\r\n\f\v\u0085\u2028\u2029]/u
|
||||
|
||||
export function measureGlyphWidth(font: Font, text: string): number {
|
||||
let width = 0
|
||||
|
|
@ -23,12 +48,138 @@ export function measureGlyphWidth(font: Font, text: string): number {
|
|||
return width
|
||||
}
|
||||
|
||||
export class LabelParagraphCache {
|
||||
private readonly entries = new ResourceCache<string, LabelParagraphEntry>({
|
||||
maxEntries: MAX_LABEL_PARAGRAPHS,
|
||||
dispose: (entry) => entry.paragraph.delete()
|
||||
function retire(entry: LabelParagraphEntry): void {
|
||||
entry.retired = true
|
||||
if (entry.users === 0) entry.paragraph.delete()
|
||||
}
|
||||
|
||||
function buildEntry(
|
||||
ck: CanvasKit,
|
||||
provider: TypefaceFontProvider,
|
||||
text: string,
|
||||
fontSize: number,
|
||||
width: number,
|
||||
color: Float32Array,
|
||||
fontWeight: number,
|
||||
fonts: LabelFontOptions
|
||||
): LabelParagraphEntry {
|
||||
const style = new ck.ParagraphStyle({
|
||||
maxLines: 1,
|
||||
ellipsis: '…',
|
||||
textDirection:
|
||||
detectTextDirection(text) === 'RTL' ? ck.TextDirection.RTL : ck.TextDirection.LTR,
|
||||
textAlign: ck.TextAlign.Left,
|
||||
textStyle: {
|
||||
color,
|
||||
fontFamilies:
|
||||
fonts.families?.(fontWeight) ??
|
||||
resolveParagraphFontFamilies(DEFAULT_FONT_FAMILY, weightToStyle(fontWeight)),
|
||||
fontSize,
|
||||
fontStyle: { weight: { value: fontWeight } as FontWeight }
|
||||
}
|
||||
})
|
||||
const builder = ck.ParagraphBuilder.MakeFromFontProvider(style, provider)
|
||||
let paragraph: Paragraph
|
||||
try {
|
||||
builder.addText(text)
|
||||
paragraph = builder.build()
|
||||
} finally {
|
||||
builder.delete()
|
||||
}
|
||||
try {
|
||||
paragraph.layout(width)
|
||||
if (fonts.onMissingGlyphs && textHasFallbackScript(text)) {
|
||||
fonts.onMissingGlyphs(missingGlyphOccurrences(text, paragraph.getShapedLines()))
|
||||
}
|
||||
const longest = paragraph.getLongestLine()
|
||||
let reuseFrom: number | null = null
|
||||
// Labels stay physically left-aligned, including RTL text; unwrapped text keeps its origin.
|
||||
// Never speculate with an extra unconstrained layout, or reuse hard-break/ellipsized text.
|
||||
if (!HARD_BREAK.test(text) && !paragraph.didExceedMaxLines()) {
|
||||
const intrinsic = Math.max(paragraph.getMaxIntrinsicWidth(), longest)
|
||||
if (Number.isFinite(intrinsic) && intrinsic <= width) reuseFrom = intrinsic
|
||||
}
|
||||
return {
|
||||
paragraph,
|
||||
metrics: { width: Math.min(longest, width), height: paragraph.getHeight() },
|
||||
units: text.length,
|
||||
layoutWidth: width,
|
||||
reuseFrom,
|
||||
users: 0,
|
||||
retired: false
|
||||
}
|
||||
} catch (error) {
|
||||
paragraph.delete()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export class LabelParagraphCache {
|
||||
private readonly entries: ResourceCache<string, LabelParagraphEntry>
|
||||
private fontGeneration = -1
|
||||
private provider: TypefaceFontProvider | null = null
|
||||
|
||||
constructor(
|
||||
maxEntries = MAX_LABEL_PARAGRAPHS,
|
||||
private readonly maxTextUnits = MAX_LABEL_TEXT_UNITS,
|
||||
private readonly fonts: LabelFontOptions = {}
|
||||
) {
|
||||
this.entries = new ResourceCache({
|
||||
maxEntries,
|
||||
maxWeight: maxTextUnits,
|
||||
weight: (entry) => entry.units,
|
||||
dispose: retire
|
||||
})
|
||||
}
|
||||
|
||||
use<T>(
|
||||
ck: CanvasKit,
|
||||
provider: TypefaceFontProvider,
|
||||
text: string,
|
||||
fontSize: number,
|
||||
maxWidth: number,
|
||||
color: Float32Array,
|
||||
generation: number,
|
||||
consume: (entry: BorrowedLabelParagraph) => T,
|
||||
fontWeight = 400
|
||||
): T {
|
||||
if (generation !== this.fontGeneration || provider !== this.provider) {
|
||||
this.clear()
|
||||
this.fontGeneration = generation
|
||||
this.provider = provider
|
||||
}
|
||||
const width = Math.max(1, maxWidth)
|
||||
const base =
|
||||
text.length <= this.maxTextUnits
|
||||
? `${fontSize}\0${fontWeight}\0${color[0]},${color[1]},${color[2]},${color[3]}\0${text}`
|
||||
: null
|
||||
const fitKey = `fit\0${base}`
|
||||
const exactKey = `${width}\0${base}`
|
||||
let entry: LabelParagraphEntry | undefined
|
||||
if (base !== null) {
|
||||
const fitting = this.entries.peek(fitKey)
|
||||
// SkParagraph floors its SkScalar (float32) layout width before line breaking.
|
||||
// This guards reuse only; pass the original width unchanged to native layout.
|
||||
const fits =
|
||||
fitting &&
|
||||
fitting.reuseFrom !== null &&
|
||||
(width === fitting.layoutWidth || Math.floor(Math.fround(width)) > fitting.reuseFrom)
|
||||
entry = this.entries.get(fits ? fitKey : exactKey)
|
||||
}
|
||||
const cached = entry !== undefined
|
||||
entry ??= buildEntry(ck, provider, text, fontSize, width, color, fontWeight, this.fonts)
|
||||
entry.users++
|
||||
try {
|
||||
if (!cached) {
|
||||
const key = entry.reuseFrom === null ? exactKey : fitKey
|
||||
if (base === null || !this.entries.set(key, entry)) entry.retired = true
|
||||
}
|
||||
return consume(entry)
|
||||
} finally {
|
||||
entry.users--
|
||||
if (entry.retired && entry.users === 0) entry.paragraph.delete()
|
||||
}
|
||||
}
|
||||
|
||||
measure(
|
||||
ck: CanvasKit,
|
||||
|
|
@ -39,8 +190,18 @@ export class LabelParagraphCache {
|
|||
color: Float32Array,
|
||||
generation: number,
|
||||
fontWeight = 400
|
||||
): Pick<LabelParagraphEntry, 'width' | 'height'> {
|
||||
return this.entry(ck, provider, text, fontSize, maxWidth, color, generation, fontWeight)
|
||||
): LabelParagraphMetrics {
|
||||
return this.use(
|
||||
ck,
|
||||
provider,
|
||||
text,
|
||||
fontSize,
|
||||
maxWidth,
|
||||
color,
|
||||
generation,
|
||||
(entry) => entry.metrics,
|
||||
fontWeight
|
||||
)
|
||||
}
|
||||
|
||||
draw(
|
||||
|
|
@ -56,59 +217,32 @@ export class LabelParagraphCache {
|
|||
y: number,
|
||||
fontWeight = 400
|
||||
): number {
|
||||
const entry = this.entry(ck, provider, text, fontSize, maxWidth, color, generation, fontWeight)
|
||||
canvas.drawParagraph(entry.paragraph, x, y)
|
||||
return entry.width
|
||||
return this.use(
|
||||
ck,
|
||||
provider,
|
||||
text,
|
||||
fontSize,
|
||||
maxWidth,
|
||||
color,
|
||||
generation,
|
||||
(entry) => {
|
||||
canvas.drawParagraph(entry.paragraph, x, y)
|
||||
return entry.metrics.width
|
||||
},
|
||||
fontWeight
|
||||
)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.provider = null
|
||||
this.fontGeneration = -1
|
||||
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,
|
||||
fontWeight: number
|
||||
): LabelParagraphEntry {
|
||||
if (generation !== this.fontGeneration) {
|
||||
this.clear()
|
||||
this.fontGeneration = generation
|
||||
}
|
||||
const boundedWidth = Math.max(1, maxWidth)
|
||||
const key = `${fontSize}\0${fontWeight}\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,
|
||||
fontStyle: { weight: { value: fontWeight } as FontWeight }
|
||||
}
|
||||
})
|
||||
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),
|
||||
height: paragraph.getHeight()
|
||||
}
|
||||
this.entries.set(key, entry)
|
||||
}
|
||||
return entry
|
||||
textUnits(): number {
|
||||
return this.entries.weight
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,7 +138,9 @@ export class SkiaRenderer {
|
|||
subtreePictureCachePositionPreviewVersion = -1
|
||||
subtreePictureCacheFontGeneration = -1
|
||||
readonly labelCache = new LabelCache()
|
||||
readonly labelParagraphCache = new LabelParagraphCache()
|
||||
readonly labelParagraphCache = new LabelParagraphCache(undefined, undefined, {
|
||||
onMissingGlyphs: (missing) => RendererFonts.resolveLabelFontCoverage(this, missing)
|
||||
})
|
||||
readonly textPreparationCache = new TextPreparationCache()
|
||||
readonly tiledScene = new TiledSceneController()
|
||||
readonly profiler: RenderProfiler
|
||||
|
|
|
|||
|
|
@ -11,7 +11,30 @@ import {
|
|||
} from '#core/constants'
|
||||
import { fontManager } from '#core/text/fonts'
|
||||
import { prepareGraphFonts } from '#core/text/prepare'
|
||||
import type { FontResolutionSnapshot } from '#core/text/resolver'
|
||||
import {
|
||||
fontCoverageDemand,
|
||||
fontResolver,
|
||||
missingGlyphsByScript,
|
||||
type MissingGlyphOccurrence,
|
||||
type FontResolutionSnapshot
|
||||
} from '#core/text/resolver'
|
||||
|
||||
export function resolveLabelFontCoverage(
|
||||
r: Pick<SkiaRenderer, 'isDestroyed' | 'onFontResolutionSettled'>,
|
||||
missing: readonly MissingGlyphOccurrence[],
|
||||
resolver = fontResolver
|
||||
): void {
|
||||
if (r.isDestroyed()) return
|
||||
for (const [script, characters] of missingGlyphsByScript(missing)) {
|
||||
const demand = fontCoverageDemand(script, characters)
|
||||
const state = resolver.state(demand).state
|
||||
if (state === 'loaded') resolver.exhaust(demand)
|
||||
else if (state === 'idle' || state === 'loading') {
|
||||
// No fake TEXT node: the shared settlement callback refreshes font generation and repaints.
|
||||
void resolver.demand(demand, r.onFontResolutionSettled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function syncFontGeneration(r: SkiaRenderer): void {
|
||||
r.fontGeneration = fontManager.generation()
|
||||
|
|
|
|||
31
packages/core/src/canvas/text/font-families.ts
Normal file
31
packages/core/src/canvas/text/font-families.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { uniq } from 'es-toolkit/array'
|
||||
|
||||
import { ResourceCache } from '#core/cache/resource'
|
||||
import { DEFAULT_FONT_FAMILY } from '#core/constants'
|
||||
import { fontManager } from '#core/text/fonts'
|
||||
|
||||
const familiesCache = new ResourceCache<string, string[]>({ maxEntries: 256 })
|
||||
|
||||
export function resolveParagraphFontFamilies(
|
||||
primary: string,
|
||||
style: string,
|
||||
arabicFallbacks: readonly string[] = fontManager.getArabicFallbackFamilies(),
|
||||
cjkFallbacks: readonly string[] = fontManager.getCJKFallbackFamilies()
|
||||
): string[] {
|
||||
const renderPrimary = fontManager.renderFamily(primary, style)
|
||||
const renderArabicFallbacks = arabicFallbacks.map((family) =>
|
||||
fontManager.renderFamily(family, 'Regular')
|
||||
)
|
||||
const renderCJKFallbacks = cjkFallbacks.map((family) =>
|
||||
fontManager.renderFamily(family, 'Regular')
|
||||
)
|
||||
const key = `${renderPrimary}\0${renderArabicFallbacks.join('\0')}\0${renderCJKFallbacks.join('\0')}`
|
||||
const cached = familiesCache.peek(key)
|
||||
if (cached) return cached
|
||||
const families = [renderPrimary]
|
||||
if (primary !== DEFAULT_FONT_FAMILY) families.push(DEFAULT_FONT_FAMILY)
|
||||
families.push(...renderArabicFallbacks, ...renderCJKFallbacks)
|
||||
const resolved = uniq(families)
|
||||
familiesCache.set(key, resolved)
|
||||
return resolved
|
||||
}
|
||||
|
|
@ -6,17 +6,13 @@ import type {
|
|||
TextFontVariations,
|
||||
TypefaceFontProvider
|
||||
} from 'canvaskit-wasm'
|
||||
import { uniq } from 'es-toolkit/array'
|
||||
|
||||
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
import { ResourceCache } from '#core/cache/resource'
|
||||
import { resolveRGBAForPreview } from '#core/color/management'
|
||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from '#core/constants'
|
||||
import { transformTextCase } from '#core/text/case'
|
||||
import { fontFallbackScriptForCharacter } from '#core/text/coverage'
|
||||
import { resolveNodeTextDirection } from '#core/text/direction'
|
||||
import type { FontFallbackScript } from '#core/text/fallbacks'
|
||||
import { fontManager, weightToStyle } from '#core/text/fonts'
|
||||
import {
|
||||
fontCoverageDemand,
|
||||
|
|
@ -24,9 +20,11 @@ import {
|
|||
fontRemoteCoverageDemand,
|
||||
fontResolver,
|
||||
missingGlyphOccurrences,
|
||||
missingGlyphsByScript,
|
||||
type FontResolutionSettled
|
||||
} from '#core/text/resolver'
|
||||
|
||||
import { resolveParagraphFontFamilies } from './font-families'
|
||||
import type { ParagraphNode } from './paragraph-inputs'
|
||||
import type { PreparedText, TextPreparationCache } from './preparation-cache'
|
||||
|
||||
|
|
@ -45,9 +43,6 @@ interface TextRenderer extends FontReadinessRenderer {
|
|||
fontsLoaded: boolean
|
||||
}
|
||||
|
||||
const FONT_FAMILY_CACHE_LIMIT = 256
|
||||
const fontFamilyCache = new ResourceCache<string, string[]>({ maxEntries: FONT_FAMILY_CACHE_LIMIT })
|
||||
|
||||
function demandFace(
|
||||
r: FontReadinessRenderer,
|
||||
node: SceneNode,
|
||||
|
|
@ -163,14 +158,9 @@ function observedGlyphReadiness(r: TextRenderer, node: SceneNode): NodeFontReadi
|
|||
return 'ready'
|
||||
}
|
||||
|
||||
const charactersByScript = new Map<FontFallbackScript, string[]>()
|
||||
for (const { character, utf16Start } of missingOccurrences) {
|
||||
const script = fontFallbackScriptForCharacter(character, languageForCharacter(node, utf16Start))
|
||||
if (!script) continue
|
||||
const characters = charactersByScript.get(script) ?? []
|
||||
characters.push(character)
|
||||
charactersByScript.set(script, characters)
|
||||
}
|
||||
const charactersByScript = missingGlyphsByScript(missingOccurrences, (offset) =>
|
||||
languageForCharacter(node, offset)
|
||||
)
|
||||
|
||||
let pending = false
|
||||
let exhausted = charactersByScript.size === 0
|
||||
|
|
@ -277,32 +267,6 @@ function buildTruncateOpts(
|
|||
return opts
|
||||
}
|
||||
|
||||
function resolveParagraphFontFamilies(
|
||||
primary: string,
|
||||
style: string,
|
||||
arabicFallbacks: readonly string[],
|
||||
cjkFallbacks: readonly string[]
|
||||
): string[] {
|
||||
const renderPrimary = fontManager.renderFamily(primary, style)
|
||||
const renderArabicFallbacks = arabicFallbacks.map((family) =>
|
||||
fontManager.renderFamily(family, 'Regular')
|
||||
)
|
||||
const renderCJKFallbacks = cjkFallbacks.map((family) =>
|
||||
fontManager.renderFamily(family, 'Regular')
|
||||
)
|
||||
const key = `${renderPrimary}\0${renderArabicFallbacks.join('\0')}\0${renderCJKFallbacks.join('\0')}`
|
||||
const cached = fontFamilyCache.peek(key)
|
||||
if (cached) return cached
|
||||
|
||||
const families = [renderPrimary]
|
||||
if (primary !== DEFAULT_FONT_FAMILY) families.push(DEFAULT_FONT_FAMILY)
|
||||
families.push(...renderArabicFallbacks, ...renderCJKFallbacks)
|
||||
|
||||
const resolved = uniq(families)
|
||||
fontFamilyCache.set(key, resolved)
|
||||
return resolved
|
||||
}
|
||||
|
||||
function getParagraphTextAlign(
|
||||
ck: CanvasKit,
|
||||
node: Pick<SceneNode, 'textAlignHorizontal' | 'textDirection' | 'text'>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ function scriptCharRegex(script: FontFallbackScript): RegExp {
|
|||
}
|
||||
}
|
||||
|
||||
export function textHasFallbackScript(text: string): boolean {
|
||||
return ARABIC_CHAR_RE.test(text) || CJK_CHAR_RE.test(text)
|
||||
}
|
||||
|
||||
export function fontFallbackScriptForCharacter(
|
||||
char: string,
|
||||
language?: string | null
|
||||
|
|
|
|||
|
|
@ -32,6 +32,21 @@ export interface MissingGlyphOccurrence {
|
|||
utf16Start: number
|
||||
}
|
||||
|
||||
export function missingGlyphsByScript(
|
||||
occurrences: readonly MissingGlyphOccurrence[],
|
||||
languageAt?: (utf16Start: number) => string | null
|
||||
): Map<FontFallbackScript, string[]> {
|
||||
const groups = new Map<FontFallbackScript, string[]>()
|
||||
for (const { character, utf16Start } of occurrences) {
|
||||
const script = fontFallbackScriptForCharacter(character, languageAt?.(utf16Start))
|
||||
if (!script) continue
|
||||
const characters = groups.get(script) ?? []
|
||||
characters.push(character)
|
||||
groups.set(script, characters)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
export function missingGlyphOccurrences(
|
||||
text: string,
|
||||
lines: readonly ObservedShapedLine[],
|
||||
|
|
|
|||
|
|
@ -1,11 +1,134 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
import { expect, test } from '#tests/e2e/fixtures'
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
import { CanvasHelper } from '#tests/helpers/canvas'
|
||||
import { mockFontsource } from '#tests/helpers/fonts/fontsource'
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 2 })
|
||||
|
||||
test('reused Unicode section labels match fresh paragraphs across zoom reversals', async ({
|
||||
page
|
||||
}) => {
|
||||
const fonts = await mockFontsource(page, [
|
||||
{
|
||||
family: 'Noto Naskh Arabic',
|
||||
subset: 'arabic',
|
||||
data: readFileSync('public/NotoNaskhArabic-Regular.ttf')
|
||||
},
|
||||
{
|
||||
family: 'Noto Sans SC',
|
||||
subset: 'chinese-simplified',
|
||||
data: readFileSync('tests/fixtures/fonts/NotoSansCJK-Test.otf'),
|
||||
format: 'otf'
|
||||
}
|
||||
])
|
||||
try {
|
||||
await page.goto('/?test&no-rulers&navigation-benchmark')
|
||||
const canvas = new CanvasHelper(page)
|
||||
await canvas.waitForInit()
|
||||
await page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('Editor unavailable')
|
||||
const names = [
|
||||
'Button',
|
||||
'AV To ffi',
|
||||
'Привет',
|
||||
'Ελληνικά',
|
||||
'écho café',
|
||||
'مرحبا',
|
||||
'你好',
|
||||
'Trail ',
|
||||
'one\ntwo'
|
||||
]
|
||||
for (const [index, name] of names.entries()) {
|
||||
store.graph.createNode('SECTION', store.state.currentPageId, {
|
||||
name,
|
||||
x: 80,
|
||||
y: 70 + index * 70,
|
||||
width: 130,
|
||||
height: 40,
|
||||
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
|
||||
strokes: [
|
||||
{
|
||||
color: { r: 0.7, g: 0.7, b: 0.7, a: 1 },
|
||||
weight: 1,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
align: 'INSIDE'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
store.clearSelection()
|
||||
store.requestRender()
|
||||
})
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const renderer = window.openPencil
|
||||
?.getStore?.()
|
||||
.canvasRenderers.find((r) => r.fontsLoaded && r.fontProvider)
|
||||
const provider = renderer?.fontProvider
|
||||
if (!renderer || !provider) return false
|
||||
return ['مرحبا', '你好'].every((text) =>
|
||||
renderer.labelParagraphCache.use(
|
||||
renderer.ck,
|
||||
provider,
|
||||
text,
|
||||
11,
|
||||
300,
|
||||
renderer.ck.BLACK,
|
||||
renderer.fontGeneration,
|
||||
({ paragraph }) => {
|
||||
const runs = paragraph.getShapedLines().flatMap((line) => line.runs)
|
||||
return (
|
||||
runs.length > 0 &&
|
||||
runs.every(
|
||||
(run) => run.glyphs.length > 0 && run.glyphs.every((glyph) => glyph !== 0)
|
||||
)
|
||||
)
|
||||
},
|
||||
600
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
expect(fonts.counts.downloads).toBeGreaterThanOrEqual(2)
|
||||
await canvas.waitForRender()
|
||||
for (const zoom of [1.25, 0.6, 1.1, 0.75, 1]) {
|
||||
await page.evaluate((scale) => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('Editor unavailable')
|
||||
store.setZoomAroundPoint(scale, 0, 0)
|
||||
}, zoom)
|
||||
await page.evaluate(() => window.openPencil?.test?.navigation?.waitForSettlement())
|
||||
await canvas.waitForRender()
|
||||
const reused = await canvas.screenshotCanvasRegion(520, 840)
|
||||
await page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store?.canvasRenderers.length) throw new Error('Renderer unavailable')
|
||||
for (const renderer of store.canvasRenderers) renderer.labelParagraphCache.clear()
|
||||
store.requestRepaint()
|
||||
})
|
||||
await canvas.waitForRender()
|
||||
const fresh = await canvas.screenshotCanvasRegion(520, 840)
|
||||
expect(reused.equals(fresh), `label pixels at ${zoom} zoom`).toBe(true)
|
||||
if (zoom === 1)
|
||||
expect(fresh).toMatchSnapshot('unicode-section-labels.png', {
|
||||
threshold: 0,
|
||||
maxDiffPixels: 0
|
||||
})
|
||||
}
|
||||
canvas.assertNoErrors()
|
||||
} finally {
|
||||
await fonts.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
for (const rotation of [-145, -40, 50]) {
|
||||
test(`frame titles and dimensions remain readable at ${rotation} degrees`, async ({ page }) => {
|
||||
await page.goto('/?test&no-rulers')
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
|
|
@ -3,10 +3,15 @@ import { test, expect } from '@playwright/test'
|
|||
import type { FontManager } from '#core/text/fonts'
|
||||
|
||||
import { CanvasHelper } from '#tests/helpers/canvas'
|
||||
import { trackFontModuleResources } from '#tests/helpers/fonts/runtime'
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await trackFontModuleResources(page)
|
||||
})
|
||||
|
||||
test('tool-created CJK text requests fallback through app font loading', async ({ page }) => {
|
||||
const canvas = new CanvasHelper(page)
|
||||
await page.goto('http://localhost:1420/?test&no-chrome&no-rulers')
|
||||
await page.goto('/?test&no-chrome&no-rulers')
|
||||
await canvas.waitForInit()
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
|
|
@ -68,7 +73,7 @@ test('tool-created CJK text requests fallback through app font loading', async (
|
|||
|
||||
test('CJK text waits for fallback fonts and repaints after they load', async ({ page }) => {
|
||||
const canvas = new CanvasHelper(page)
|
||||
await page.goto('http://localhost:1420/?test&no-chrome&no-rulers')
|
||||
await page.goto('/?test&no-chrome&no-rulers')
|
||||
await canvas.waitForInit()
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,13 @@ import type { FontManager } from '#core/text/fonts'
|
|||
|
||||
import { expect, test } from '#tests/e2e/fixtures'
|
||||
import { CanvasHelper } from '#tests/helpers/canvas'
|
||||
import { mockFontsource } from '#tests/helpers/fonts/fontsource'
|
||||
import { trackFontModuleResources } from '#tests/helpers/fonts/runtime'
|
||||
|
||||
async function openEditor(page: Page): Promise<CanvasHelper> {
|
||||
await trackFontModuleResources(page)
|
||||
// These tests install fixture fonts explicitly; remote arrivals must not race those fixtures.
|
||||
await mockFontsource(page, [])
|
||||
await page.goto('/?test&no-chrome&no-rulers')
|
||||
const canvas = new CanvasHelper(page)
|
||||
await canvas.waitForInit()
|
||||
|
|
|
|||
147
tests/engine/render/canvas/labels/fonts.test.ts
Normal file
147
tests/engine/render/canvas/labels/fonts.test.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { beforeAll, expect, test } from 'bun:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { initCanvasKit } from '#cli/headless'
|
||||
import { LabelParagraphCache } from '#core/canvas/labels/paragraph-cache'
|
||||
import { resolveLabelFontCoverage } from '#core/canvas/renderer/fonts'
|
||||
import { resolveParagraphFontFamilies } from '#core/canvas/text/font-families'
|
||||
import { FontResolver, missingGlyphOccurrences } from '#core/text/resolver'
|
||||
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
import { repoPath } from '#tests/helpers/paths'
|
||||
|
||||
let ck: Awaited<ReturnType<typeof initCanvasKit>>
|
||||
beforeAll(async () => {
|
||||
ck = await initCanvasKit()
|
||||
})
|
||||
|
||||
test.each([
|
||||
{
|
||||
text: 'مرحبا بالعالم',
|
||||
family: 'Noto Naskh Arabic',
|
||||
path: 'tests/fixtures/fonts/NotoNaskhArabic-Regular.ttf',
|
||||
arabic: true
|
||||
},
|
||||
{
|
||||
text: '你好世界',
|
||||
family: 'Noto Sans SC',
|
||||
path: 'tests/fixtures/fonts/NotoSansSC-Regular.ttf',
|
||||
arabic: false
|
||||
}
|
||||
])('labels reuse shared fallback loading and render real $family glyphs', async (fixture) => {
|
||||
const provider = ck.TypefaceFontProvider.Make()
|
||||
provider.registerFont(readFileSync(repoPath('public/Inter-Regular.ttf')), 'Inter')
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const settled = Promise.withResolvers<undefined>()
|
||||
let generation = 1
|
||||
let loads = 0
|
||||
let loaded = false
|
||||
const resolver = new FontResolver(async () => {
|
||||
loads++
|
||||
await release.promise
|
||||
provider.registerFont(readFileSync(repoPath(fixture.path)), fixture.family)
|
||||
loaded = true
|
||||
return true
|
||||
})
|
||||
const owner = {
|
||||
isDestroyed: () => false,
|
||||
onFontResolutionSettled: () => {
|
||||
generation++
|
||||
settled.resolve(undefined)
|
||||
}
|
||||
}
|
||||
const families = () =>
|
||||
resolveParagraphFontFamilies(
|
||||
'Inter',
|
||||
'Regular',
|
||||
loaded && fixture.arabic ? [fixture.family] : [],
|
||||
loaded && !fixture.arabic ? [fixture.family] : []
|
||||
)
|
||||
const fresh = new LabelParagraphCache(0, undefined, { families })
|
||||
const cache = new LabelParagraphCache(undefined, undefined, {
|
||||
families,
|
||||
onMissingGlyphs: (missing) => resolveLabelFontCoverage(owner, missing, resolver)
|
||||
})
|
||||
const surface = expectDefined(ck.MakeSurface(320, 64), 'label font surface')
|
||||
try {
|
||||
cache.use(ck, provider, fixture.text, 24, 300, ck.BLACK, generation, ({ paragraph }) => {
|
||||
expect(
|
||||
missingGlyphOccurrences(fixture.text, paragraph.getShapedLines()).length
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
cache.measure(ck, provider, fixture.text, 24, 300, ck.BLACK, generation)
|
||||
expect(loads).toBe(1)
|
||||
release.resolve(undefined)
|
||||
await settled.promise
|
||||
const canvas = surface.getCanvas()
|
||||
canvas.clear(ck.WHITE)
|
||||
cache.use(ck, provider, fixture.text, 24, 300, ck.BLACK, generation, ({ paragraph }) => {
|
||||
expect(missingGlyphOccurrences(fixture.text, paragraph.getShapedLines())).toEqual([])
|
||||
canvas.drawParagraph(paragraph, 2, 2)
|
||||
})
|
||||
surface.flush()
|
||||
const image = surface.makeImageSnapshot()
|
||||
try {
|
||||
const pixels = expectDefined(
|
||||
image.readPixels(0, 0, {
|
||||
width: 320,
|
||||
height: 64,
|
||||
colorType: ck.ColorType.RGBA_8888,
|
||||
alphaType: ck.AlphaType.Unpremul,
|
||||
colorSpace: ck.ColorSpace.SRGB
|
||||
}),
|
||||
'label font pixels'
|
||||
)
|
||||
let ink = 0
|
||||
for (let i = 0; i < pixels.length; i += 4) if (pixels[i] < 128 && pixels[i + 3] > 0) ink++
|
||||
expect(ink).toBeGreaterThan(40)
|
||||
} finally {
|
||||
image.delete()
|
||||
}
|
||||
const intrinsic = cache.measure(ck, provider, fixture.text, 24, 300, ck.BLACK, generation).width
|
||||
const shape = (candidate: LabelParagraphCache, width: number, weight: number) =>
|
||||
candidate.use(
|
||||
ck,
|
||||
provider,
|
||||
fixture.text,
|
||||
24,
|
||||
width,
|
||||
ck.BLACK,
|
||||
generation,
|
||||
({ paragraph, metrics }) => ({
|
||||
metrics,
|
||||
runs: paragraph.getShapedLines().map((line) =>
|
||||
line.runs.map((run) => ({
|
||||
glyphs: [...run.glyphs],
|
||||
positions: [...run.positions],
|
||||
offsets: [...run.offsets]
|
||||
}))
|
||||
)
|
||||
}),
|
||||
weight
|
||||
)
|
||||
for (const weight of [400, 600]) {
|
||||
for (const width of [
|
||||
300,
|
||||
intrinsic - 1,
|
||||
intrinsic,
|
||||
intrinsic + 0.01,
|
||||
intrinsic + 0.25,
|
||||
intrinsic + 0.75,
|
||||
intrinsic + 1,
|
||||
intrinsic + 2,
|
||||
300
|
||||
]) {
|
||||
expect(shape(cache, width, weight)).toEqual(shape(fresh, width, weight))
|
||||
}
|
||||
}
|
||||
expect(loads).toBe(1)
|
||||
} finally {
|
||||
release.resolve(undefined)
|
||||
if (loads > 0) await settled.promise
|
||||
cache.clear()
|
||||
fresh.clear()
|
||||
surface.delete()
|
||||
provider.delete()
|
||||
}
|
||||
})
|
||||
257
tests/engine/render/canvas/labels/paragraph-reuse.test.ts
Normal file
257
tests/engine/render/canvas/labels/paragraph-reuse.test.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import type { CanvasKit, Paragraph, ParagraphBuilder, TypefaceFontProvider } from 'canvaskit-wasm'
|
||||
|
||||
import { initCanvasKit } from '#cli/headless'
|
||||
import { LabelParagraphCache } from '#core/canvas/labels/paragraph-cache'
|
||||
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
import { repoPath } from '#tests/helpers/paths'
|
||||
|
||||
let ck: Awaited<ReturnType<typeof initCanvasKit>>
|
||||
let provider: TypefaceFontProvider
|
||||
beforeAll(async () => {
|
||||
ck = await initCanvasKit()
|
||||
provider = ck.TypefaceFontProvider.Make()
|
||||
provider.registerFont(readFileSync(repoPath('public/Inter-Regular.ttf')), 'Inter')
|
||||
provider.registerFont(readFileSync(repoPath('public/Inter-SemiBold.ttf')), 'Inter')
|
||||
})
|
||||
afterAll(() => provider.delete())
|
||||
|
||||
function withLabel<T>(
|
||||
cache: LabelParagraphCache,
|
||||
text: string,
|
||||
width: number,
|
||||
consume: (paragraph: Paragraph) => T
|
||||
): T {
|
||||
return cache.use(ck, provider, text, 11, width, ck.BLACK, 1, ({ paragraph }) =>
|
||||
consume(paragraph)
|
||||
)
|
||||
}
|
||||
|
||||
describe('label paragraph reuse', () => {
|
||||
test('reuses fitting widths without re-layout and keeps ellipsis variants separate', () => {
|
||||
const cache = new LabelParagraphCache()
|
||||
try {
|
||||
withLabel(cache, 'Button', 300, (first) => {
|
||||
for (const width of [200, 100, 180, 301]) {
|
||||
withLabel(cache, 'Button', width, (next) => expect(next).toBe(first))
|
||||
}
|
||||
withLabel(cache, 'Button', 5, (clipped) => {
|
||||
expect(clipped).not.toBe(first)
|
||||
expect(clipped.didExceedMaxLines()).toBe(true)
|
||||
})
|
||||
withLabel(cache, 'Button', 100, (next) => expect(next).toBe(first))
|
||||
})
|
||||
expect(cache.size()).toBe(2)
|
||||
} finally {
|
||||
cache.clear()
|
||||
}
|
||||
})
|
||||
|
||||
test('pins borrowed native paragraphs across nested eviction, clear and exceptions', () => {
|
||||
const cache = new LabelParagraphCache(1)
|
||||
let borrowed: Paragraph | undefined
|
||||
try {
|
||||
expect(() =>
|
||||
withLabel(cache, 'First', 200, (first) => {
|
||||
borrowed = first
|
||||
withLabel(cache, 'Second', 200, (second) => {
|
||||
cache.clear()
|
||||
expect(first.isDeleted()).toBe(false)
|
||||
expect(second.isDeleted()).toBe(false)
|
||||
})
|
||||
expect(first.isDeleted()).toBe(false)
|
||||
throw new Error('consumer failed')
|
||||
})
|
||||
).toThrow('consumer failed')
|
||||
expect(borrowed?.isDeleted()).toBe(true)
|
||||
expect(cache.size()).toBe(0)
|
||||
} finally {
|
||||
cache.clear()
|
||||
}
|
||||
})
|
||||
|
||||
test('bounds text units and disposes oversized paragraphs after consumption', () => {
|
||||
const cache = new LabelParagraphCache(8, 10)
|
||||
let transient: Paragraph | undefined
|
||||
try {
|
||||
withLabel(cache, 'First', 200, (paragraph) => expect(paragraph.isDeleted()).toBe(false))
|
||||
withLabel(cache, 'Second', 200, (paragraph) => expect(paragraph.isDeleted()).toBe(false))
|
||||
expect(cache.textUnits()).toBe(6)
|
||||
withLabel(cache, 'Oversized label', 200, (paragraph) => {
|
||||
transient = paragraph
|
||||
expect(paragraph.isDeleted()).toBe(false)
|
||||
expect(cache.textUnits()).toBe(6)
|
||||
})
|
||||
expect(transient?.isDeleted()).toBe(true)
|
||||
expect(cache.size()).toBe(1)
|
||||
} finally {
|
||||
cache.clear()
|
||||
}
|
||||
})
|
||||
|
||||
test('invalidates on provider identity even when generation is unchanged', () => {
|
||||
const other = ck.TypefaceFontProvider.Make()
|
||||
const cache = new LabelParagraphCache()
|
||||
try {
|
||||
withLabel(cache, 'Provider', 200, (first) => {
|
||||
cache.use(ck, other, 'Provider', 11, 200, ck.BLACK, 1, ({ paragraph }) => {
|
||||
expect(paragraph).not.toBe(first)
|
||||
expect(first.isDeleted()).toBe(false)
|
||||
})
|
||||
})
|
||||
expect(cache.size()).toBe(1)
|
||||
} finally {
|
||||
cache.clear()
|
||||
other.delete()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(['addText', 'layout'])('releases native resources when %s throws', (stage) => {
|
||||
const cache = new LabelParagraphCache()
|
||||
let builder: ParagraphBuilder | undefined
|
||||
let paragraph: Paragraph | undefined
|
||||
const fail = () => {
|
||||
throw new Error('native failure')
|
||||
}
|
||||
const kit: CanvasKit = {
|
||||
...ck,
|
||||
ParagraphBuilder: {
|
||||
...ck.ParagraphBuilder,
|
||||
MakeFromFontProvider: (...args) => {
|
||||
const created = ck.ParagraphBuilder.MakeFromFontProvider(...args)
|
||||
builder = created
|
||||
if (stage === 'addText') created.addText = fail
|
||||
else {
|
||||
const build = created.build.bind(created)
|
||||
created.build = () => {
|
||||
const result = build()
|
||||
paragraph = result
|
||||
result.layout = fail
|
||||
return result
|
||||
}
|
||||
}
|
||||
return created
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
expect(() => cache.measure(kit, provider, 'Failure', 11, 200, ck.BLACK, 1)).toThrow(
|
||||
'native failure'
|
||||
)
|
||||
expect(builder?.isDeleted()).toBe(true)
|
||||
if (stage === 'layout') expect(paragraph?.isDeleted()).toBe(true)
|
||||
expect(cache.size()).toBe(0)
|
||||
} finally {
|
||||
cache.clear()
|
||||
if (builder && !builder.isDeleted()) builder.delete()
|
||||
if (paragraph && !paragraph.isDeleted()) paragraph.delete()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([400, 600])(
|
||||
'matches fresh layouts at Unicode/wrap boundaries (weight %i)',
|
||||
(fontWeight) => {
|
||||
const cached = new LabelParagraphCache()
|
||||
const fresh = new LabelParagraphCache(0)
|
||||
const surface = expectDefined(ck.MakeSurface(1024, 40), 'label comparison surface')
|
||||
const canvas = surface.getCanvas()
|
||||
const paint = (cache: LabelParagraphCache, text: string, width: number) => {
|
||||
canvas.clear(ck.WHITE)
|
||||
const metrics = cache.use(
|
||||
ck,
|
||||
provider,
|
||||
text,
|
||||
11,
|
||||
width,
|
||||
ck.BLACK,
|
||||
1,
|
||||
({ paragraph, metrics }) => {
|
||||
canvas.drawParagraph(paragraph, 2, 2)
|
||||
return metrics
|
||||
},
|
||||
fontWeight
|
||||
)
|
||||
surface.flush()
|
||||
const image = surface.makeImageSnapshot()
|
||||
try {
|
||||
return {
|
||||
metrics,
|
||||
pixels: expectDefined(
|
||||
image.readPixels(0, 0, {
|
||||
width: 1024,
|
||||
height: 40,
|
||||
colorType: ck.ColorType.RGBA_8888,
|
||||
alphaType: ck.AlphaType.Unpremul,
|
||||
colorSpace: ck.ColorSpace.SRGB
|
||||
}),
|
||||
'label pixels'
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
image.delete()
|
||||
}
|
||||
}
|
||||
try {
|
||||
for (const text of [
|
||||
'Button',
|
||||
'AV To ffi',
|
||||
'漢字',
|
||||
'مرحبا بالعالم',
|
||||
'אבג 123',
|
||||
'👩🏽💻 🇯🇵',
|
||||
'é',
|
||||
'Trail ',
|
||||
' Lead',
|
||||
'a\tb',
|
||||
'one\ntwo',
|
||||
'one\r\ntwo',
|
||||
'a\u2028b',
|
||||
'a\u2029b',
|
||||
'a\u0085b',
|
||||
'\u202Babc\u202C',
|
||||
'\u200Fabc',
|
||||
'zero\u200Bwidth',
|
||||
'',
|
||||
' '
|
||||
]) {
|
||||
const intrinsic = fresh.use(
|
||||
ck,
|
||||
provider,
|
||||
text,
|
||||
11,
|
||||
1000,
|
||||
ck.BLACK,
|
||||
1,
|
||||
({ paragraph }) => paragraph.getMaxIntrinsicWidth(),
|
||||
fontWeight
|
||||
)
|
||||
for (const width of [
|
||||
1000,
|
||||
intrinsic + 1,
|
||||
intrinsic + 1 / 64,
|
||||
intrinsic,
|
||||
intrinsic - 1 / 64,
|
||||
intrinsic - 1,
|
||||
1,
|
||||
0,
|
||||
-1,
|
||||
120,
|
||||
300
|
||||
]) {
|
||||
const actual = paint(cached, text, width)
|
||||
const expected = paint(fresh, text, width)
|
||||
expect(actual.metrics, `${JSON.stringify(text)} at ${width}`).toEqual(expected.metrics)
|
||||
expect(actual.pixels, `${JSON.stringify(text)} at ${width}`).toEqual(expected.pixels)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cached.clear()
|
||||
fresh.clear()
|
||||
surface.delete()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
69
tests/helpers/fonts/fontsource.ts
Normal file
69
tests/helpers/fonts/fontsource.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
interface FontsourceFixture {
|
||||
family: string
|
||||
subset: string
|
||||
data: Buffer
|
||||
format?: 'ttf' | 'otf'
|
||||
}
|
||||
|
||||
/** Use real font bytes through the browser-enabled provider, without external network requests. */
|
||||
export async function mockFontsource(page: Page, fixtures: readonly FontsourceFixture[]) {
|
||||
const counts = { downloads: 0 }
|
||||
const entries = new Map(fixtures.map((fixture, index) => [String(index), fixture]))
|
||||
const pattern =
|
||||
/^https:\/\/(api\.fontsource\.org\/v1\/fonts(?:\/|$)|cdn\.jsdelivr\.net\/fontsource\/fonts\/openpencil-fixture-)/
|
||||
const headers = { 'access-control-allow-origin': '*' }
|
||||
async function handle(route: Route) {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.hostname === 'cdn.jsdelivr.net') {
|
||||
const fixture = entries.get(
|
||||
url.pathname.slice('/fontsource/fonts/openpencil-fixture-'.length, -4)
|
||||
)
|
||||
if (!fixture) throw new Error('Unknown fixture font')
|
||||
counts.downloads++
|
||||
await route.fulfill({
|
||||
body: fixture.data,
|
||||
contentType: `font/${fixture.format ?? 'ttf'}`,
|
||||
headers
|
||||
})
|
||||
} else if (url.pathname === '/v1/fonts') {
|
||||
await route.fulfill({
|
||||
headers,
|
||||
json: [...entries].map(([id, fixture]) => ({
|
||||
id,
|
||||
family: fixture.family,
|
||||
variable: false,
|
||||
weights: [400],
|
||||
styles: ['normal'],
|
||||
subsets: [fixture.subset],
|
||||
defSubset: fixture.subset
|
||||
}))
|
||||
})
|
||||
} else {
|
||||
const id = url.pathname.split('/').at(-1)
|
||||
const fixture = id === undefined ? undefined : entries.get(id)
|
||||
if (!fixture) throw new Error('Unknown fixture font metadata')
|
||||
await route.fulfill({
|
||||
headers,
|
||||
json: {
|
||||
unicodeRange: {},
|
||||
variants: {
|
||||
'400': {
|
||||
normal: {
|
||||
[fixture.subset]: {
|
||||
url: {
|
||||
[fixture.format ?? 'ttf']:
|
||||
`https://cdn.jsdelivr.net/fontsource/fonts/openpencil-fixture-${id}.${fixture.format ?? 'ttf'}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
await page.route(pattern, handle)
|
||||
return { counts, dispose: () => page.unroute(pattern, handle) }
|
||||
}
|
||||
11
tests/helpers/fonts/runtime.ts
Normal file
11
tests/helpers/fonts/runtime.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { Page } from '@playwright/test'
|
||||
|
||||
const FONT_MODULE_RESOURCE_CAPACITY = 4096
|
||||
|
||||
/** Preserve the loaded font module URL even when the dev module graph exceeds the browser's default buffer. */
|
||||
export async function trackFontModuleResources(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
(capacity) => performance.setResourceTimingBufferSize(capacity),
|
||||
FONT_MODULE_RESOURCE_CAPACITY
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue