diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index 1f8bc3973..f51ee486f 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -947,16 +947,15 @@ function shouldClipTextToLayoutBox(node: SceneNode): boolean { ) } -function drawSubstitutedPathText( +function drawResolvedPathText( r: SkiaRenderer, canvas: Canvas, node: SceneNode, fontReadiness: ReturnType ): boolean { return ( - fontReadiness === 'substituted' && - node.textPathData !== null && - drawDerivedText(r, canvas, node) + // Resolving the exact face must not replace curved glyph placement with a straight paragraph. + fontReadiness !== 'exhausted' && node.textPathData !== null && drawDerivedText(r, canvas, node) ) } @@ -974,7 +973,7 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode, fil canvas.restore() return } - if (drawSubstitutedPathText(r, canvas, node, fontReadiness)) { + if (drawResolvedPathText(r, canvas, node, fontReadiness)) { canvas.restore() return } diff --git a/tests/e2e/canvas/text-path-visual.spec.ts-snapshots/text-path-rendering-and-selection-openpencil-darwin.png b/tests/e2e/canvas/text-path-visual.spec.ts-snapshots/text-path-rendering-and-selection-openpencil-darwin.png index 5e8b5d15a..1088c0ce8 100644 Binary files a/tests/e2e/canvas/text-path-visual.spec.ts-snapshots/text-path-rendering-and-selection-openpencil-darwin.png and b/tests/e2e/canvas/text-path-visual.spec.ts-snapshots/text-path-rendering-and-selection-openpencil-darwin.png differ diff --git a/tests/e2e/fonts/substitution/editing-visual.spec.ts b/tests/e2e/fonts/substitution/editing-visual.spec.ts index 6aa147f6b..19aece1a6 100644 --- a/tests/e2e/fonts/substitution/editing-visual.spec.ts +++ b/tests/e2e/fonts/substitution/editing-visual.spec.ts @@ -1,14 +1,21 @@ -import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures' +import { expect, test } from '#tests/e2e/fixtures' +import { CanvasHelper } from '#tests/helpers/canvas' +import { trackFontModuleResources } from '#tests/helpers/fonts/runtime' -const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers') - -async function expectCanvas(name: string): Promise { - await editor.canvas.waitForRender() - editor.canvas.assertNoErrors() - expect(await editor.canvas.screenshotCanvasRegion()).toMatchSnapshot(`${name}.png`) +async function expectCanvas(canvas: CanvasHelper, name: string): Promise { + await canvas.waitForRender() + canvas.assertNoErrors() + expect(await canvas.screenshotCanvasRegion()).toMatchSnapshot(`${name}.png`) } -test('first edit replaces baked missing-font glyphs with visible live substitution', async () => { +test('first edit replaces baked missing-font glyphs with visible live substitution', async ({ + page +}) => { + await trackFontModuleResources(page) + await page.goto('/?test&no-chrome&no-rulers') + const editor = { page, canvas: new CanvasHelper(page) } + await editor.canvas.waitForInit() + await editor.canvas.clearCanvas() const textId = await editor.page.evaluate(() => { const store = window.openPencil?.getStore?.() if (!store?.renderer) throw new Error('OpenPencil renderer not initialized') @@ -52,13 +59,13 @@ test('first edit replaces baked missing-font glyphs with visible live substituti const node = store?.graph.getNode(id) return node ? store?.renderer?.nodeFontReadiness(node) === 'substituted' : false }, textId) - await expectCanvas('missing-font-baked-before-edit') + await expectCanvas(editor.canvas, 'missing-font-baked-before-edit') await editor.page.keyboard.press('Enter') const textarea = editor.page.locator('textarea[aria-hidden="true"]') await textarea.fill('!') await textarea.dispatchEvent('input') - await expectCanvas('missing-font-live-substitution-after-first-edit') + await expectCanvas(editor.canvas, 'missing-font-live-substitution-after-first-edit') const edited = await editor.page.evaluate((id) => { const store = window.openPencil?.getStore?.() @@ -81,5 +88,5 @@ test('first edit replaces baked missing-font glyphs with visible live substituti await editor.page.keyboard.press('Escape') await editor.canvas.undo() - await expectCanvas('missing-font-substitution-after-undo') + await expectCanvas(editor.canvas, 'missing-font-substitution-after-undo') }) diff --git a/tests/engine/render/canvas/derived-text.test.ts b/tests/engine/render/canvas/derived-text.test.ts index 6cf81018c..b13f3d2d3 100644 --- a/tests/engine/render/canvas/derived-text.test.ts +++ b/tests/engine/render/canvas/derived-text.test.ts @@ -382,6 +382,66 @@ describe('derived text rendering', () => { }) 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({ diff --git a/tests/engine/render/canvas/text.test.ts b/tests/engine/render/canvas/text.test.ts index 725b2ff50..9072813b3 100644 --- a/tests/engine/render/canvas/text.test.ts +++ b/tests/engine/render/canvas/text.test.ts @@ -232,36 +232,39 @@ describe('renderText', () => { 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 - } - ] - }) + test.each(['ready', 'substituted'] as const)( + 'keeps derived path-text glyphs when its face is %s', + (readiness) => { + const base = createMockRenderer() + const r = createMockRenderer({ + nodeFontReadiness: mock(() => readiness), + 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) + renderText(r, canvas as never, node) - expect(r.buildParagraph).not.toHaveBeenCalled() - }) + expect(r.buildParagraph).not.toHaveBeenCalled() + } + ) test('uses baked text pictures after font resolution is exhausted', () => { const r = createMockRenderer({ nodeFontReadiness: mock(() => 'exhausted') }) const canvas = createMockCanvas()