import { beforeAll, describe, expect, test } from 'bun:test' import { SceneGraph } from '@open-pencil/scene-graph' import { initCanvasKit } from '#cli/headless' import { SkiaRenderer } from '#core/canvas/renderer' import { derivedUnderlineRect, hasRotatedDerivedGlyphs, shouldUseHardDerivedGlyphCoverage, snapDerivedGlyphBaseline } from '#core/canvas/text/derived' import { renderNodesToImage } from '#core/io/formats/raster/render' import { expectDefined } from '#tests/helpers/assert' let ck: Awaited> beforeAll(async () => { ck = await initCanvasKit() }) function useDerivedGlyphFallback(renderer: SkiaRenderer): void { renderer.nodeFontReadiness = () => 'exhausted' } function squareCommandsBlob(): Uint8Array { const blob = new Uint8Array(1 + 4 * 9 + 1) const view = new DataView(blob.buffer) let offset = 0 const commands = [ { command: 1, x: 0, y: 0 }, { command: 2, x: 1, y: 0 }, { command: 2, x: 1, y: 1 }, { command: 2, x: 0, y: 1 } ] for (const { command, x, y } of commands) { blob[offset] = command view.setFloat32(offset + 1, x, true) view.setFloat32(offset + 5, y, true) offset += 9 } blob[offset] = 0 return blob } describe('derived text rendering', () => { test('uses run alpha and inherited paint at saved cluster positions', () => { const graph = new SceneGraph() const page = graph.getPages()[0] const text = graph.createNode('TEXT', page.id, { text: 'ffiX', width: 40, height: 20, fontSize: 10, fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 1, visible: true }], styleRuns: [ { start: 0, length: 3, style: { fills: [ { type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 0.5, visible: true } ] } } ], derivedTextGlyphs: [ { commandsBlob: squareCommandsBlob(), x: 0, y: 10, fontSize: 10, firstCharacter: 0 }, { commandsBlob: squareCommandsBlob(), x: 20, y: 10, fontSize: 10, firstCharacter: 3 } ] }) const surface = expectDefined(ck.MakeSurface(1, 1), 'surface') const renderer = new SkiaRenderer(ck, surface) renderer.nodeFontReadiness = () => { throw new Error('Saved text must not depend on font readiness') } try { const png = expectDefined( renderNodesToImage(ck, renderer, graph, page.id, [text.id], { scale: 1, format: 'PNG', trimTransparent: false }), 'png' ) const image = expectDefined(ck.MakeImageFromEncoded(png), 'image') try { const pixels = expectDefined( image.readPixels(0, 0, { alphaType: ck.AlphaType.Unpremul, colorType: ck.ColorType.RGBA_8888, colorSpace: ck.ColorSpace.SRGB, width: image.width(), height: image.height() }), 'pixels' ) const at = (x: number, y: number) => Array.from(pixels.slice((y * image.width() + x) * 4, (y * image.width() + x) * 4 + 4)) expect(at(5, 5)).toEqual([255, 0, 0, 128]) expect(at(25, 5)).toEqual([0, 0, 255, 255]) } finally { image.delete() } } finally { renderer.destroy() } }) test('glyph silhouettes are disposed by cache clearing and renderer destruction', () => { const renderer = new SkiaRenderer( ck, expectDefined(ck.MakeSurface(8, 8), 'glyph cache surface') ) const first = new ck.Path() const second = new ck.Path() let disposedByRenderer = false try { renderer.glyphSilhouetteCache.set('first', first) expect(renderer.glyphSilhouetteCache.peek('first')).toBe(first) renderer.glyphSilhouetteCache.clear() expect(first.isDeleted()).toBe(true) renderer.glyphSilhouetteCache.set('second', second) } finally { try { renderer.destroy() disposedByRenderer = second.isDeleted() } finally { if (!first.isDeleted()) first.delete() if (!second.isDeleted()) second.delete() } } expect(disposedByRenderer).toBe(true) }) test('snaps Figma glyph baselines to device pixels', () => { expect(snapDerivedGlyphBaseline(47.45454406738281)).toBe(47) expect(snapDerivedGlyphBaseline(15.090909004211426)).toBe(15) expect(snapDerivedGlyphBaseline(17.81818199157715)).toBe(18) }) test('uses hard source coverage only for regular 20px derived glyphs', () => { expect(shouldUseHardDerivedGlyphCoverage({ fontSize: 20, fontWeight: 400 })).toBeTrue() expect(shouldUseHardDerivedGlyphCoverage({ fontSize: 20, fontWeight: 600 })).toBeFalse() expect(shouldUseHardDerivedGlyphCoverage({ fontSize: 14, fontWeight: 500 })).toBeFalse() }) test('places derived underlines at Figma-like subpixel coverage bounds', () => { expect(derivedUnderlineRect({ width: 70 }, 17)).toEqual({ x1: 0, y1: 19.75, x2: 69.25, y2: 20.75 }) }) test('draws Figma-derived glyphs even when the font is unavailable', async () => { const graph = new SceneGraph() const page = graph.getPages()[0] const text = graph.createNode('TEXT', page.id, { width: 20, height: 20, text: 'x', fontFamily: '__MissingFont__', fills: [ { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true } ], derivedTextGlyphs: [ { commandsBlob: squareCommandsBlob(), x: 2, y: 12, fontSize: 10 } ] }) const surface = expectDefined(ck.MakeSurface(1, 1), 'surface') const renderer = new SkiaRenderer(ck, surface) useDerivedGlyphFallback(renderer) try { const png = expectDefined( renderNodesToImage(ck, renderer, graph, page.id, [text.id], { scale: 1, format: 'PNG' }), 'png' ) const image = expectDefined(ck.MakeImageFromEncoded(png), 'image') const pixels = expectDefined( image.readPixels(0, 0, { alphaType: ck.AlphaType.Unpremul, colorType: ck.ColorType.RGBA_8888, colorSpace: ck.ColorSpace.SRGB, width: image.width(), height: image.height() }), 'pixels' ) expect(Math.max(...pixels.filter((_, index) => index % 4 === 3))).toBeGreaterThan(0) image.delete() } finally { surface.delete() } }) test('draws underlines for Figma-derived text decorations', async () => { const graph = new SceneGraph() const page = graph.getPages()[0] const text = graph.createNode('TEXT', page.id, { width: 20, height: 24, text: 'x', fontFamily: '__MissingFont__', textDecoration: 'UNDERLINE', fills: [ { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true } ], derivedTextGlyphs: [ { commandsBlob: squareCommandsBlob(), x: 2, y: 12, fontSize: 10 } ] }) const surface = expectDefined(ck.MakeSurface(1, 1), 'surface') const renderer = new SkiaRenderer(ck, surface) useDerivedGlyphFallback(renderer) try { const png = expectDefined( renderNodesToImage(ck, renderer, graph, page.id, [text.id], { scale: 1, format: 'PNG' }), 'png' ) const image = expectDefined(ck.MakeImageFromEncoded(png), 'image') const pixels = expectDefined( image.readPixels(0, 0, { alphaType: ck.AlphaType.Unpremul, colorType: ck.ColorType.RGBA_8888, colorSpace: ck.ColorSpace.SRGB, width: image.width(), height: image.height() }), 'pixels' ) const underlinedRows = [15, 16].map((y) => pixels[(y * image.width() + 8) * 4 + 3]) expect(Math.max(...underlinedRows)).toBeGreaterThan(0) image.delete() } finally { surface.delete() } }) test('does not draw base derived underlines through NONE style runs', async () => { const graph = new SceneGraph() const page = graph.getPages()[0] const text = graph.createNode('TEXT', page.id, { width: 60, height: 28, text: 'on off', fontFamily: '__MissingFont__', textDecoration: 'UNDERLINE', styleRuns: [ { start: 3, length: 3, style: { textDecoration: 'NONE' } } ], fills: [ { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true } ], derivedTextGlyphs: [ { commandsBlob: squareCommandsBlob(), x: 2, y: 12, fontSize: 10 } ] }) const surface = expectDefined(ck.MakeSurface(1, 1), 'surface') const renderer = new SkiaRenderer(ck, surface) useDerivedGlyphFallback(renderer) try { const png = expectDefined( renderNodesToImage(ck, renderer, graph, page.id, [text.id], { scale: 1, format: 'PNG' }), 'png' ) const image = expectDefined(ck.MakeImageFromEncoded(png), 'image') const pixels = expectDefined( image.readPixels(0, 0, { alphaType: ck.AlphaType.Unpremul, colorType: ck.ColorType.RGBA_8888, colorSpace: ck.ColorSpace.SRGB, width: image.width(), height: image.height() }), 'pixels' ) const leftUnderlineAlpha = Math.max( pixels[(15 * image.width() + 12) * 4 + 3] ?? 0, pixels[(16 * image.width() + 12) * 4 + 3] ?? 0 ) const disabledUnderlineAlpha = Math.max( pixels[(15 * image.width() + 45) * 4 + 3] ?? 0, pixels[(16 * image.width() + 45) * 4 + 3] ?? 0 ) expect(leftUnderlineAlpha).toBeGreaterThan(0) expect(disabledUnderlineAlpha).toBe(0) image.delete() } finally { surface.delete() } }) test('draws styled decoration runs for Figma-derived text', async () => { const graph = new SceneGraph() const page = graph.getPages()[0] const text = graph.createNode('TEXT', page.id, { width: 60, height: 28, text: 'red blue', fontFamily: '__MissingFont__', textDecoration: 'UNDERLINE', textDecorationStyle: 'WAVY', textDecorationThickness: 2, textDecorationFills: [ { type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true } ], styleRuns: [ { start: 4, length: 4, style: { textDecorationStyle: 'DOTTED', textDecorationThickness: 3, textDecorationFills: [ { type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 1, visible: true } ] } } ], fills: [ { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true } ], derivedTextGlyphs: [ { commandsBlob: squareCommandsBlob(), x: 2, y: 12, fontSize: 10 } ] }) const surface = expectDefined(ck.MakeSurface(1, 1), 'surface') const renderer = new SkiaRenderer(ck, surface) useDerivedGlyphFallback(renderer) try { const png = expectDefined( renderNodesToImage(ck, renderer, graph, page.id, [text.id], { scale: 1, format: 'PNG' }), 'png' ) const image = expectDefined(ck.MakeImageFromEncoded(png), 'image') const pixels = expectDefined( image.readPixels(0, 0, { alphaType: ck.AlphaType.Unpremul, colorType: ck.ColorType.RGBA_8888, colorSpace: ck.ColorSpace.SRGB, width: image.width(), height: image.height() }), 'pixels' ) let redPixels = 0 let bluePixels = 0 for (let index = 0; index < pixels.length; index += 4) { const red = pixels[index] ?? 0 const green = pixels[index + 1] ?? 0 const blue = pixels[index + 2] ?? 0 const alpha = pixels[index + 3] ?? 0 if (alpha > 0 && red > 150 && green < 80 && blue < 80) redPixels++ if (alpha > 0 && blue > 150 && red < 80 && green < 80) bluePixels++ } expect(redPixels).toBeGreaterThan(0) expect(bluePixels).toBeGreaterThan(0) image.delete() } finally { surface.delete() } }) }) describe('rotated derived glyphs (text-on-path)', () => { test('paints the curved glyph placement on the first render with an already loaded exact font', async () => { const graph = new SceneGraph() const page = expectDefined(graph.getPages()[0], 'page') const text = graph.createNode('TEXT', page.id, { width: 200, height: 200, text: 'x', fontFamily: 'Inter', textPathData: { network: { vertices: [], segments: [], regions: [] }, normalizedSize: { x: 200, y: 200 }, tValue: 0, forward: true }, fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }], derivedTextGlyphs: [ { commandsBlob: squareCommandsBlob(), x: 50, y: 50, fontSize: 20, rotation: -Math.PI / 2 } ] }) const renderer = new SkiaRenderer(ck, expectDefined(ck.MakeSurface(1, 1), 'surface')) try { await renderer.loadFonts() expect(renderer.nodeFontReadiness(text)).toBe('ready') const png = expectDefined( renderNodesToImage(ck, renderer, graph, page.id, [text.id], { scale: 1, format: 'PNG' }), 'png' ) const image = expectDefined(ck.MakeImageFromEncoded(png), 'image') try { const pixels = expectDefined( image.readPixels(0, 0, { alphaType: ck.AlphaType.Unpremul, colorType: ck.ColorType.RGBA_8888, colorSpace: ck.ColorSpace.SRGB, width: image.width(), height: image.height() }), 'pixels' ) const alphaAt = (x: number, y: number) => pixels[(y * image.width() + x) * 4 + 3] // Font-space Y inversion and the stored rotation place the square at [50, 70]². expect(alphaAt(60, 60)).toBe(255) expect(alphaAt(10, 10)).toBe(0) } finally { image.delete() } } finally { renderer.destroy() } }) test('hasRotatedDerivedGlyphs detects non-zero rotation', () => { expect( hasRotatedDerivedGlyphs({ derivedTextGlyphs: [ { commandsBlob: squareCommandsBlob(), x: 0, y: 0, fontSize: 12, rotation: 0 } ] }) ).toBe(false) expect( hasRotatedDerivedGlyphs({ derivedTextGlyphs: [ { commandsBlob: squareCommandsBlob(), x: 0, y: 0, fontSize: 12, rotation: -1.5 } ] }) ).toBe(true) }) test('draws rotated derived glyphs without throwing', async () => { const graph = new SceneGraph() const page = graph.getPages()[0] const text = graph.createNode('TEXT', page.id, { width: 200, height: 200, text: 'x', fontFamily: '__MissingFont__', fills: [ { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true } ], derivedTextGlyphs: [ { commandsBlob: squareCommandsBlob(), x: 50, y: 50, fontSize: 20, rotation: -Math.PI / 2 } ] }) const surface = expectDefined(ck.MakeSurface(1, 1), 'surface') const renderer = new SkiaRenderer(ck, surface) // The font is intentionally missing; baked glyphs only paint once font // resolution is exhausted (see renderText's readiness gate). useDerivedGlyphFallback(renderer) try { const png = expectDefined( renderNodesToImage(ck, renderer, graph, page.id, [text.id], { scale: 1, format: 'PNG' }), 'png' ) // A blank canvas also yields a non-empty PNG, so decode and require the // rotated glyph to actually paint pixels — not merely render without throwing. const image = expectDefined(ck.MakeImageFromEncoded(png), 'image') const pixels = expectDefined( image.readPixels(0, 0, { alphaType: ck.AlphaType.Unpremul, colorType: ck.ColorType.RGBA_8888, colorSpace: ck.ColorSpace.SRGB, width: image.width(), height: image.height() }), 'pixels' ) let paintedPixels = 0 for (let index = 3; index < pixels.length; index += 4) { if ((pixels[index] ?? 0) > 0) paintedPixels++ } image.delete() expect(paintedPixels).toBeGreaterThan(0) } finally { surface.delete() } }) })