返回 slidev
build.test.ts
根目录 / packages / slidev / node / commands / pptx / build.test.ts
1 import type { SlideIr } from './ir'
2 import JSZip from 'jszip'
3 import PptxGenJS from 'pptxgenjs'
4 import { describe, expect, it } from 'vitest'
5 import { buildPptx } from './build'
6
7 /**
8 * These assert on the generated OOXML, not on the JavaScript that produced it.
9 *
10 * That is deliberate: every bug this file exists to catch is a unit conversion,
11 * and a unit conversion looks correct in the source right up until you read
12 * what it wrote. `sz="2400"` is checkable against the spec; `pt(fontSize)` is
13 * not.
14 */
15
16 const BLACK = { r: 0, g: 0, b: 0, a: 1 }
17
18 function slide(nodes: SlideIr['nodes']): SlideIr {
19 return { no: 1, clickIndex: 0, containerId: '001-01', size: { w: 980, h: 552 }, nodes }
20 }
21
22 async function slideXml(ir: SlideIr[]): Promise<string> {
23 const buffer = await buildPptx(PptxGenJS, ir, { width: 980, height: 552 })
24 const zip = await JSZip.loadAsync(buffer)
25 return await zip.file('ppt/slides/slide1.xml')!.async('string')
26 }
27
28 function textNode(runs: SlideIr['nodes'][number] extends never ? never : any): any {
29 return {
30 kind: 'text',
31 sourceId: 1,
32 rect: { x: 0, y: 0, w: 200, h: 40 },
33 elementRect: { x: 0, y: 0, w: 200, h: 40 },
34 lineCount: 1,
35 align: 'left',
36 lineHeight: 40,
37 runs,
38 }
39 }
40
41 describe('unit conversions', () => {
42 it('writes font size in points, not pixels', async () => {
43 const xml = await slideXml([
44 slide([textNode([{ text: 'Hello', fontSize: 32, fontFamily: 'Arial' }])]),
45 ])
46 // 32 CSS px at 96 px/in is 24 pt, and OOXML stores hundredths of a point.
47 // Getting this wrong makes every deck a third too large.
48 expect(xml).toContain('sz="2400"')
49 })
50
51 it('writes letter spacing in points and disables kerning', async () => {
52 const xml = await slideXml([
53 slide([textNode([{ text: 'WIDE', fontSize: 16, fontFamily: 'Arial', letterSpacing: 2 }])]),
54 ])
55 // 2px -> 1.5pt -> spc is hundredths of a point.
56 expect(xml).toContain('spc="150"')
57 expect(xml).toContain('kern="0"')
58 })
59
60 it('places geometry at exactly 9525 EMU per pixel', async () => {
61 const xml = await slideXml([
62 slide([{
63 kind: 'box',
64 sourceId: 1,
65 rect: { x: 100, y: 50, w: 200, h: 25 },
66 fill: BLACK,
67 }]),
68 ])
69 // Integer EMU throughout, so there is no rounding drift to chase later.
70 expect(xml).toContain('x="952500"') // 100 * 9525
71 expect(xml).toContain('y="476250"') // 50 * 9525
72 expect(xml).toContain('cx="1905000"') // 200 * 9525
73 expect(xml).toContain('cy="238125"') // 25 * 9525
74 })
75
76 it('writes a corner radius that is not zero', async () => {
77 const xml = await slideXml([
78 slide([{
79 kind: 'box',
80 sourceId: 1,
81 rect: { x: 0, y: 0, w: 200, h: 100 },
82 fill: BLACK,
83 radius: 12,
84 }]),
85 ])
86 expect(xml).toContain('prst="roundRect"')
87 // OOXML states the corner adjustment as hundred-thousandths of the shorter
88 // side. 12px of a 100px side is 12%, so exactly 12000.
89 //
90 // Asserted exactly, not as "greater than zero": `rectRadius` is documented
91 // as a 0.0-1.0 fraction but is really inches, and the wrong reading still
92 // produces a non-zero adjustment (11520 here). A loose assertion passes on
93 // both and pins nothing, which is what this test caught the first time.
94 expect(xml).toContain('<a:gd name="adj" fmla="val 12000"/>')
95 })
96 })
97
98 describe('fills and borders', () => {
99 it('maps alpha to transparency', async () => {
100 const xml = await slideXml([
101 slide([{
102 kind: 'box',
103 sourceId: 1,
104 rect: { x: 0, y: 0, w: 10, h: 10 },
105 fill: { r: 255, g: 0, b: 0, a: 0.6 },
106 }]),
107 ])
108 // 0.6 opacity is 40% transparent, and OOXML alpha is the inverse again,
109 // in thousandths of a percent.
110 expect(xml).toContain('<a:alpha val="60000"/>')
111 })
112
113 it('emits no alpha element for an opaque fill', async () => {
114 const xml = await slideXml([
115 slide([{ kind: 'box', sourceId: 1, rect: { x: 0, y: 0, w: 10, h: 10 }, fill: BLACK }]),
116 ])
117 expect(xml).not.toContain('<a:alpha')
118 })
119
120 it('uses one uniform line when all four borders match', async () => {
121 const border = { width: 2, color: BLACK, style: 'solid' as const }
122 const xml = await slideXml([
123 slide([{
124 kind: 'box',
125 sourceId: 1,
126 rect: { x: 0, y: 0, w: 100, h: 100 },
127 borders: [border, border, border, border],
128 }]),
129 ])
130 // One shape, not five.
131 expect(xml.match(/<p:sp>/g)).toHaveLength(1)
132 })
133
134 it('splits differing borders into one rectangle per side', async () => {
135 const thick = { width: 4, color: BLACK, style: 'solid' as const }
136 const xml = await slideXml([
137 slide([{
138 kind: 'box',
139 sourceId: 1,
140 rect: { x: 0, y: 0, w: 100, h: 100 },
141 borders: [thick, undefined, undefined, undefined],
142 }]),
143 ])
144 // Just the edge. pptxgenjs gives a shape a single uniform border, so a
145 // left-accent bar or a single top rule has to be its own rectangle, and
146 // the box that would have carried it has neither fill nor border left to
147 // draw. Emitting it anyway left PowerPoint to resolve a shape with no fill
148 // and no outline from its default style.
149 expect(xml.match(/<p:sp>/g)).toHaveLength(1)
150 expect(xml).toContain('cy="38100"') // the 4px edge: 4 * 9525
151 })
152 })
153
154 describe('text', () => {
155 it('emits a real line break for a soft break, not a vertical tab', async () => {
156 const xml = await slideXml([
157 slide([textNode([
158 { text: 'first', fontSize: 16, fontFamily: 'Arial' },
159 { text: 'second', fontSize: 16, fontFamily: 'Arial', breakBefore: true },
160 ])]),
161 ])
162 expect(xml).toContain('<a:br/>')
163 expect(xml).not.toContain('\v')
164 })
165
166 it('keeps the midpoint of a centered line when adding slack', async () => {
167 const centered = { ...textNode([{ text: 'x', fontSize: 16, fontFamily: 'Arial' }]), align: 'center' }
168 const xml = await slideXml([slide([centered])])
169 // The box is widened by 12px so PowerPoint's wider metrics do not force a
170 // wrap, so its origin moves left by half of that to hold the center still.
171 expect(xml).toContain('x="-57150"') // -6 * 9525
172 expect(xml).toContain('cx="2019300"') // 212 * 9525
173 })
174
175 it('turns off wrapping for a single line and on for several', async () => {
176 const one = await slideXml([slide([textNode([{ text: 'x', fontSize: 16, fontFamily: 'Arial' }])])])
177 expect(one).toContain('wrap="none"')
178
179 const many = await slideXml([
180 slide([{ ...textNode([{ text: 'x', fontSize: 16, fontFamily: 'Arial' }]), lineCount: 3 }]),
181 ])
182 expect(many).not.toContain('wrap="none"')
183 })
184 })
185
186 describe('slide assembly', () => {
187 it('writes notes into the notes slide', async () => {
188 const buffer = await buildPptx(
189 PptxGenJS,
190 [{ ...slide([]), note: 'Say this part out loud.' }],
191 { width: 980, height: 552 },
192 )
193 const zip = await JSZip.loadAsync(buffer)
194 const notes = await zip.file('ppt/notesSlides/notesSlide1.xml')!.async('string')
195 expect(notes).toContain('Say this part out loud.')
196 })
197
198 it('falls back to a background picture and draws nothing else', async () => {
199 const png = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
200 const xml = await slideXml([{
201 ...slide([{ kind: 'box', sourceId: 1, rect: { x: 0, y: 0, w: 10, h: 10 }, fill: BLACK }]),
202 fallbackPng: png,
203 fallbackReason: 'walker timed out',
204 }])
205 // The safety valve has to be exclusive. Drawing the shapes as well would
206 // put half-measured content on top of a correct picture of the same thing.
207 expect(xml).not.toContain('<p:sp>')
208 })
209
210 it('produces a non-empty shape tree, which the image exporter never does', async () => {
211 const xml = await slideXml([
212 slide([textNode([{ text: 'Selectable', fontSize: 16, fontFamily: 'Arial' }])]),
213 ])
214 expect(xml).toContain('<a:t>Selectable</a:t>')
215 })
216 })
217
218 describe('a hyperlink is not underlined unless CSS says so', () => {
219 it('suppresses the underline pptxgenjs adds to every link', async () => {
220 // pptxgenjs writes `u="sng"` for any run carrying a hyperlink unless
221 // `underline` is set. Slidev's own themes rule links with a dashed
222 // `border-bottom`, which is already drawn as its own shape, so the deck
223 // came out with two lines under every link.
224 const xml = await slideXml([
225 slide([textNode([{ text: 'Documentation', fontSize: 16, fontFamily: 'Arial', link: 'https://sli.dev' }])]),
226 ])
227 expect(xml).toContain('u="none"')
228 expect(xml).not.toContain('u="sng"')
229 })
230
231 it('still underlines a run whose CSS asks for it', async () => {
232 const xml = await slideXml([
233 slide([textNode([{ text: 'Documentation', fontSize: 16, fontFamily: 'Arial', link: 'https://sli.dev', underline: true }])]),
234 ])
235 expect(xml).toContain('u="sng"')
236 })
237 })
238
239 describe('a dashed border keeps its dashes', () => {
240 const dashed = { width: 1, color: BLACK, style: 'dashed' as const }
241
242 it('draws a dashed edge as a line, not a filled bar', async () => {
243 // A filled rectangle cannot carry a dash pattern. Slidev rules its links
244 // with `border-bottom: 1px dashed`, so every link in a deck came out with
245 // a solid bar under it.
246 const xml = await slideXml([
247 slide([{
248 kind: 'box',
249 sourceId: 1,
250 rect: { x: 0, y: 0, w: 100, h: 20 },
251 borders: [undefined, undefined, dashed, undefined],
252 }]),
253 ])
254 expect(xml).toContain('<a:prstDash val="dash"/>')
255 expect(xml).toContain('prst="line"')
256 })
257
258 it('leaves a solid edge as a filled bar', async () => {
259 const xml = await slideXml([
260 slide([{
261 kind: 'box',
262 sourceId: 1,
263 rect: { x: 0, y: 0, w: 100, h: 20 },
264 borders: [undefined, undefined, { width: 1, color: BLACK, style: 'solid' as const }, undefined],
265 }]),
266 ])
267 expect(xml).not.toContain('prst="line"')
268 })
269 })
270
271 describe('a fill is stated, never inherited', () => {
272 it('writes an explicit noFill for a shape that has none', async () => {
273 // pptxgenjs writes `<a:noFill/>` for an ABSENT fill and nothing at all for
274 // `{ type: 'none' }`, so the explicit-looking version was the one that
275 // inherited the default shape style.
276 const xml = await slideXml([
277 slide([{
278 kind: 'box',
279 sourceId: 1,
280 rect: { x: 0, y: 0, w: 100, h: 100 },
281 borders: [
282 { width: 1, color: BLACK, style: 'solid' as const },
283 { width: 1, color: BLACK, style: 'solid' as const },
284 { width: 1, color: BLACK, style: 'solid' as const },
285 { width: 1, color: BLACK, style: 'solid' as const },
286 ],
287 }]),
288 ])
289 expect(xml).toContain('<a:noFill/>')
290 })
291 })
292
292 lines TYPESCRIPT