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

291 lines
9.3 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { parseFigBuffer } from '@open-pencil/fig'
import { convertFigmaDerivedTextGlyphs, nodeChangeToProps } from '@open-pencil/fig/node-change'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { SceneGraph } from '@open-pencil/scene-graph'
import type { Vector } from '@open-pencil/scene-graph/primitives'
import { exportFigFile } from '#core/io/formats/fig/export'
import { encodeVectorNetworkBlob } from '#core/vector'
import { expectDefined } from '#tests/helpers/assert'
import { loadFigFixture } from '#tests/helpers/fig-fixtures'
/** exportFigFile returns a Uint8Array; parseFigBuffer takes an ArrayBuffer. */
function reparse(out: Uint8Array) {
return parseFigBuffer(out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength))
}
function emptyBlob(size = 8): Uint8Array {
return new Uint8Array(size)
}
describe('TEXT_PATH import mapping', () => {
test('maps TEXT_PATH to TEXT and materializes format-neutral path data', () => {
const networkBlob = encodeVectorNetworkBlob({
vertices: [
{ x: 0, y: 0 },
{ x: 100, y: 0 }
],
segments: [{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }],
regions: []
})
const props = nodeChangeToProps(
{
type: 'TEXT_PATH',
name: 'Along path',
fontSize: 24,
textData: { characters: 'Hello' },
size: { x: 100, y: 100 },
vectorData: {
vectorNetworkBlob: 0,
normalizedSize: { x: 100, y: 100 },
styleOverrideTable: []
},
textPathStart: { tValue: 0.25, forward: false },
fillPaints: [
{
type: 'SOLID',
color: { r: 0, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true,
blendMode: 'NORMAL'
}
]
} as NodeChange,
[networkBlob]
)
expect(props.nodeType).toBe('TEXT')
expect(props.name).toBe('Along path')
expect(props.text).toBe('Hello')
expect(props.textPathData?.tValue).toBe(0.25)
expect(props.textPathData?.forward).toBe(false)
expect(props.textPathData?.network.vertices).toHaveLength(2)
})
test('preserves glyph rotation from derivedTextData', () => {
const blob = emptyBlob(16)
const glyphs = convertFigmaDerivedTextGlyphs(
{
glyphs: [
{
commandsBlob: 0,
position: { x: 10, y: 20 },
fontSize: 80,
rotation: -1.75
},
{
commandsBlob: 0,
position: { x: 12, y: 22 },
fontSize: 80,
rotation: 0
}
]
},
[blob]
)
expect(glyphs).toHaveLength(2)
expect(glyphs[0]?.rotation).toBeCloseTo(-1.75, 5)
expect(glyphs[0]?.x).toBe(10)
expect(glyphs[0]?.y).toBe(20)
expect(glyphs[0]?.fontSize).toBe(80)
expect(glyphs[1]?.rotation).toBe(0)
})
})
const LOCAL_CIRCLE_TEXT = 'tests/fixtures/circle-text.fig'
describe('TEXT_PATH real fixture (optional local)', () => {
test('imports circular path text without RECTANGLE occlusion type', async () => {
const graph = await loadFigFixture(LOCAL_CIRCLE_TEXT)
if (!graph) return
const pathText = expectDefined(
[...graph.getAllNodes()].find((n) => n.name === 'ArnoCoenen.art'),
'path text node'
)
expect(pathText.type).toBe('TEXT')
expect(pathText.type).not.toBe('RECTANGLE')
expect(pathText.text).toContain('ArnoCoenen')
expect(pathText.textPathData).not.toBeNull()
expect(pathText.derivedTextGlyphs?.length).toBeGreaterThan(0)
expect(pathText.derivedTextGlyphs?.some((g) => (g.rotation ?? 0) !== 0)).toBe(true)
expect(pathText.strokeGeometry.length).toBeGreaterThan(0)
// Multi-color logo sibling must remain a VECTOR (not covered by type fallback)
const icon = expectDefined(
[...graph.getAllNodes()].find((n) => n.name === 'ArnoIcon' && n.type === 'VECTOR'),
'ArnoIcon vector'
)
expect(icon.fillGeometry.length).toBeGreaterThan(0)
// Unedited export preserves TEXT_PATH type
const out = await exportFigFile(graph)
const reparsed = reparse(out)
const exported = reparsed.nodeChanges.find((nc) => nc.name === 'ArnoCoenen.art')
expect(exported?.type).toBe('TEXT_PATH')
const rotations = exported?.derivedTextData?.glyphs?.map((g) => g.rotation ?? 0) ?? []
expect(rotations.some((r) => r !== 0)).toBe(true)
})
})
function unitSquareBlob(): Uint8Array {
const blob = new Uint8Array(1 + 4 * 9 + 1)
const view = new DataView(blob.buffer)
const pts = [
[1, 0, 0],
[2, 1, 0],
[2, 1, 1],
[2, 0, 1]
] as const
let offset = 0
for (const [cmd, x, y] of pts) {
blob[offset] = cmd
view.setFloat32(offset + 1, x, true)
view.setFloat32(offset + 5, y, true)
offset += 9
}
return blob
}
describe('TEXT_PATH resize export', () => {
test('bakes glyph scaleX/scaleY into blobs — Kiwi Glyph has no scale field', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
graph.createNode('TEXT', page.id, {
text: 'A',
width: 100,
height: 100,
textPathData: {
network: { vertices: [], segments: [], regions: [] },
normalizedSize: { x: 100, y: 100 },
tValue: 0,
forward: true
},
derivedTextGlyphs: [
{
commandsBlob: unitSquareBlob(),
x: 10,
y: 20,
fontSize: 40,
rotation: Math.PI / 2,
scaleX: 0.5,
scaleY: 1
}
]
})
const out = await exportFigFile(graph)
const reparsed = reparse(out)
const exported = expectDefined(reparsed.nodeChanges.find((nc) => nc.type === 'TEXT_PATH'))
const glyph = expectDefined(exported.derivedTextData?.glyphs?.[0])
const blob = expectDefined(reparsed.blobs[expectDefined(glyph.commandsBlob)])
// At θ=π/2 the horizontal squash lands on the glyph's local Y axis:
// (1,1) → (1,0.5). Without baking, reload would draw unscaled shapes at
// scaled positions (the DomeSticker save/reopen garble).
const view = new DataView(blob.buffer, blob.byteOffset)
expect(view.getFloat32(2 * 9 + 1, true)).toBeCloseTo(1, 4)
expect(view.getFloat32(2 * 9 + 5, true)).toBeCloseTo(0.5, 4)
})
})
describe('TEXT_PATH raw payload round-trip (synthetic)', () => {
test('vectorData + textPathStart survive .fig export unchanged', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const networkBlob = encodeVectorNetworkBlob({
vertices: [
{ x: 0, y: 0 },
{ x: 100, y: 0 }
],
segments: [{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }],
regions: []
})
const node = graph.createNode('TEXT', page.id, {
text: 'A',
width: 100,
height: 100,
textPathData: {
network: {
vertices: [
{ x: 0, y: 0 },
{ x: 100, y: 0 }
],
segments: [
{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
],
regions: []
},
normalizedSize: { x: 100, y: 100 },
tValue: 0.3,
forward: false
},
derivedTextGlyphs: [
{ commandsBlob: unitSquareBlob(), x: 10, y: 20, fontSize: 40, rotation: 0 }
]
})
node.source.fig.rawNodeFields = {
vectorData: {
vectorNetworkBlob: { __openPencilFigmaBlob: networkBlob },
normalizedSize: { x: 100, y: 100 }
},
textPathStart: { tValue: 0.3, forward: false }
}
const out = await exportFigFile(graph)
const reparsed = reparse(out)
const exported = expectDefined(reparsed.nodeChanges.find((nc) => nc.type === 'TEXT_PATH'))
const rawExported = exported as typeof exported & Record<string, unknown>
const vectorData = rawExported.vectorData as {
vectorNetworkBlob?: number
normalizedSize?: Vector
}
expect(vectorData.normalizedSize).toEqual({ x: 100, y: 100 })
const blobIndex = expectDefined(vectorData.vectorNetworkBlob, 'vectorNetworkBlob index')
const exportedBlob = expectDefined(reparsed.blobs[blobIndex], 'exported network blob')
expect(Buffer.from(exportedBlob).equals(Buffer.from(networkBlob))).toBe(true)
const textPathStart = rawExported.textPathStart as { tValue?: number; forward?: boolean }
expect(textPathStart.tValue).toBeCloseTo(0.3, 6)
expect(textPathStart.forward).toBe(false)
})
})
describe('TEXT_PATH edit invalidation', () => {
test('clears format-neutral path data when text content is edited', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const node = graph.createNode('TEXT', page.id, {
text: 'ArnoCoenen.art',
textPathBox: { x: 0, y: 0, width: 100, height: 100 },
textPathData: {
network: { vertices: [], segments: [], regions: [] },
normalizedSize: { x: 1, y: 1 },
tValue: 0,
forward: true
},
derivedTextGlyphs: [
{
commandsBlob: emptyBlob(),
x: 0,
y: 0,
fontSize: 80,
rotation: -1.5
}
]
})
graph.updateNode(node.id, { text: 'Edited' })
const updated = expectDefined(graph.getNode(node.id))
expect(updated.derivedTextGlyphs).toBeNull()
expect(updated.textPathData).toBeNull()
expect(updated.textPathBox).not.toBeNull()
})
})