diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index af3217fdd..39af5af7f 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -6,8 +6,8 @@ import type { Color } from '#core/types' import { vectorNetworkToCenterlinePath } from '#core/vector' import { renderBooleanOperation } from './boolean' -import { nodeHasRadius } from './shapes' import type { SkiaRenderer, RenderOverlays } from './renderer' +import { nodeHasRadius } from './shapes' import { drawDashedRRectWithSolidCorners, drawStyledRRectStroke, @@ -557,15 +557,6 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode, fil } const paragraphY = 0 - if (!r.isNodeFontLoaded(node)) { - canvas.restore() - return - } - if (isGradientFill(fill) && drawGradientText(r, canvas, node, paragraphY)) { - canvas.restore() - return - } - if (node.textPicture) { const pic = r.ck.MakePicture(node.textPicture) if (pic) { @@ -579,6 +570,15 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode, fil canvas.restore() return } + + if (!r.isNodeFontLoaded(node)) { + canvas.restore() + return + } + if (isGradientFill(fill) && drawGradientText(r, canvas, node, paragraphY)) { + canvas.restore() + return + } if (r.fontsLoaded && r.fontProvider) { const paragraph = r.buildParagraph(node, r.fillPaint.getColor()) canvas.drawParagraph(paragraph, 0, paragraphY) diff --git a/tests/engine/render/canvas/derived-text.test.ts b/tests/engine/render/canvas/derived-text.test.ts index bbb8057b6..4f411901e 100644 --- a/tests/engine/render/canvas/derived-text.test.ts +++ b/tests/engine/render/canvas/derived-text.test.ts @@ -1,11 +1,98 @@ -import { describe, expect, test } from 'bun:test' +import { beforeAll, describe, expect, test } from 'bun:test' +import { renderNodesToImage, SceneGraph, SkiaRenderer } from '@open-pencil/core' + +import { initCanvasKit } from '#cli/headless' import { snapFigmaDerivedGlyphBaseline } from '#core/canvas/text-derived' +import { expectDefined } from '#tests/helpers/assert' + +let ck: Awaited> + +beforeAll(async () => { + ck = await initCanvasKit() +}) + +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('snaps Figma glyph baselines to device pixels', () => { expect(snapFigmaDerivedGlyphBaseline(47.45454406738281)).toBe(47) expect(snapFigmaDerivedGlyphBaseline(15.090909004211426)).toBe(15) expect(snapFigmaDerivedGlyphBaseline(17.81818199157715)).toBe(18) }) + + 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 + } + ], + figmaDerivedTextGlyphs: [ + { + commandsBlob: squareCommandsBlob(), + x: 2, + y: 12, + fontSize: 10 + } + ] + }) + + const surface = expectDefined(ck.MakeSurface(1, 1), 'surface') + const renderer = new SkiaRenderer(ck, surface) + + 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() + } + }) })