openpencil/tests/engine/render/canvas/text.test.ts
Danila Poyarkov 0f64cecc49
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
2026-08-30 14:33:32 +03:00

617 lines
21 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 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)
})
})