openpencil/tests/engine/io/fig/export/path-text-reflow-export.test.ts
2026-08-17 12:00:01 +03:00

100 lines
4.1 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { parseFigBuffer } from '@open-pencil/fig'
import { exportFigFile } from '#core/io/formats/fig/export'
import { calibratePathTextLayout, getTextPathData, reflowPathTextGlyphs } from '#core/text/path'
import { expectDefined } from '#tests/helpers/assert'
import { loadFigFixture } from '#tests/helpers/fig-fixtures'
const LOCAL_CIRCLE_TEXT = 'tests/fixtures/circle-text.fig'
/** exportFigFile returns a Uint8Array; parseFigBuffer takes an ArrayBuffer. */
function reparse(out: Uint8Array) {
return parseFigBuffer(out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength))
}
describe('TEXT_PATH reflow export hygiene (optional local)', () => {
test('reflowed node exports no strokeGeometry and reflowed glyph positions', async () => {
const graph = await loadFigFixture(LOCAL_CIRCLE_TEXT)
if (!graph) return
const node = expectDefined(
[...graph.getAllNodes()].find((n) => n.name === 'ArnoCoenen.art'),
'path text node'
)
const data = expectDefined(getTextPathData(node), 'text path data')
const box = expectDefined(node.textPathBox, 'textPathBox')
const origGlyphs = expectDefined(node.derivedTextGlyphs, 'imported glyphs')
const layout = expectDefined(calibratePathTextLayout(origGlyphs, data, box), 'layout')
const halfBox = {
x: box.x * 0.5,
y: box.y * 0.5,
width: box.width * 0.5,
height: box.height * 0.5
}
const reflowed = expectDefined(
reflowPathTextGlyphs(origGlyphs, data, layout, halfBox),
'reflowed glyphs'
)
// Sanity: reflow at half size must actually move the glyphs.
expect(reflowed.some((g, i) => Math.abs(g.x - origGlyphs[i].x) > 1)).toBe(true)
// Simulate the state resize.ts's reflow leaves behind: scene strokeGeometry
// emptied while rawNodeFields still holds the ORIGINAL baked silhouettes
// plus vectorData/textPathStart. preserveSourceMetadataDuring keeps the raw
// fields — a bare updateNode would wipe them (clearEditedSourceMetadata)
// and hide exactly the stale-silhouette fallback this test guards against.
graph.preserveSourceMetadataDuring(() => {
graph.updateNode(node.id, {
strokeGeometry: [],
textPathBox: halfBox,
derivedTextGlyphs: reflowed
})
})
const updated = expectDefined(graph.getNode(node.id), 'updated node')
expect(updated.strokeGeometry.length).toBe(0)
expect('strokeGeometry' in updated.source.fig.rawNodeFields).toBe(true)
const out = await exportFigFile(graph)
const reparsed = reparse(out)
const exported = expectDefined(
reparsed.nodeChanges.find((nc) => nc.name === 'ArnoCoenen.art'),
'exported node change'
)
expect(exported.type).toBe('TEXT_PATH')
// No strokeGeometry at all: neither from the (empty) scene field nor from
// stale rawNodeFields — silhouettes are re-derived from the glyphs.
expect(exported.strokeGeometry).toBeUndefined()
// derivedTextData carries the reflowed positions, not raw pre-resize ones.
const glyphs = expectDefined(exported.derivedTextData?.glyphs, 'exported glyphs')
expect(glyphs.length).toBe(reflowed.length)
for (let i = 0; i < reflowed.length; i++) {
expect(expectDefined(glyphs[i]?.position?.x)).toBeCloseTo(reflowed[i].x, 2)
expect(expectDefined(glyphs[i]?.position?.y)).toBeCloseTo(reflowed[i].y, 2)
}
// The layout path payload survives so reimport can reflow again.
const rawExported = exported as typeof exported & Record<string, unknown>
expect(rawExported.vectorData).toBeDefined()
expect(rawExported.textPathStart).toBeDefined()
})
test('unmodified fixture still round-trips WITH strokeGeometry', async () => {
const graph = await loadFigFixture(LOCAL_CIRCLE_TEXT)
if (!graph) return
const out = await exportFigFile(graph)
const reparsed = reparse(out)
const exported = expectDefined(
reparsed.nodeChanges.find((nc) => nc.name === 'ArnoCoenen.art'),
'exported node change'
)
expect(exported.type).toBe('TEXT_PATH')
expect(exported.strokeGeometry?.length ?? 0).toBeGreaterThan(0)
})
})