* 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
617 lines
21 KiB
TypeScript
617 lines
21 KiB
TypeScript
import { describe, test, expect, mock } from 'bun:test'
|
||
|
||
import {
|
||
detectTextDirection,
|
||
resolveTextDirection,
|
||
SceneGraph,
|
||
SkiaRenderer as SkiaRendererClass
|
||
} from '@open-pencil/core'
|
||
import type { SceneNode } from '@open-pencil/scene-graph'
|
||
import { createDefaultSourceMetadata } from '@open-pencil/scene-graph/node-defaults'
|
||
|
||
import { initCanvasKit } from '#cli/headless'
|
||
import type { SkiaRenderer } from '#core/canvas/renderer'
|
||
import { renderText, textVerticalOffset } from '#core/canvas/scene'
|
||
import { buildParagraph, isNodeFontLoaded } from '#core/canvas/text'
|
||
import { transformTextCase } from '#core/text/case'
|
||
import { fontManager } from '#core/text/fonts'
|
||
import { fontFaceDemand, fontResolver, missingGlyphCharacters } from '#core/text/resolver'
|
||
|
||
import { expectDefined } from '#tests/helpers/assert'
|
||
import { repoPath } from '#tests/helpers/paths'
|
||
|
||
function createMockCanvas() {
|
||
return {
|
||
drawParagraph: mock(() => undefined),
|
||
drawPicture: mock(() => undefined),
|
||
drawText: mock(() => undefined),
|
||
drawPath: mock(() => undefined),
|
||
drawRect: mock(() => undefined),
|
||
save: mock(() => undefined),
|
||
saveLayer: mock(() => undefined),
|
||
restore: mock(() => undefined),
|
||
clipRect: mock(() => undefined),
|
||
translate: mock(() => undefined),
|
||
scale: mock(() => undefined),
|
||
rotate: mock(() => undefined)
|
||
}
|
||
}
|
||
|
||
function createMockParagraph() {
|
||
return { delete: mock(() => undefined), getHeight: mock(() => 20) }
|
||
}
|
||
|
||
function createMockPicture() {
|
||
return { delete: mock(() => undefined) }
|
||
}
|
||
|
||
function createMockRenderer(overrides: Partial<Record<string, unknown>> = {}) {
|
||
const paragraph = createMockParagraph()
|
||
return {
|
||
fontsLoaded: true,
|
||
fontProvider: {},
|
||
textFont: {},
|
||
fillPaint: { getColor: () => new Float32Array([0, 0, 0, 1]) },
|
||
effectLayerPaint: {
|
||
setBlendMode: mock(() => undefined),
|
||
setColorFilter: mock(() => undefined),
|
||
setImageFilter: mock(() => undefined)
|
||
},
|
||
ck: {
|
||
MakePicture: mock(() => createMockPicture()),
|
||
PathBuilder: class {
|
||
moveTo = mock(() => this)
|
||
lineTo = mock(() => this)
|
||
cubicTo = mock(() => this)
|
||
quadTo = mock(() => this)
|
||
close = mock(() => this)
|
||
setFillType = mock(() => this)
|
||
delete = mock(() => undefined)
|
||
detachAndDelete = mock(() => ({
|
||
delete: mock(() => undefined),
|
||
setFillType: mock(() => undefined)
|
||
}))
|
||
},
|
||
LTRBRect: mock((...args: number[]) => args),
|
||
Color4f: mock((...args: number[]) => new Float32Array(args)),
|
||
BlendMode: { SrcOver: 0, SrcIn: 1 },
|
||
ClipOp: { Intersect: 0 }
|
||
},
|
||
DEFAULT_FONT_SIZE: 14,
|
||
isNodeFontLoaded: mock(() => true),
|
||
nodeFontReadiness: mock(() => 'ready'),
|
||
isTextPictureCurrent: mock(() => true),
|
||
buildParagraph: mock(() => paragraph),
|
||
_paragraph: paragraph,
|
||
...overrides
|
||
} as SkiaRenderer & { _paragraph: ReturnType<typeof createMockParagraph> }
|
||
}
|
||
|
||
function textNode(overrides: Partial<SceneNode> = {}): SceneNode {
|
||
return {
|
||
type: 'TEXT',
|
||
text: 'Hello 你好',
|
||
fontSize: 16,
|
||
fontFamily: 'Arial',
|
||
fontWeight: 400,
|
||
italic: false,
|
||
letterSpacing: 0,
|
||
lineHeight: null,
|
||
textAlignHorizontal: 'LEFT',
|
||
textAlignVertical: 'TOP',
|
||
textAutoResize: 'NONE',
|
||
textDecoration: 'NONE',
|
||
textDirection: 'AUTO',
|
||
styleRuns: [],
|
||
source: createDefaultSourceMetadata(),
|
||
...overrides
|
||
} as SceneNode
|
||
}
|
||
|
||
async function createTextRenderer() {
|
||
const ck = await initCanvasKit()
|
||
const surface = expectDefined(ck.MakeSurface(400, 120), 'surface')
|
||
const renderer = new SkiaRendererClass(ck, surface)
|
||
return { renderer, surface }
|
||
}
|
||
|
||
describe('text case and vertical alignment', () => {
|
||
test('transforms display text without changing the source', () => {
|
||
expect(transformTextCase('hello WORLD', 'ORIGINAL')).toBe('hello WORLD')
|
||
expect(transformTextCase('hello World', 'UPPER')).toBe('HELLO WORLD')
|
||
expect(transformTextCase('Hello WORLD', 'LOWER')).toBe('hello world')
|
||
expect(transformTextCase('hello WORLD 42nd', 'TITLE')).toBe('Hello World 42nd')
|
||
})
|
||
|
||
test('computes top, center, and bottom paragraph offsets', () => {
|
||
expect(textVerticalOffset(textNode({ height: 100, textAlignVertical: 'TOP' }), 20)).toBe(0)
|
||
expect(textVerticalOffset(textNode({ height: 100, textAlignVertical: 'CENTER' }), 20)).toBe(40)
|
||
expect(textVerticalOffset(textNode({ height: 100, textAlignVertical: 'BOTTOM' }), 20)).toBe(80)
|
||
expect(textVerticalOffset(textNode({ height: 10, textAlignVertical: 'BOTTOM' }), 20)).toBe(0)
|
||
})
|
||
})
|
||
|
||
describe('renderText', () => {
|
||
test('uses buildParagraph when fonts are loaded and node font is available', () => {
|
||
const r = createMockRenderer()
|
||
const canvas = createMockCanvas()
|
||
|
||
renderText(r, canvas as never, textNode())
|
||
|
||
expect(r.buildParagraph).toHaveBeenCalledWith(expect.anything(), expect.anything(), {
|
||
halfLeading: true
|
||
})
|
||
expect(canvas.drawParagraph).toHaveBeenCalledTimes(1)
|
||
expect(canvas.drawText).not.toHaveBeenCalled()
|
||
expect(r._paragraph.delete).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
test('skips text while the node font is not available', () => {
|
||
const r = createMockRenderer({ nodeFontReadiness: mock(() => 'pending') })
|
||
const canvas = createMockCanvas()
|
||
|
||
renderText(r, canvas as never, textNode())
|
||
|
||
expect(r.buildParagraph).not.toHaveBeenCalled()
|
||
expect(canvas.drawParagraph).not.toHaveBeenCalled()
|
||
expect(canvas.drawText).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('renders gradient text through a paragraph mask without outline font data', () => {
|
||
const r = createMockRenderer()
|
||
const canvas = createMockCanvas()
|
||
|
||
renderText(r, canvas as never, textNode(), {
|
||
type: 'GRADIENT_LINEAR',
|
||
visible: true,
|
||
opacity: 1,
|
||
gradientStops: [],
|
||
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
|
||
})
|
||
|
||
expect(r.buildParagraph).toHaveBeenCalledWith(expect.anything(), expect.anything(), {
|
||
halfLeading: true
|
||
})
|
||
expect(canvas.saveLayer).toHaveBeenCalledTimes(2)
|
||
expect(canvas.drawParagraph).toHaveBeenCalledTimes(1)
|
||
expect(canvas.drawRect).toHaveBeenCalledTimes(1)
|
||
expect(r.effectLayerPaint.setBlendMode).toHaveBeenCalledWith(r.ck.BlendMode.SrcIn)
|
||
expect(r._paragraph.delete).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
test('renders non-solid text fills as vector outlines when outline font data is available', async () => {
|
||
const interData = await Bun.file(repoPath('public/Inter-Regular.ttf')).arrayBuffer()
|
||
fontManager.markLoaded('Inter', 'Regular', interData)
|
||
const r = createMockRenderer()
|
||
const canvas = createMockCanvas()
|
||
|
||
renderText(
|
||
r,
|
||
canvas as never,
|
||
textNode({ text: 'OPEN', fontFamily: 'Inter', width: 120, height: 40 }),
|
||
{
|
||
type: 'GRADIENT_LINEAR',
|
||
visible: true,
|
||
opacity: 1,
|
||
gradientStops: [],
|
||
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
|
||
}
|
||
)
|
||
|
||
expect(canvas.drawPath).toHaveBeenCalledTimes(1)
|
||
expect(r.buildParagraph).not.toHaveBeenCalled()
|
||
expect(canvas.saveLayer).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('prefers resolved fonts over baked text pictures', () => {
|
||
const r = createMockRenderer()
|
||
const canvas = createMockCanvas()
|
||
const node = textNode({ textPicture: new Uint8Array([1, 2, 3]) })
|
||
|
||
renderText(r, canvas as never, node)
|
||
|
||
expect(canvas.drawPicture).not.toHaveBeenCalled()
|
||
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()
|
||
const node = textNode({ textPicture: new Uint8Array([1, 2, 3]) })
|
||
|
||
renderText(r, canvas as never, node)
|
||
|
||
expect(canvas.drawPicture).toHaveBeenCalledTimes(1)
|
||
expect(r.buildParagraph).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('falls back to drawText only when fonts are NOT loaded', () => {
|
||
const r = createMockRenderer({ fontsLoaded: false, fontProvider: null })
|
||
const canvas = createMockCanvas()
|
||
|
||
renderText(r, canvas as never, textNode())
|
||
|
||
expect(canvas.drawText).toHaveBeenCalledTimes(1)
|
||
expect(r.buildParagraph).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('does nothing for empty text', () => {
|
||
const r = createMockRenderer()
|
||
const canvas = createMockCanvas()
|
||
|
||
renderText(r, canvas as never, textNode({ text: '' }))
|
||
|
||
expect(r.buildParagraph).not.toHaveBeenCalled()
|
||
expect(canvas.drawText).not.toHaveBeenCalled()
|
||
expect(canvas.drawPicture).not.toHaveBeenCalled()
|
||
})
|
||
})
|
||
|
||
describe('paragraph font weights', () => {
|
||
test('bold Inter paragraph is wider than regular Inter', async () => {
|
||
const { renderer, surface } = await createTextRenderer()
|
||
await renderer.loadFonts()
|
||
const regular = await Bun.file(repoPath('public/Inter-Regular.ttf')).arrayBuffer()
|
||
const bold = await Bun.file(repoPath('public/Inter-Bold.ttf')).arrayBuffer()
|
||
fontManager.markLoaded('Inter', 'Regular', regular)
|
||
fontManager.markLoaded('Inter', 'Bold', bold)
|
||
|
||
const base = textNode({
|
||
text: 'World largest design',
|
||
fontFamily: 'Inter',
|
||
fontSize: 64,
|
||
width: 1000,
|
||
height: 100,
|
||
fontWeight: 400,
|
||
italic: false
|
||
})
|
||
const regularParagraph = buildParagraph(renderer, base)
|
||
const boldParagraph = buildParagraph(renderer, { ...base, fontWeight: 700 })
|
||
|
||
expect(boldParagraph.getLongestLine()).toBeGreaterThan(regularParagraph.getLongestLine())
|
||
regularParagraph.delete()
|
||
boldParagraph.delete()
|
||
surface.delete()
|
||
})
|
||
|
||
test('shapes italic text when only the regular family face is available', async () => {
|
||
const { renderer, surface } = await createTextRenderer()
|
||
await renderer.loadFonts()
|
||
const regular = await Bun.file(repoPath('public/Inter-Regular.ttf')).arrayBuffer()
|
||
fontManager.markLoaded('Inter', 'Regular', regular)
|
||
const demand = fontFaceDemand('Inter', 'Regular Italic', 'Synthetic italic')
|
||
fontResolver.reset(demand)
|
||
fontResolver.exhaust(demand)
|
||
const node = textNode({
|
||
text: 'Synthetic italic',
|
||
fontFamily: 'Inter',
|
||
italic: true,
|
||
width: 300,
|
||
height: 40
|
||
})
|
||
|
||
expect(isNodeFontLoaded(renderer, node)).toBe(true)
|
||
const paragraph = buildParagraph(renderer, node)
|
||
paragraph.layout(300)
|
||
expect(paragraph.getLongestLine()).toBeGreaterThan(0)
|
||
|
||
paragraph.delete()
|
||
fontResolver.reset(demand)
|
||
surface.delete()
|
||
})
|
||
})
|
||
|
||
describe('renderText headless visual', () => {
|
||
test('detects base direction for Arabic and mixed text', () => {
|
||
expect(detectTextDirection('مرحبا')).toBe('RTL')
|
||
expect(resolveTextDirection('AUTO', 'مرحبا world')).toBe('RTL')
|
||
expect(resolveTextDirection('AUTO', 'Hello مرحبا')).toBe('LTR')
|
||
expect(resolveTextDirection('RTL', 'Hello')).toBe('RTL')
|
||
})
|
||
|
||
test('observes CanvasKit notdef glyphs for unsupported CJK text', async () => {
|
||
const ck = await initCanvasKit()
|
||
const fontProvider = ck.TypefaceFontProvider.Make()
|
||
fontManager.attachProvider(ck, fontProvider)
|
||
const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
|
||
fontProvider.registerFont(interData, 'Inter')
|
||
fontManager.markLoaded('Inter', 'Regular', interData)
|
||
const manager = fontManager as typeof fontManager & { cjkFallbackFamilies: string[] }
|
||
const originalFallbacks = [...manager.cjkFallbackFamilies]
|
||
manager.cjkFallbackFamilies = []
|
||
const surface = expectDefined(ck.MakeSurface(200, 50), 'CanvasKit surface')
|
||
|
||
try {
|
||
const renderer = new SkiaRendererClass(ck, surface)
|
||
renderer.fontsLoaded = true
|
||
renderer.fontProvider = fontProvider
|
||
const paragraph = buildParagraph(
|
||
renderer,
|
||
textNode({ text: 'A𠀀B', fontFamily: 'Inter', fontWeight: 400 })
|
||
)
|
||
paragraph.layout(200)
|
||
|
||
expect(missingGlyphCharacters('A𠀀B', paragraph.getShapedLines())).toEqual(['𠀀'])
|
||
paragraph.delete()
|
||
} finally {
|
||
manager.cjkFallbackFamilies = originalFallbacks
|
||
surface.delete()
|
||
}
|
||
})
|
||
|
||
test('does not require fallback families when the primary font covers CJK glyphs', async () => {
|
||
const notoPath = repoPath('tests/fixtures/fonts/NotoSansSC-Regular.ttf')
|
||
const notoData = await Bun.file(notoPath).arrayBuffer()
|
||
fontManager.markLoaded('Noto Sans SC', 'Regular', notoData)
|
||
const manager = fontManager as typeof fontManager & { cjkFallbackFamilies: string[] }
|
||
const originalFallbacks = [...manager.cjkFallbackFamilies]
|
||
manager.cjkFallbackFamilies = []
|
||
|
||
try {
|
||
const loaded = isNodeFontLoaded(
|
||
{ fontProvider: {}, fontsLoaded: true } as never,
|
||
textNode({ text: '你好世界', fontFamily: 'Noto Sans SC', fontWeight: 400 })
|
||
)
|
||
|
||
expect(loaded).toBe(true)
|
||
} finally {
|
||
manager.cjkFallbackFamilies = originalFallbacks
|
||
}
|
||
})
|
||
|
||
test('renders CJK text via fallback font through paragraph shaper', async () => {
|
||
const ck = await initCanvasKit()
|
||
const fontProvider = ck.TypefaceFontProvider.Make()
|
||
fontManager.attachProvider(ck, fontProvider)
|
||
|
||
const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
|
||
fontManager.markLoaded('Inter', 'Regular', interData)
|
||
|
||
const notoPath = repoPath('tests/fixtures/fonts/NotoSansSC-Regular.ttf')
|
||
const notoData = await Bun.file(notoPath).arrayBuffer()
|
||
fontManager.markLoaded('Noto Sans SC', 'Regular', notoData)
|
||
fontManager.setCJKFallbackFamily('Noto Sans SC')
|
||
for (let attempt = 0; attempt < 5; attempt++) {
|
||
fontManager.markLoaded('Noto Sans SC', 'Regular', notoData)
|
||
}
|
||
|
||
const graph = new SceneGraph()
|
||
const page = graph.getPages()[0]
|
||
const node = graph.createNode('TEXT', page.id, {
|
||
text: '你好世界',
|
||
fontFamily: 'Inter',
|
||
fontSize: 32,
|
||
fontWeight: 400,
|
||
width: 200,
|
||
height: 50,
|
||
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
|
||
})
|
||
|
||
const surface = expectDefined(ck.MakeSurface(200, 50), 'CanvasKit surface')
|
||
const renderer = new SkiaRendererClass(ck, surface)
|
||
renderer.viewportWidth = 200
|
||
renderer.viewportHeight = 50
|
||
renderer.dpr = 1
|
||
renderer.fontsLoaded = true
|
||
renderer.fontProvider = fontProvider
|
||
|
||
const canvas = surface.getCanvas()
|
||
canvas.clear(ck.WHITE)
|
||
renderText(renderer, canvas, expectDefined(graph.getNode(node.id), 'text node'))
|
||
surface.flush()
|
||
|
||
const image = surface.makeImageSnapshot()
|
||
const encoded = expectDefined(image.encodeToBytes(ck.ImageFormat.PNG, 100), 'encoded PNG')
|
||
image.delete()
|
||
surface.delete()
|
||
|
||
expect(encoded.length).toBeGreaterThan(200)
|
||
|
||
const decodedImage = expectDefined(ck.MakeImageFromEncoded(encoded), 'decoded PNG image')
|
||
const pixels = decodedImage.readPixels(0, 0, {
|
||
width: 200,
|
||
height: 50,
|
||
colorType: ck.ColorType.RGBA_8888,
|
||
alphaType: ck.AlphaType.Unpremul,
|
||
colorSpace: ck.ColorSpace.SRGB
|
||
})
|
||
decodedImage.delete()
|
||
|
||
let darkPixels = 0
|
||
for (let i = 0; i < pixels.length; i += 4) {
|
||
if (pixels[i] < 128 && pixels[i + 1] < 128 && pixels[i + 2] < 128) {
|
||
darkPixels++
|
||
}
|
||
}
|
||
// CJK characters are dense — should have many dark pixels if rendering correctly
|
||
// Tofu boxes would have far fewer (just outlines)
|
||
expect(darkPixels).toBeGreaterThan(500)
|
||
})
|
||
|
||
test('renders linear gradient text through the canvas scene fill path', async () => {
|
||
const ck = await initCanvasKit()
|
||
const fontProvider = ck.TypefaceFontProvider.Make()
|
||
fontManager.attachProvider(ck, fontProvider)
|
||
|
||
const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
|
||
fontProvider.registerFont(interData, 'Inter')
|
||
fontManager.markLoaded('Inter', 'Regular', interData)
|
||
|
||
const graph = new SceneGraph()
|
||
const page = graph.getPages()[0]
|
||
const node = graph.createNode('TEXT', page.id, {
|
||
text: 'OPEN',
|
||
fontFamily: 'Inter',
|
||
fontSize: 64,
|
||
fontWeight: 400,
|
||
width: 220,
|
||
height: 80,
|
||
fills: [
|
||
{
|
||
type: 'GRADIENT_LINEAR',
|
||
opacity: 1,
|
||
visible: true,
|
||
gradientStops: [
|
||
{ position: 0, color: { r: 1, g: 0, b: 0, a: 1 } },
|
||
{ position: 1, color: { r: 0, g: 0, b: 1, a: 1 } }
|
||
],
|
||
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
|
||
}
|
||
]
|
||
})
|
||
|
||
const surface = expectDefined(ck.MakeSurface(220, 80), 'CanvasKit surface')
|
||
const renderer = new SkiaRendererClass(ck, surface)
|
||
renderer.viewportWidth = 220
|
||
renderer.viewportHeight = 80
|
||
renderer.dpr = 1
|
||
renderer.fontsLoaded = true
|
||
renderer.fontProvider = fontProvider
|
||
|
||
const canvas = surface.getCanvas()
|
||
canvas.clear(ck.WHITE)
|
||
renderer.renderShape(canvas, expectDefined(graph.getNode(node.id), 'text node'), graph)
|
||
surface.flush()
|
||
|
||
const image = surface.makeImageSnapshot()
|
||
const pixels = image.readPixels(0, 0, {
|
||
width: 220,
|
||
height: 80,
|
||
colorType: ck.ColorType.RGBA_8888,
|
||
alphaType: ck.AlphaType.Unpremul,
|
||
colorSpace: ck.ColorSpace.SRGB
|
||
})
|
||
image.delete()
|
||
surface.delete()
|
||
|
||
let redTextPixels = 0
|
||
let blueTextPixels = 0
|
||
for (let y = 0; y < 80; y++) {
|
||
for (let x = 0; x < 220; x++) {
|
||
const i = (y * 220 + x) * 4
|
||
const r = pixels[i]
|
||
const g = pixels[i + 1]
|
||
const b = pixels[i + 2]
|
||
if (g > 220) continue
|
||
if (x < 110 && b > r + 40) blueTextPixels++
|
||
if (x >= 110 && r > b + 40) redTextPixels++
|
||
}
|
||
}
|
||
|
||
expect(redTextPixels).toBeGreaterThan(40)
|
||
expect(blueTextPixels).toBeGreaterThan(40)
|
||
})
|
||
|
||
test('renders Arabic text via fallback font through paragraph shaper', async () => {
|
||
const ck = await initCanvasKit()
|
||
const fontProvider = ck.TypefaceFontProvider.Make()
|
||
fontManager.attachProvider(ck, fontProvider)
|
||
|
||
const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
|
||
fontProvider.registerFont(interData, 'Inter')
|
||
fontManager.markLoaded('Inter', 'Regular', interData)
|
||
|
||
const arabicPath = repoPath('tests/fixtures/fonts/NotoNaskhArabic-Regular.ttf')
|
||
const arabicData = await Bun.file(arabicPath).arrayBuffer()
|
||
fontProvider.registerFont(arabicData, 'Noto Naskh Arabic')
|
||
fontManager.setArabicFallbackFamily('Noto Naskh Arabic')
|
||
|
||
const graph = new SceneGraph()
|
||
const page = graph.getPages()[0]
|
||
const node = graph.createNode('TEXT', page.id, {
|
||
text: 'مرحبا بالعالم',
|
||
textDirection: 'AUTO',
|
||
fontFamily: 'Inter',
|
||
fontSize: 32,
|
||
fontWeight: 400,
|
||
width: 220,
|
||
height: 60,
|
||
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
|
||
})
|
||
|
||
const surface = expectDefined(ck.MakeSurface(220, 60), 'CanvasKit surface')
|
||
const renderer = new SkiaRendererClass(ck, surface)
|
||
renderer.viewportWidth = 220
|
||
renderer.viewportHeight = 60
|
||
renderer.dpr = 1
|
||
renderer.fontsLoaded = true
|
||
renderer.fontProvider = fontProvider
|
||
|
||
const canvas = surface.getCanvas()
|
||
canvas.clear(ck.WHITE)
|
||
renderText(renderer, canvas, expectDefined(graph.getNode(node.id), 'text node'))
|
||
surface.flush()
|
||
|
||
const image = surface.makeImageSnapshot()
|
||
const encoded = expectDefined(image.encodeToBytes(ck.ImageFormat.PNG, 100), 'encoded PNG')
|
||
image.delete()
|
||
surface.delete()
|
||
|
||
expect(encoded.length).toBeGreaterThan(200)
|
||
|
||
const decodedImage = expectDefined(ck.MakeImageFromEncoded(encoded), 'decoded PNG image')
|
||
const pixels = decodedImage.readPixels(0, 0, {
|
||
width: 220,
|
||
height: 60,
|
||
colorType: ck.ColorType.RGBA_8888,
|
||
alphaType: ck.AlphaType.Unpremul,
|
||
colorSpace: ck.ColorSpace.SRGB
|
||
})
|
||
decodedImage.delete()
|
||
|
||
let darkPixels = 0
|
||
for (let i = 0; i < pixels.length; i += 4) {
|
||
if (pixels[i] < 128 && pixels[i + 1] < 128 && pixels[i + 2] < 128) {
|
||
darkPixels++
|
||
}
|
||
}
|
||
expect(darkPixels).toBeGreaterThan(450)
|
||
})
|
||
})
|