* build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
549 lines
17 KiB
TypeScript
549 lines
17 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
|
|
import { renderNodesToPPTX, type PPTXExportStats } from '@open-pencil/core'
|
|
import { BUILTIN_IO_FORMATS } from '@open-pencil/core/io'
|
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
|
|
|
import {
|
|
SLIDE_WIDTH_IN,
|
|
TINY_PNG,
|
|
inches,
|
|
makeGraph,
|
|
makeSlideFrame,
|
|
pageId,
|
|
placements,
|
|
slideXML,
|
|
solidFill,
|
|
solidStroke,
|
|
stubRasterize,
|
|
unzipPPTX
|
|
} from './helpers'
|
|
|
|
describe('renderNodesToPPTX()', () => {
|
|
test('returns null for empty or hidden selection', async () => {
|
|
const graph = makeGraph()
|
|
expect(
|
|
await renderNodesToPPTX(graph, pageId(graph), [], { rasterize: stubRasterize })
|
|
).toBeNull()
|
|
|
|
const hidden = graph.createNode('FRAME', pageId(graph), {
|
|
width: 100,
|
|
height: 100,
|
|
visible: false
|
|
})
|
|
expect(
|
|
await renderNodesToPPTX(graph, pageId(graph), [hidden.id], { rasterize: stubRasterize })
|
|
).toBeNull()
|
|
})
|
|
|
|
test('one slide per top-level frame', async () => {
|
|
const graph = makeGraph()
|
|
const a = makeSlideFrame(graph, 'Slide A')
|
|
const b = makeSlideFrame(graph, 'Slide B')
|
|
const data = await renderNodesToPPTX(graph, pageId(graph), [a.id, b.id], {
|
|
rasterize: stubRasterize
|
|
})
|
|
expect(data).not.toBeNull()
|
|
if (!data) return
|
|
const files = unzipPPTX(data)
|
|
expect(files['ppt/presentation.xml']).toBeDefined()
|
|
expect(files['ppt/slides/slide1.xml']).toBeDefined()
|
|
expect(files['ppt/slides/slide2.xml']).toBeDefined()
|
|
expect(files['ppt/slides/slide3.xml']).toBeUndefined()
|
|
})
|
|
|
|
test('text becomes a native run with styles preserved', async () => {
|
|
const graph = makeGraph()
|
|
const frame = makeSlideFrame(graph, 'Slide')
|
|
graph.createNode('TEXT', frame.id, {
|
|
text: 'Hello PPTX',
|
|
width: 400,
|
|
height: 60,
|
|
x: 100,
|
|
y: 100,
|
|
fontSize: 32,
|
|
fontFamily: 'Inter',
|
|
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }],
|
|
styleRuns: [{ start: 0, length: 5, style: { fontWeight: 700 } }]
|
|
})
|
|
const data = await renderNodesToPPTX(graph, pageId(graph), [frame.id], {
|
|
rasterize: stubRasterize
|
|
})
|
|
expect(data).not.toBeNull()
|
|
if (!data) return
|
|
const xml = slideXML(unzipPPTX(data), 1)
|
|
expect(xml).toContain('Hello')
|
|
expect(xml).toContain('PPTX')
|
|
// Bold partial run survives as its own <a:r> with b="1".
|
|
expect(xml).toContain('b="1"')
|
|
expect(xml).toContain('FF0000')
|
|
expect(xml).toContain('Inter')
|
|
})
|
|
|
|
test('solid shapes become native shapes, gradients fall back to images', async () => {
|
|
const graph = makeGraph()
|
|
const frame = makeSlideFrame(graph, 'Slide')
|
|
graph.createNode('RECTANGLE', frame.id, {
|
|
width: 200,
|
|
height: 100,
|
|
x: 40,
|
|
y: 40,
|
|
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 1, visible: true }]
|
|
})
|
|
graph.createNode('RECTANGLE', frame.id, {
|
|
width: 200,
|
|
height: 100,
|
|
x: 40,
|
|
y: 200,
|
|
fills: [
|
|
{
|
|
type: 'GRADIENT_LINEAR',
|
|
color: { r: 0, g: 0, b: 0, a: 1 },
|
|
opacity: 1,
|
|
visible: true,
|
|
gradientStops: [
|
|
{ position: 0, color: { r: 1, g: 0, b: 0, a: 1 } },
|
|
{ position: 1, color: { r: 0, g: 0, b: 1, a: 1 } }
|
|
]
|
|
}
|
|
]
|
|
})
|
|
|
|
let stats: PPTXExportStats | null = null
|
|
const data = await renderNodesToPPTX(graph, pageId(graph), [frame.id], {
|
|
rasterize: stubRasterize,
|
|
onStats: (s) => {
|
|
stats = s
|
|
}
|
|
})
|
|
expect(data).not.toBeNull()
|
|
if (!data) return
|
|
const files = unzipPPTX(data)
|
|
const xml = slideXML(files, 1)
|
|
expect(xml).toContain('0000FF')
|
|
// Gradient rect got rasterized into a media image.
|
|
const media = Object.keys(files).filter((f) => f.startsWith('ppt/media/') && !f.endsWith('/'))
|
|
expect(media.length).toBeGreaterThan(0)
|
|
expect(stats).not.toBeNull()
|
|
if (!stats) return
|
|
const reported: PPTXExportStats = stats
|
|
expect(reported.editable).toBeGreaterThan(0)
|
|
expect(reported.fallback).toBe(1)
|
|
expect(reported.fallbackReasons['gradient fill']).toBe(1)
|
|
})
|
|
|
|
test('vector-only container (icon) rasterizes as one image, not per path', async () => {
|
|
const graph = makeGraph()
|
|
const frame = makeSlideFrame(graph, 'Slide')
|
|
const iconFrame = graph.createNode('FRAME', frame.id, {
|
|
name: 'Icon / lucide:heart',
|
|
x: 40,
|
|
y: 40,
|
|
width: 48,
|
|
height: 48,
|
|
fills: []
|
|
})
|
|
for (let i = 0; i < 3; i++) {
|
|
graph.createNode('VECTOR', iconFrame.id, {
|
|
name: 'path',
|
|
width: 48,
|
|
height: 48,
|
|
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
|
|
})
|
|
}
|
|
|
|
let stats: PPTXExportStats | null = null
|
|
const rasterCalls: string[][] = []
|
|
const data = await renderNodesToPPTX(graph, pageId(graph), [frame.id], {
|
|
rasterize: (ids) => {
|
|
rasterCalls.push(ids)
|
|
return Promise.resolve(TINY_PNG)
|
|
},
|
|
onStats: (s) => {
|
|
stats = s
|
|
}
|
|
})
|
|
expect(data).not.toBeNull()
|
|
if (!data) return
|
|
// The icon frame rasterized once (whole container), not once per path.
|
|
expect(rasterCalls).toEqual([[iconFrame.id]])
|
|
const media = Object.keys(unzipPPTX(data)).filter(
|
|
(f) => f.startsWith('ppt/media/') && !f.endsWith('/')
|
|
)
|
|
expect(media).toHaveLength(1)
|
|
expect(stats).not.toBeNull()
|
|
if (!stats) return
|
|
const reported: PPTXExportStats = stats
|
|
expect(reported.fallbackReasons['vector graphics']).toBe(1)
|
|
})
|
|
|
|
test('a child of a rotated frame keeps the inherited rotation and stays centered', async () => {
|
|
const graph = makeGraph()
|
|
const frame = makeSlideFrame(graph, 'Slide')
|
|
const rotated = graph.createNode('FRAME', frame.id, {
|
|
name: 'Rotated',
|
|
x: 400,
|
|
y: 200,
|
|
width: 200,
|
|
height: 100,
|
|
rotation: 90,
|
|
fills: []
|
|
})
|
|
graph.createNode('RECTANGLE', rotated.id, {
|
|
width: 200,
|
|
height: 100,
|
|
fills: [solidFill(0, 0, 1)]
|
|
})
|
|
|
|
const data = await renderNodesToPPTX(graph, pageId(graph), [frame.id], {
|
|
rasterize: stubRasterize
|
|
})
|
|
expect(data).not.toBeNull()
|
|
if (!data) return
|
|
const [rect, ...rest] = placements(slideXML(unzipPPTX(data), 1))
|
|
expect(rest).toHaveLength(0)
|
|
// Rotation comes from the parent frame, and the rect is centered on it —
|
|
// the unrotated box origin, not the rotated corner at (550, 150).
|
|
expect(rect.rot).toBeCloseTo(90, 3)
|
|
expect(rect.centerX).toBeCloseTo(inches(500), 3)
|
|
expect(rect.centerY).toBeCloseTo(inches(250), 3)
|
|
expect(rect.width).toBeCloseTo(inches(200), 3)
|
|
expect(rect.height).toBeCloseTo(inches(100), 3)
|
|
})
|
|
|
|
test('a root frame with overflowing clipped content rasterizes as one image', async () => {
|
|
const graph = makeGraph()
|
|
const frame = makeSlideFrame(graph, 'Clipped slide')
|
|
frame.clipsContent = true
|
|
graph.createNode('RECTANGLE', frame.id, {
|
|
x: 1200,
|
|
y: 40,
|
|
width: 200,
|
|
height: 100,
|
|
fills: [solidFill(1, 0, 0)]
|
|
})
|
|
|
|
let stats: PPTXExportStats | null = null
|
|
const rasterCalls: string[][] = []
|
|
const data = await renderNodesToPPTX(graph, pageId(graph), [frame.id], {
|
|
rasterize: (ids) => {
|
|
rasterCalls.push(ids)
|
|
return Promise.resolve(TINY_PNG)
|
|
},
|
|
onStats: (value) => {
|
|
stats = value
|
|
}
|
|
})
|
|
expect(data).not.toBeNull()
|
|
if (!data) return
|
|
expect(rasterCalls).toEqual([[frame.id]])
|
|
expect(slideXML(unzipPPTX(data), 1)).not.toContain('FF0000')
|
|
expect(stats).not.toBeNull()
|
|
if (!stats) return
|
|
const reported: PPTXExportStats = stats
|
|
expect(reported.fallbackReasons['clipped content']).toBe(1)
|
|
})
|
|
|
|
test('a root frame containing a mask rasterizes as one image', async () => {
|
|
const graph = makeGraph()
|
|
const frame = makeSlideFrame(graph, 'Masked slide')
|
|
graph.createNode('RECTANGLE', frame.id, {
|
|
width: 200,
|
|
height: 200,
|
|
isMask: true,
|
|
fills: [solidFill(0, 0, 0)]
|
|
})
|
|
graph.createNode('RECTANGLE', frame.id, {
|
|
x: 100,
|
|
y: 100,
|
|
width: 300,
|
|
height: 300,
|
|
fills: [solidFill(1, 0, 0)]
|
|
})
|
|
|
|
let stats: PPTXExportStats | null = null
|
|
const rasterCalls: string[][] = []
|
|
const data = await renderNodesToPPTX(graph, pageId(graph), [frame.id], {
|
|
rasterize: (ids) => {
|
|
rasterCalls.push(ids)
|
|
return Promise.resolve(TINY_PNG)
|
|
},
|
|
onStats: (value) => {
|
|
stats = value
|
|
}
|
|
})
|
|
expect(data).not.toBeNull()
|
|
if (!data) return
|
|
expect(rasterCalls).toEqual([[frame.id]])
|
|
expect(slideXML(unzipPPTX(data), 1)).not.toContain('FF0000')
|
|
expect(stats).not.toBeNull()
|
|
if (!stats) return
|
|
const reported: PPTXExportStats = stats
|
|
expect(reported.fallbackReasons['contains mask']).toBe(1)
|
|
})
|
|
|
|
test('an invisible mask does not flatten an otherwise editable root frame', async () => {
|
|
const graph = makeGraph()
|
|
const frame = makeSlideFrame(graph, 'Slide with hidden mask')
|
|
graph.createNode('RECTANGLE', frame.id, {
|
|
width: 200,
|
|
height: 200,
|
|
isMask: true,
|
|
visible: false,
|
|
fills: [solidFill(0, 0, 0)]
|
|
})
|
|
graph.createNode('RECTANGLE', frame.id, {
|
|
x: 100,
|
|
y: 100,
|
|
width: 300,
|
|
height: 300,
|
|
fills: [solidFill(1, 0, 0)]
|
|
})
|
|
|
|
const rasterCalls: string[][] = []
|
|
const data = await renderNodesToPPTX(graph, pageId(graph), [frame.id], {
|
|
rasterize: (ids) => {
|
|
rasterCalls.push(ids)
|
|
return Promise.resolve(TINY_PNG)
|
|
}
|
|
})
|
|
expect(data).not.toBeNull()
|
|
if (!data) return
|
|
expect(rasterCalls).toEqual([])
|
|
expect(slideXML(unzipPPTX(data), 1)).toContain('FF0000')
|
|
})
|
|
|
|
test('a clipped frame with an overflowing child rasterizes as one image', async () => {
|
|
const graph = makeGraph()
|
|
const frame = makeSlideFrame(graph, 'Slide')
|
|
const clipped = graph.createNode('FRAME', frame.id, {
|
|
name: 'Clipped',
|
|
x: 100,
|
|
y: 100,
|
|
width: 200,
|
|
height: 200,
|
|
clipsContent: true,
|
|
fills: [solidFill(0, 0, 1)]
|
|
})
|
|
graph.createNode('RECTANGLE', clipped.id, {
|
|
x: 150,
|
|
y: 20,
|
|
width: 200,
|
|
height: 100,
|
|
fills: [solidFill(1, 0, 0)]
|
|
})
|
|
|
|
let stats: PPTXExportStats | null = null
|
|
const rasterCalls: string[][] = []
|
|
const data = await renderNodesToPPTX(graph, pageId(graph), [frame.id], {
|
|
rasterize: (ids) => {
|
|
rasterCalls.push(ids)
|
|
return Promise.resolve(TINY_PNG)
|
|
},
|
|
onStats: (s) => {
|
|
stats = s
|
|
}
|
|
})
|
|
expect(data).not.toBeNull()
|
|
if (!data) return
|
|
// The frame rasterized once, so the cropped part of the child cannot leak
|
|
// back in as a separate native shape.
|
|
expect(rasterCalls).toEqual([[clipped.id]])
|
|
expect(slideXML(unzipPPTX(data), 1)).not.toContain('FF0000')
|
|
expect(stats).not.toBeNull()
|
|
if (!stats) return
|
|
const reported: PPTXExportStats = stats
|
|
expect(reported.fallbackReasons['clipped content']).toBe(1)
|
|
})
|
|
|
|
test('a clipped frame whose children fit stays native', async () => {
|
|
const graph = makeGraph()
|
|
const frame = makeSlideFrame(graph, 'Slide')
|
|
const clipped = graph.createNode('FRAME', frame.id, {
|
|
name: 'Clipped',
|
|
x: 100,
|
|
y: 100,
|
|
width: 200,
|
|
height: 200,
|
|
clipsContent: true,
|
|
fills: [solidFill(0, 0, 1)]
|
|
})
|
|
graph.createNode('RECTANGLE', clipped.id, {
|
|
x: 20,
|
|
y: 20,
|
|
width: 100,
|
|
height: 100,
|
|
fills: [solidFill(1, 0, 0)]
|
|
})
|
|
|
|
let stats: PPTXExportStats | null = null
|
|
const data = await renderNodesToPPTX(graph, pageId(graph), [frame.id], {
|
|
rasterize: stubRasterize,
|
|
onStats: (s) => {
|
|
stats = s
|
|
}
|
|
})
|
|
expect(data).not.toBeNull()
|
|
if (!data) return
|
|
const xml = slideXML(unzipPPTX(data), 1)
|
|
expect(xml).toContain('FF0000')
|
|
expect(stats).not.toBeNull()
|
|
if (!stats) return
|
|
const reported: PPTXExportStats = stats
|
|
expect(reported.fallback).toBe(0)
|
|
})
|
|
|
|
test('line transparency includes the stroke paint alpha', async () => {
|
|
const graph = makeGraph()
|
|
const frame = makeSlideFrame(graph, 'Slide')
|
|
graph.createNode('LINE', frame.id, {
|
|
x: 40,
|
|
y: 40,
|
|
width: 200,
|
|
height: 2,
|
|
strokes: [solidStroke(1, 0, 0, 0.5)]
|
|
})
|
|
const data = await renderNodesToPPTX(graph, pageId(graph), [frame.id], {
|
|
rasterize: stubRasterize
|
|
})
|
|
expect(data).not.toBeNull()
|
|
if (!data) return
|
|
const xml = slideXML(unzipPPTX(data), 1)
|
|
expect(xml).toContain('FF0000')
|
|
// 50% alpha survives instead of rendering fully opaque.
|
|
expect(xml).toContain('<a:alpha val="50000"/>')
|
|
})
|
|
|
|
test('a stroked slide frame becomes a native shape, not an image', async () => {
|
|
const graph = makeGraph()
|
|
const frame = graph.createNode('FRAME', pageId(graph), {
|
|
width: 1280,
|
|
height: 720,
|
|
fills: [solidFill(1, 1, 1)],
|
|
strokes: [solidStroke(0, 0, 0, 1, 4)]
|
|
})
|
|
|
|
let stats: PPTXExportStats | null = null
|
|
const data = await renderNodesToPPTX(graph, pageId(graph), [frame.id], {
|
|
rasterize: stubRasterize,
|
|
onStats: (s) => {
|
|
stats = s
|
|
}
|
|
})
|
|
expect(data).not.toBeNull()
|
|
if (!data) return
|
|
const files = unzipPPTX(data)
|
|
// A slide-sized shape carries the border instead of a flattened image.
|
|
const [border, ...rest] = placements(slideXML(files, 1))
|
|
expect(rest).toHaveLength(0)
|
|
expect(border.width).toBeCloseTo(SLIDE_WIDTH_IN, 2)
|
|
expect(
|
|
Object.keys(files).filter((f) => f.startsWith('ppt/media/') && !f.endsWith('/'))
|
|
).toHaveLength(0)
|
|
expect(stats).not.toBeNull()
|
|
if (!stats) return
|
|
const reported: PPTXExportStats = stats
|
|
expect(reported.fallback).toBe(0)
|
|
})
|
|
|
|
test('a non-solid slide background rasterizes without its children', async () => {
|
|
const graph = makeGraph()
|
|
const frame = graph.createNode('FRAME', pageId(graph), {
|
|
width: 1280,
|
|
height: 720,
|
|
fills: [
|
|
{
|
|
type: 'GRADIENT_LINEAR',
|
|
color: { r: 0, g: 0, b: 0, a: 1 },
|
|
opacity: 1,
|
|
visible: true,
|
|
gradientStops: [
|
|
{ position: 0, color: { r: 1, g: 0, b: 0, a: 1 } },
|
|
{ position: 1, color: { r: 0, g: 0, b: 1, a: 1 } }
|
|
]
|
|
}
|
|
]
|
|
})
|
|
graph.createNode('TEXT', frame.id, {
|
|
text: 'On the gradient',
|
|
x: 100,
|
|
y: 100,
|
|
width: 400,
|
|
height: 60,
|
|
fontSize: 32,
|
|
fills: [solidFill(1, 1, 1)]
|
|
})
|
|
|
|
const calls: { ids: string[]; paintOnly?: boolean }[] = []
|
|
const data = await renderNodesToPPTX(graph, pageId(graph), [frame.id], {
|
|
rasterize: (ids, _scale, options) => {
|
|
calls.push({ ids, paintOnly: options?.paintOnly })
|
|
return Promise.resolve(TINY_PNG)
|
|
}
|
|
})
|
|
expect(data).not.toBeNull()
|
|
if (!data) return
|
|
// Baking the children into the background image would draw them twice —
|
|
// once flattened, once as the native text added right after.
|
|
expect(calls).toEqual([{ ids: [frame.id], paintOnly: true }])
|
|
expect(slideXML(unzipPPTX(data), 1)).toContain('On the gradient')
|
|
})
|
|
|
|
test('solid frame background maps to slide background color', async () => {
|
|
const graph = makeGraph()
|
|
const frame = graph.createNode('FRAME', pageId(graph), {
|
|
width: 1280,
|
|
height: 720,
|
|
fills: [{ type: 'SOLID', color: { r: 0.2, g: 0.4, b: 0.6, a: 1 }, opacity: 1, visible: true }]
|
|
})
|
|
const data = await renderNodesToPPTX(graph, pageId(graph), [frame.id], {
|
|
rasterize: stubRasterize
|
|
})
|
|
expect(data).not.toBeNull()
|
|
if (!data) return
|
|
const xml = slideXML(unzipPPTX(data), 1)
|
|
expect(xml.toUpperCase()).toContain('336699')
|
|
})
|
|
|
|
test('a translucent frame background keeps its alpha', async () => {
|
|
const graph = makeGraph()
|
|
const frame = graph.createNode('FRAME', pageId(graph), {
|
|
width: 1280,
|
|
height: 720,
|
|
fills: [solidFill(0.2, 0.4, 0.6, 0.5)]
|
|
})
|
|
const data = await renderNodesToPPTX(graph, pageId(graph), [frame.id], {
|
|
rasterize: stubRasterize
|
|
})
|
|
expect(data).not.toBeNull()
|
|
if (!data) return
|
|
const xml = slideXML(unzipPPTX(data), 1)
|
|
expect(xml).toContain('<a:alpha val="50000"/>')
|
|
})
|
|
|
|
test('document export covers frames on every page', async () => {
|
|
const graph = makeGraph()
|
|
makeSlideFrame(graph, 'Page 1 slide')
|
|
const second = graph.addPage('Page 2')
|
|
graph.createNode('FRAME', second.id, {
|
|
name: 'Page 2 slide',
|
|
width: 1280,
|
|
height: 720,
|
|
fills: [solidFill(1, 1, 1)]
|
|
})
|
|
|
|
const pptx = BUILTIN_IO_FORMATS.find((format) => format.id === 'pptx')
|
|
expect(pptx).toBeDefined()
|
|
if (!pptx) return
|
|
const exportPPTX = expectDefined(pptx.exportContent, 'pptx exportContent')
|
|
const result = await exportPPTX(
|
|
{ graph, target: { scope: 'document' } },
|
|
{ rasterize: stubRasterize }
|
|
)
|
|
const files = unzipPPTX(result.data as Uint8Array)
|
|
expect(files['ppt/slides/slide1.xml']).toBeDefined()
|
|
expect(files['ppt/slides/slide2.xml']).toBeDefined()
|
|
})
|
|
})
|