openpencil/tests/engine/io/pptx/export/render.test.ts
Danila Poyarkov 7f91594d0c refactor: standardize acronym casing
- Rename first-party API, RPC, JSON, CORS, SVG, JSX, and related identifiers to preserve acronym casing
- Keep upstream and serialized boundary names unchanged
- Add a lint guardrail and migration notes for exported APIs
2026-08-10 15:48:16 +03:00

546 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 {
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 result = await pptx.exportContent(
{ 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()
})
})