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, uint8ArrayToArrayBuffer } 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(uint8ArrayToArrayBuffer(out)) } 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 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) }) })