| 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 |