fix(canvas): preserve path text with loaded fonts

This commit is contained in:
Danila Poyarkov 2026-09-16 00:59:26 +03:00
parent 2e66792c59
commit c361ec3c87
5 changed files with 113 additions and 44 deletions

View file

@ -947,16 +947,15 @@ function shouldClipTextToLayoutBox(node: SceneNode): boolean {
)
}
function drawSubstitutedPathText(
function drawResolvedPathText(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
fontReadiness: ReturnType<SkiaRenderer['nodeFontReadiness']>
): 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
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

After

Width:  |  Height:  |  Size: 250 KiB

View file

@ -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<void> {
await editor.canvas.waitForRender()
editor.canvas.assertNoErrors()
expect(await editor.canvas.screenshotCanvasRegion()).toMatchSnapshot(`${name}.png`)
async function expectCanvas(canvas: CanvasHelper, name: string): Promise<void> {
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')
})

View file

@ -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({

View file

@ -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()