openpencil/tests/engine/render/canvas/text.test.ts
2026-05-17 20:40:39 +03:00

414 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, test, expect, mock } from 'bun:test'
import {
detectTextDirection,
resolveTextDirection,
SceneGraph,
SkiaRenderer as SkiaRendererClass
} from '@open-pencil/core'
import { initCanvasKit } from '#cli/headless'
import type { SkiaRenderer } from '#core/canvas/renderer'
import { renderText } from '#core/canvas/scene'
import type { SceneNode } from '#core/scene-graph'
import { buildParagraph } from '#core/canvas/text'
import { fontManager } from '#core/text/fonts'
import { expectDefined } from '#tests/helpers/assert'
import { repoPath } from '#tests/helpers/paths'
function createMockCanvas() {
return {
drawParagraph: mock(() => undefined),
drawPicture: mock(() => undefined),
drawText: mock(() => undefined),
drawRect: mock(() => undefined),
save: mock(() => undefined),
saveLayer: mock(() => undefined),
restore: mock(() => undefined),
clipRect: mock(() => undefined)
}
}
function createMockParagraph() {
return { delete: mock(() => undefined) }
}
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()),
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),
buildParagraph: mock(() => paragraph),
_paragraph: paragraph,
...overrides
} as SkiaRenderer & { _paragraph: ReturnType<typeof createMockParagraph> }
}
function textNode(overrides: Partial<SceneNode> = {}): SceneNode {
return {
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: [],
...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('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).toHaveBeenCalledTimes(1)
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({ isNodeFontLoaded: mock(() => false) })
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', () => {
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).toHaveBeenCalledTimes(1)
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('prefers textPicture over paragraph', () => {
const r = createMockRenderer()
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()
})
})
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('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()
fontProvider.registerFont(interData, 'Inter')
fontManager.markLoaded('Inter', 'Regular', interData)
const notoPath = repoPath('tests/fixtures/fonts/NotoSansSC-Regular.ttf')
const notoData = await Bun.file(notoPath).arrayBuffer()
fontProvider.registerFont(notoData, 'Noto Sans SC')
fontManager.setCJKFallbackFamily('Noto Sans SC')
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 && r > b + 40) redTextPixels++
if (x >= 110 && b > r + 40) blueTextPixels++
}
}
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)
})
})