openpencil/tests/engine/vue/input/resize/path-text/reflow.test.ts
2026-08-17 12:00:01 +03:00

436 lines
15 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import type { Editor } from '@open-pencil/core/editor'
import { parseFigBuffer } from '@open-pencil/fig'
import { SceneGraph } from '@open-pencil/scene-graph'
import type { Fill, SceneNode, Stroke, VectorNetwork } from '@open-pencil/scene-graph'
import { copyGeometryPaths, copyStrokes } from '@open-pencil/scene-graph/copy'
import { exportFigFile } from '#core/io/formats/fig/export'
import { getTextPathData, layoutPathTextFromAdvances } from '#core/text/path'
import { applyResize, commitResizePreview } from '#vue/shared/input/resize'
import type { DragResize, OrigChildState } from '#vue/shared/input/types'
import { expectDefined } from '#tests/helpers/assert'
const BLACK: Fill = {
type: 'SOLID',
color: { r: 0, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true,
blendMode: 'NORMAL'
}
const WHITE_STROKE: Stroke = {
color: { r: 1, g: 1, b: 1, a: 1 },
weight: 4,
opacity: 1,
visible: true,
align: 'OUTSIDE',
cap: 'NONE',
join: 'MITER',
dashPattern: []
}
const KAPPA = 0.5523
/** 4-cubic circle, radius 100 centered (128,128), in 256x256 normalized space. */
function circleNetwork(): VectorNetwork {
const c = 128
const r = 100
const k = r * KAPPA
return {
vertices: [
{ x: c + r, y: c },
{ x: c, y: c + r },
{ x: c - r, y: c },
{ x: c, y: c - r }
],
segments: [
{ start: 0, end: 1, tangentStart: { x: 0, y: k }, tangentEnd: { x: k, y: 0 } },
{ start: 1, end: 2, tangentStart: { x: -k, y: 0 }, tangentEnd: { x: 0, y: k } },
{ start: 2, end: 3, tangentStart: { x: 0, y: -k }, tangentEnd: { x: -k, y: 0 } },
{ start: 3, end: 0, tangentStart: { x: k, y: 0 }, tangentEnd: { x: 0, y: -k } }
],
regions: []
}
}
/** Minimal move+line outline blob — layout never reads its contents. */
function glyphBlob(): Uint8Array {
const blob = new Uint8Array(2 * 9)
const view = new DataView(blob.buffer)
blob[0] = 1
view.setFloat32(1, 0, true)
view.setFloat32(5, 0, true)
blob[9] = 2
view.setFloat32(10, 1, true)
view.setFloat32(14, 0, true)
return blob
}
function squareGeometryBlob(size: number): Uint8Array {
const blob = new Uint8Array(1 + 4 * 9 + 1)
const view = new DataView(blob.buffer)
const pts = [
[1, 0, 0],
[2, size, 0],
[2, size, size],
[2, 0, size]
] as const
let o = 0
for (const [cmd, x, y] of pts) {
blob[o] = cmd
view.setFloat32(o + 1, x, true)
view.setFloat32(o + 5, y, true)
o += 9
}
blob[o] = 0
return blob
}
describe('group resize reflows path text instead of squashing it', () => {
test('non-uniform group resize: box scales, strokeGeometry clears, glyphs carry no scale', () => {
const graph = new SceneGraph()
const page = expectDefined(graph.getPages()[0])
const group = graph.createNode('GROUP', page.id, { x: 0, y: 0, width: 256, height: 256 })
const box = { x: 0, y: 0, width: 256, height: 256 }
const text = graph.createNode('TEXT', group.id, {
x: 0,
y: 0,
width: 256,
height: 256,
text: 'abc',
fills: [BLACK],
strokes: [WHITE_STROKE],
strokeGeometry: [{ windingRule: 'NONZERO', commandsBlob: squareGeometryBlob(256) }],
textPathBox: { ...box }
})
text.textPathData = {
network: circleNetwork(),
normalizedSize: { x: 256, y: 256 },
tValue: 0.25,
forward: false
}
const data = expectDefined(getTextPathData(text), 'synthetic path data')
const seeds = expectDefined(
layoutPathTextFromAdvances(data, box, 0.25, 0, [
{ commandsBlob: glyphBlob(), fontSize: 40, advance: 0.6 },
{ commandsBlob: glyphBlob(), fontSize: 40, advance: 0.6 },
{ commandsBlob: glyphBlob(), fontSize: 40, advance: 0.6 }
]),
'seed glyphs'
)
text.derivedTextGlyphs = seeds.map((g) => ({
...g,
commandsBlob: new Uint8Array(g.commandsBlob)
}))
const editor = {
graph,
renderer: undefined,
requestRepaint: () => undefined,
updateNode: (id: string, changes: Partial<SceneNode>) => graph.updateNode(id, changes),
commitResize: () => undefined,
commitGroupResize: () => undefined
} as Editor
const origChild: OrigChildState = {
x: 0,
y: 0,
width: 256,
height: 256,
vectorNetwork: null,
fillGeometry: [],
strokeGeometry: copyGeometryPaths(text.strokeGeometry),
derivedTextGlyphs: seeds.map((g) => ({
...g,
commandsBlob: new Uint8Array(g.commandsBlob)
})),
strokes: copyStrokes(text.strokes),
textPathData: structuredClone(expectDefined(text.textPathData)),
textPathBox: { ...box }
}
const drag: DragResize = {
type: 'resize',
handle: 'se',
startX: 256,
startY: 256,
origRect: { x: 0, y: 0, width: 256, height: 256 },
nodeId: group.id,
origVectorNetwork: null,
origFillGeometry: [],
origStrokeGeometry: [],
origDerivedTextGlyphs: null,
origStrokes: [],
origTextPathData: null,
origTextPathBox: null,
origChildren: new Map([[text.id, origChild]])
}
// Drag the SE handle right by 256px: 256x256 -> 512x256 (non-uniform).
applyResize(drag, 512, 256, false, editor)
commitResizePreview(drag, editor)
const committed = expectDefined(graph.getNode(text.id), 'committed text node')
expect(committed.width).toBe(512)
expect(committed.height).toBe(256)
// textPathBox scaled x2 in width only.
const tpb = expectDefined(committed.textPathBox, 'committed textPathBox')
expect(tpb.x).toBeCloseTo(0, 3)
expect(tpb.y).toBeCloseTo(0, 3)
expect(tpb.width).toBeCloseTo(512, 3)
expect(tpb.height).toBeCloseTo(256, 3)
// Reflow cleared the baked silhouettes — the renderer rebuilds per glyph.
expect(committed.strokeGeometry).toEqual([])
const glyphs = expectDefined(committed.derivedTextGlyphs, 'committed glyphs')
expect(glyphs).toHaveLength(3)
for (const g of glyphs) {
expect(g.scaleX).toBeUndefined()
expect(g.scaleY).toBeUndefined()
expect(g.fontSize).toBeCloseTo(40, 3)
}
// The wider path actually re-placed the glyphs.
expect(glyphs.some((g, i) => Math.hypot(g.x - seeds[i].x, g.y - seeds[i].y) > 1)).toBe(true)
// Reflow keeps stroke weight constant (like font size).
expect(committed.strokes[0]?.weight).toBeCloseTo(4, 3)
// Undo payloads snapshot graph state via structuredClone — the committed
// nodes must not carry proxies or non-clonable values.
expect(() => structuredClone(committed)).not.toThrow()
expect(() => structuredClone(expectDefined(graph.getNode(group.id)))).not.toThrow()
})
})
describe('direct (non-group) resize reflows path text instead of squashing it', () => {
test('dragging a TEXT_PATH node itself scales the box, clears strokeGeometry, glyphs carry no scale', () => {
const graph = new SceneGraph()
const page = expectDefined(graph.getPages()[0])
const box = { x: 0, y: 0, width: 256, height: 256 }
const text = graph.createNode('TEXT', page.id, {
x: 0,
y: 0,
width: 256,
height: 256,
text: 'abc',
fills: [BLACK],
strokes: [WHITE_STROKE],
strokeGeometry: [{ windingRule: 'NONZERO', commandsBlob: squareGeometryBlob(256) }],
textPathBox: { ...box }
})
text.textPathData = {
network: circleNetwork(),
normalizedSize: { x: 256, y: 256 },
tValue: 0.25,
forward: false
}
const data = expectDefined(getTextPathData(text), 'synthetic path data')
const seeds = expectDefined(
layoutPathTextFromAdvances(data, box, 0.25, 0, [
{ commandsBlob: glyphBlob(), fontSize: 40, advance: 0.6 },
{ commandsBlob: glyphBlob(), fontSize: 40, advance: 0.6 },
{ commandsBlob: glyphBlob(), fontSize: 40, advance: 0.6 }
]),
'seed glyphs'
)
text.derivedTextGlyphs = seeds.map((g) => ({
...g,
commandsBlob: new Uint8Array(g.commandsBlob)
}))
const editor = {
graph,
renderer: undefined,
requestRepaint: () => undefined,
updateNode: (id: string, changes: Partial<SceneNode>) => graph.updateNode(id, changes),
commitResize: () => undefined,
commitGroupResize: () => undefined
} as Editor
const drag: DragResize = {
type: 'resize',
handle: 'se',
startX: 256,
startY: 256,
origRect: { x: 0, y: 0, width: 256, height: 256 },
nodeId: text.id,
origVectorNetwork: null,
origFillGeometry: [],
origStrokeGeometry: copyGeometryPaths(text.strokeGeometry),
origDerivedTextGlyphs: seeds.map((g) => ({
...g,
commandsBlob: new Uint8Array(g.commandsBlob)
})),
origStrokes: copyStrokes(text.strokes),
origTextPathData: structuredClone(expectDefined(text.textPathData)),
origTextPathBox: { ...box },
origChildren: null
}
// Drag the SE handle right by 256px: 256x256 -> 512x256 (non-uniform).
applyResize(drag, 512, 256, false, editor)
commitResizePreview(drag, editor)
const committed = expectDefined(graph.getNode(text.id), 'committed text node')
expect(committed.width).toBe(512)
expect(committed.height).toBe(256)
const tpb = expectDefined(committed.textPathBox, 'committed textPathBox')
expect(tpb.width).toBeCloseTo(512, 3)
expect(tpb.height).toBeCloseTo(256, 3)
// Reflow cleared the baked silhouettes — the renderer rebuilds per glyph.
expect(committed.strokeGeometry).toEqual([])
const glyphs = expectDefined(committed.derivedTextGlyphs, 'committed glyphs')
expect(glyphs).toHaveLength(3)
for (const g of glyphs) {
expect(g.scaleX).toBeUndefined()
expect(g.scaleY).toBeUndefined()
expect(g.fontSize).toBeCloseTo(40, 3)
}
// The wider path actually re-placed the glyphs.
expect(glyphs.some((g, i) => Math.hypot(g.x - seeds[i].x, g.y - seeds[i].y) > 1)).toBe(true)
// Reflow keeps stroke weight constant (like font size).
expect(committed.strokes[0]?.weight).toBeCloseTo(4, 3)
expect(() => structuredClone(committed)).not.toThrow()
})
})
describe('resize + export integration: real commit path clears stale raw payload', () => {
test('reflowed derivedTextData exports fresh positions, not the stale pre-resize raw blob', async () => {
// Regression test for the bug where clearResizedRawGeometry (which deletes
// rawNodeFields.strokeGeometry on every commit) broke export's reflow
// detection, letting stale raw derivedTextData resurrect pre-resize glyph
// positions in the exported file. Unlike the gated fixture test, this
// drives the REAL resize.ts commit path (not a bare graph.updateNode), so
// clearResizedRawGeometry actually runs before export sees the node.
const graph = new SceneGraph()
const page = expectDefined(graph.getPages()[0])
const box = { x: 0, y: 0, width: 256, height: 256 }
const text = graph.createNode('TEXT', page.id, {
x: 0,
y: 0,
width: 256,
height: 256,
text: 'abc',
fills: [BLACK],
strokes: [WHITE_STROKE],
strokeGeometry: [{ windingRule: 'NONZERO', commandsBlob: squareGeometryBlob(256) }],
textPathBox: { ...box }
})
// applyRawFigmaNodeFields only force-overwrites nc.derivedTextData from raw
// when node.source.id is set — that's the imported-node marker a real .fig
// import stamps. A freshly created (non-imported) node doesn't reach the
// buggy branch, so this must mimic a genuine import to be a real regression
// guard.
text.source.id = '1:2'
text.textPathData = {
network: circleNetwork(),
normalizedSize: { x: 256, y: 256 },
tValue: 0.25,
forward: false
}
const data = expectDefined(getTextPathData(text), 'synthetic path data')
const seeds = expectDefined(
layoutPathTextFromAdvances(data, box, 0.25, 0, [
{ commandsBlob: glyphBlob(), fontSize: 40, advance: 0.6 },
{ commandsBlob: glyphBlob(), fontSize: 40, advance: 0.6 },
{ commandsBlob: glyphBlob(), fontSize: 40, advance: 0.6 }
]),
'seed glyphs'
)
text.derivedTextGlyphs = seeds.map((g) => ({
...g,
commandsBlob: new Uint8Array(g.commandsBlob)
}))
// The rest of the raw payload a real .fig import would carry: stale baked
// derivedTextData + strokeGeometry silhouettes matching the pre-resize seeds.
text.source.fig.rawNodeFields = {
...text.source.fig.rawNodeFields,
derivedTextData: {
glyphs: seeds.map((g) => ({
commandsBlob: { __openPencilFigmaBlob: new Uint8Array(g.commandsBlob) },
position: { x: g.x, y: g.y },
fontSize: g.fontSize,
rotation: g.rotation ?? 0
}))
},
strokeGeometry: [
{ windingRule: 'NONZERO', commandsBlob: { __openPencilFigmaBlob: squareGeometryBlob(256) } }
]
}
const editor = {
graph,
renderer: undefined,
requestRepaint: () => undefined,
updateNode: (id: string, changes: Partial<SceneNode>) => graph.updateNode(id, changes),
commitResize: () => undefined,
commitGroupResize: () => undefined
} as Editor
const drag: DragResize = {
type: 'resize',
handle: 'se',
startX: 256,
startY: 256,
origRect: { x: 0, y: 0, width: 256, height: 256 },
nodeId: text.id,
origVectorNetwork: null,
origFillGeometry: [],
origStrokeGeometry: copyGeometryPaths(text.strokeGeometry),
origDerivedTextGlyphs: seeds.map((g) => ({
...g,
commandsBlob: new Uint8Array(g.commandsBlob)
})),
origStrokes: copyStrokes(text.strokes),
origTextPathData: structuredClone(expectDefined(text.textPathData)),
origTextPathBox: { ...box },
origChildren: null
}
// Drag the SE handle right by 256px: 256x256 -> 512x256 (non-uniform).
applyResize(drag, 512, 256, false, editor)
commitResizePreview(drag, editor)
const committed = expectDefined(graph.getNode(text.id), 'committed text node')
const committedGlyphs = expectDefined(committed.derivedTextGlyphs, 'committed glyphs')
// Sanity: the real resize path actually reflowed (moved) the glyphs.
expect(committedGlyphs.some((g, i) => Math.hypot(g.x - seeds[i].x, g.y - seeds[i].y) > 1)).toBe(
true
)
const out = await exportFigFile(graph)
const reparsed = parseFigBuffer(out)
const exported = expectDefined(reparsed.nodeChanges.find((nc) => nc.type === 'TEXT_PATH'))
// No stale silhouette geometry resurrected from the raw payload.
expect(exported.strokeGeometry).toBeUndefined()
// Exported derivedTextData must match the REFLOWED positions, not the
// stale pre-resize raw blob set up above.
const exportedGlyphs = expectDefined(exported.derivedTextData?.glyphs, 'exported glyphs')
expect(exportedGlyphs.length).toBe(committedGlyphs.length)
for (let i = 0; i < committedGlyphs.length; i++) {
expect(expectDefined(exportedGlyphs[i]?.position?.x)).toBeCloseTo(committedGlyphs[i].x, 2)
expect(expectDefined(exportedGlyphs[i]?.position?.y)).toBeCloseTo(committedGlyphs[i].y, 2)
}
// ...and specifically NOT the stale seed positions (the bug this guards against).
expect(exportedGlyphs.some((g, i) => Math.abs((g.position?.x ?? 0) - seeds[i].x) > 5)).toBe(
true
)
})
})