返回 slidev
normalize.test.ts
根目录 / packages / slidev / node / commands / pptx / normalize.test.ts
1 import type { IrBox, IrText, RawNode, RawSlide, RawSnapshot, RawStyle } from './ir'
2 import { describe, expect, it } from 'vitest'
3 import { parseColor } from './color'
4 import { normalize, parseLength, parseShadow, rasterReasonFor } from './normalize'
5
6 /**
7 * One named case per rule.
8 *
9 * Every case here is a defect that reached a rendered deck. A rule without one
10 * is a rule nobody will remember the reason for.
11 */
12
13 const BASE_STYLE: RawStyle = {
14 display: 'block',
15 position: 'static',
16 zIndex: 'auto',
17 visibility: 'visible',
18 opacity: '1',
19 color: 'rgb(0, 0, 0)',
20 backgroundColor: 'rgba(0, 0, 0, 0)',
21 backgroundImage: 'none',
22 fontFamily: 'Inter, sans-serif',
23 fontSize: '16px',
24 fontWeight: '400',
25 fontStyle: 'normal',
26 textAlign: 'start',
27 textDecorationLine: 'none',
28 textTransform: 'none',
29 letterSpacing: 'normal',
30 lineHeight: 'normal',
31 whiteSpace: 'normal',
32 paddingLeft: '0px',
33 paddingRight: '0px',
34 borderTopWidth: '0px',
35 borderTopStyle: 'none',
36 borderTopColor: 'rgb(0, 0, 0)',
37 borderRightWidth: '0px',
38 borderRightStyle: 'none',
39 borderRightColor: 'rgb(0, 0, 0)',
40 borderBottomWidth: '0px',
41 borderBottomStyle: 'none',
42 borderBottomColor: 'rgb(0, 0, 0)',
43 borderLeftWidth: '0px',
44 borderLeftStyle: 'none',
45 borderLeftColor: 'rgb(0, 0, 0)',
46 borderTopLeftRadius: '0px',
47 boxShadow: 'none',
48 filter: 'none',
49 backdropFilter: 'none',
50 mixBlendMode: 'normal',
51 clipPath: 'none',
52 transform: 'none',
53 writingMode: 'horizontal-tb',
54 webkitBackgroundClip: 'border-box',
55 overflow: 'visible',
56 top: 'auto',
57 right: 'auto',
58 bottom: 'auto',
59 left: 'auto',
60 width: 'auto',
61 height: 'auto',
62 }
63
64 function style(overrides: Partial<RawStyle> = {}): RawStyle {
65 return { ...BASE_STYLE, ...overrides }
66 }
67
68 const RECT = { x: 0, y: 0, w: 100, h: 20 }
69
70 function el(id: number, parent: number, tag: string, styleIndex: number, extra: Partial<RawNode> = {}): RawNode {
71 return { id, parent, tag, style: styleIndex, rect: RECT, ...extra }
72 }
73
74 function text(id: number, parent: number, value: string, extra: Partial<RawNode> = {}): RawNode {
75 return {
76 id,
77 parent,
78 tag: '#text',
79 style: -1,
80 rect: RECT,
81 text: value,
82 glyphRects: [RECT],
83 ...extra,
84 }
85 }
86
87 function snapshot(nodes: RawNode[], styles: RawStyle[]): RawSnapshot {
88 const slide: RawSlide = { no: 1, clickIndex: 0, containerId: '001-01', size: { w: 980, h: 552 }, nodes }
89 return { slides: [slide], styles, fontResolution: { 'Inter, sans-serif': 'Inter' }, unplaceablePseudos: [] }
90 }
91
92 function run(nodes: RawNode[], styles: RawStyle[]) {
93 return normalize(snapshot(nodes, styles), { notes: new Map() })
94 }
95
96 function texts(nodes: any[]): IrText[] {
97 return nodes.filter(n => n.kind === 'text')
98 }
99
100 function boxes(nodes: any[]): IrBox[] {
101 return nodes.filter(n => n.kind === 'box')
102 }
103
104 describe('value parsing', () => {
105 it('reads rgb and rgba', () => {
106 expect(parseColor('rgb(255, 0, 0)')).toEqual({ r: 255, g: 0, b: 0, a: 1 })
107 expect(parseColor('rgba(0, 0, 0, 0.5)')).toEqual({ r: 0, g: 0, b: 0, a: 0.5 })
108 expect(parseColor('transparent')).toEqual({ r: 0, g: 0, b: 0, a: 0 })
109 expect(parseColor(undefined)).toBeUndefined()
110 })
111
112 it('reads the modern color syntaxes Chromium leaves in computed values', () => {
113 // A theme authored in modern syntax keeps these in the computed value.
114 // Returning undefined for them dropped every fill and text color on the
115 // slide with nothing in the log to explain it.
116 const red = parseColor('oklch(0.628 0.2577 29.23)')!
117 expect(red.r).toBeGreaterThan(248)
118 expect(red.g).toBeLessThan(8)
119 expect(red.b).toBeLessThan(8)
120
121 expect(parseColor('hsl(120, 100%, 50%)')).toEqual({ r: 0, g: 255, b: 0, a: 1 })
122 expect(parseColor('color(srgb 1 0 0)')).toEqual({ r: 255, g: 0, b: 0, a: 1 })
123 })
124
125 it('reports a color it cannot read instead of failing silently', () => {
126 const bad = style({ backgroundColor: 'lab(50% 40 59.5)' })
127 const { unparsedColors } = run([el(0, -1, 'DIV', 1)], [BASE_STYLE, bad])
128 expect(unparsedColors).toContain('lab(50% 40 59.5)')
129 // Per run, not module state: a drained global leaks into the next export
130 // whenever this one throws before the caller collects it.
131 expect(run([el(0, -1, 'DIV', 0)], [BASE_STYLE]).unparsedColors).toHaveLength(0)
132 })
133
134 it('reads a pixel length, and a percentage against its basis', () => {
135 expect(parseLength('12.5px')).toBe(12.5)
136 expect(parseLength('normal')).toBe(0)
137 // Chromium keeps border-radius as a percentage rather than resolving it, so
138 // `parseFloat` alone turned `50%` on a 200px box into 50px.
139 expect(parseLength('50%', 200)).toBe(100)
140 })
141
142 it('reads a box-shadow into PowerPoint polar form', () => {
143 const shadow = parseShadow('rgba(0, 0, 0, 0.5) 0px 4px 8px 0px')!
144 expect(shadow.blur).toBe(8)
145 expect(shadow.offset).toBe(4)
146 expect(shadow.angle).toBe(90)
147 expect(shadow.color.a).toBe(0.5)
148 // No DrawingML equivalent for either of these.
149 expect(parseShadow('none')).toBeUndefined()
150 expect(parseShadow('rgb(0, 0, 0) 0px 2px 4px inset')).toBeUndefined()
151 })
152 })
153
154 describe('a <br> becomes a real line break', () => {
155 it('breaks between the runs either side of it', () => {
156 const nodes = [
157 el(0, -1, 'DIV', 0),
158 text(1, 0, 'line one'),
159 el(2, 0, 'BR', 1),
160 text(3, 0, 'line two'),
161 ]
162 const { slides } = run(nodes, [BASE_STYLE, style({ display: 'inline' })])
163 const out = texts(slides[0].nodes)[0]
164 // Without this the two runs are concatenated and export as "line oneline two".
165 expect(out.runs.map(r => r.text)).toEqual(['line one', 'line two'])
166 expect(out.runs[1].breakBefore).toBe(true)
167 expect(out.runs[0].breakBefore).toBeUndefined()
168 })
169 })
170
171 describe('code blocks keep their line structure', () => {
172 it('turns a pre newline into a real line break', () => {
173 const pre = style({ whiteSpace: 'pre' })
174 const inline = style({ display: 'inline', whiteSpace: 'pre' })
175 const nodes = [
176 el(0, -1, 'PRE', 1),
177 el(1, 0, 'SPAN', 2),
178 text(2, 1, 'const a = 1'),
179 // Shiki separates its line spans with a bare newline text node, which
180 // has no client rects of its own.
181 text(3, 0, '\n', { glyphRects: [] }),
182 el(4, 0, 'SPAN', 2),
183 text(5, 4, 'const b = 2'),
184 ]
185 const { slides } = run(nodes, [BASE_STYLE, pre, inline])
186 const out = texts(slides[0].nodes)[0]
187 // Folded to a space, every multi-line code block exported as one
188 // re-wrapped paragraph, which is core content for a developer slide tool.
189 expect(out.runs.map(r => r.text)).toEqual(['const a = 1', 'const b = 2'])
190 expect(out.runs[1].breakBefore).toBe(true)
191 })
192
193 it('keeps a blank line between two consecutive breaks', () => {
194 const inline = style({ display: 'inline' })
195 const nodes = [
196 el(0, -1, 'DIV', 0),
197 text(1, 0, 'first'),
198 el(2, 0, 'BR', 1),
199 el(3, 0, 'BR', 1),
200 text(4, 0, 'second'),
201 ]
202 const { slides } = run(nodes, [BASE_STYLE, inline])
203 const out = texts(slides[0].nodes)[0]
204 // `softBreakBefore` is one break per run, so two in a row need an empty
205 // run between them or the blank line silently disappears.
206 expect(out.runs).toHaveLength(3)
207 expect(out.runs[1].text).toBe('')
208 expect(out.runs[1].breakBefore).toBe(true)
209 expect(out.runs[2].breakBefore).toBe(true)
210 })
211
212 it('does not open a box with a leading break', () => {
213 const inline = style({ display: 'inline' })
214 const nodes = [el(0, -1, 'DIV', 0), el(1, 0, 'BR', 1), text(2, 0, 'text')]
215 const { slides } = run(nodes, [BASE_STYLE, inline])
216 expect(texts(slides[0].nodes)[0].runs[0].breakBefore).toBeUndefined()
217 })
218 })
219
220 describe('an inline image survives the text walk', () => {
221 it('draws an <img> that sits inside a line of text', () => {
222 const inline = style({ display: 'inline' })
223 const nodes = [
224 el(0, -1, 'P', 0),
225 text(1, 0, 'before'),
226 el(2, 0, 'IMG', 1, { src: 'data:image/png;base64,AAAA', alt: 'logo' }),
227 ]
228 const { slides } = run(nodes, [BASE_STYLE, inline])
229 // Inline children go to the text grouper, which finds no text under an
230 // IMG, so the image used to vanish from the slide entirely.
231 const images = slides[0].nodes.filter(n => n.kind === 'image')
232 expect(images).toHaveLength(1)
233 expect((images[0] as any).alt).toBe('logo')
234 })
235 })
236
237 describe('the space between inline elements', () => {
238 it('keeps a whitespace-only node between two spans', () => {
239 const inline = style({ display: 'inline' })
240 const nodes = [
241 el(0, -1, 'DIV', 0),
242 el(1, 0, 'SPAN', 1),
243 text(2, 1, 'Hello'),
244 text(3, 0, ' '),
245 el(4, 0, 'SPAN', 1),
246 text(5, 4, 'World'),
247 ]
248 const { slides } = run(nodes, [BASE_STYLE, inline])
249 const out = texts(slides[0].nodes)[0]
250 // Dropping it exported "HelloWorld".
251 expect(out.runs.map(r => r.text).join('')).toBe('Hello World')
252 })
253 })
254
255 describe('inline-level layout containers still lay out as boxes', () => {
256 it('does not merge the cells of an inline-flex badge', () => {
257 const badge = style({ display: 'inline-flex' })
258 const cell = style({ display: 'inline' })
259 const nodes = [
260 el(0, -1, 'DIV', 0),
261 el(1, 0, 'SPAN', 1),
262 el(2, 1, 'SPAN', 2),
263 text(3, 2, 'Left cell'),
264 el(4, 1, 'SPAN', 2),
265 text(5, 4, 'Right cell'),
266 ]
267 const { slides } = run(nodes, [BASE_STYLE, badge, cell])
268 // `^inline` matched inline-flex, so the walk descended and concatenated
269 // the cells: the trap-3 bug one display value over.
270 const out = texts(slides[0].nodes)
271 expect(out).toHaveLength(2)
272 expect(out.map(t => t.runs[0].text)).toEqual(['Left cell', 'Right cell'])
273 })
274 })
275
276 describe('a list item flows its children as text', () => {
277 it('keeps a bold lead-in joined inside an <li>', () => {
278 const item = style({ display: 'list-item' })
279 const inline = style({ display: 'inline' })
280 const nodes = [
281 el(0, -1, 'LI', 1, { marker: '\u2022 ' }),
282 text(1, 0, 'plain '),
283 el(2, 0, 'B', 2),
284 text(3, 2, 'bold'),
285 text(4, 0, ' tail'),
286 ]
287 const { slides } = run(nodes, [BASE_STYLE, item, inline])
288 const out = texts(slides[0].nodes)
289 // One marker box plus ONE grouped paragraph. Treating `list-item` as a
290 // layout container fragmented this into four boxes.
291 expect(out).toHaveLength(2)
292 expect(out[1].runs.map(r => r.text)).toEqual(['plain ', 'bold', ' tail'])
293 })
294 })
295
296 describe('element opacity reaches the fill', () => {
297 it('folds opacity into the color alpha', () => {
298 const faded = style({ backgroundColor: 'rgb(0, 0, 0)' })
299 // Carried on the NODE, compounded down the tree by the walker, because CSS
300 // opacity does not inherit and DrawingML has no group opacity to stand in
301 // for a half-transparent wrapper.
302 const nodes = [el(0, -1, 'DIV', 1, { opacity: 0.5 })]
303 const { slides } = run(nodes, [BASE_STYLE, faded])
304 // DrawingML has no element opacity, only per-fill alpha, so a half
305 // transparent overlay exported fully opaque and hid what was behind it.
306 expect(boxes(slides[0].nodes)[0].fill).toEqual({ r: 0, g: 0, b: 0, a: 0.5 })
307 })
308 })
309
310 describe('paint order follows CSS, not the document', () => {
311 it('draws a positioned element above in-flow content that comes later', () => {
312 const positioned = style({ position: 'absolute' })
313 const backdrop = style({ backgroundImage: 'url(/img/scenery.png)' })
314 const corner = { x: 900, y: 10, w: 60, h: 20 }
315 const nodes = [
316 // A slide's page counter is the FIRST child of its container, and it is
317 // a positioned <footer> wrapping a plain <div>, so the layer has to come
318 // from the nearest POSITIONED ancestor rather than the nearest styled one.
319 el(0, -1, 'FOOTER', 1, { rect: corner }),
320 el(1, 0, 'DIV', 0, { rect: corner }),
321 text(2, 1, '3 / 35', { rect: corner, glyphRects: [corner] }),
322 // The full-bleed background comes after it in the tree, so array order
323 // painted it straight over the counter and the counter vanished.
324 el(3, -1, 'DIV', 2, { rect: { x: 0, y: 0, w: 980, h: 552 } }),
325 ]
326 const { slides } = run(nodes, [BASE_STYLE, positioned, backdrop])
327 const kinds = slides[0].nodes.map(n => n.kind)
328 expect(kinds.indexOf('raster')).toBeLessThan(kinds.indexOf('text'))
329 })
330
331 it('keeps document order within one layer', () => {
332 const nodes = [
333 el(0, -1, 'DIV', 1),
334 text(1, 0, 'first'),
335 el(2, -1, 'DIV', 1),
336 text(3, 2, 'second'),
337 ]
338 const { slides } = run(nodes, [BASE_STYLE, style({ backgroundColor: 'rgb(1, 2, 3)' })])
339 const out = texts(slides[0].nodes)
340 expect(out.map(t => t.runs[0].text)).toEqual(['first', 'second'])
341 })
342 })
343
344 describe('opacity reaches text, not only shapes', () => {
345 it('greys a paragraph that its ancestor dimmed', () => {
346 // Slidev's own stylesheet greys this kind of paragraph with `opacity: 0.5`
347 // rather than a color. Opacity is compounded down the tree onto elements,
348 // but a text node has no style of its own, so every run lost it and the
349 // paragraph exported solid black.
350 const nodes = [
351 el(0, -1, 'P', 0, { opacity: 0.5 }),
352 text(1, 0, 'a dimmed paragraph', { opacity: 0.5 }),
353 ]
354 const { slides } = run(nodes, [BASE_STYLE])
355 expect(texts(slides[0].nodes)[0].runs[0].color).toEqual({ r: 0, g: 0, b: 0, a: 0.5 })
356 })
357
358 it('centers a list marker on its line rather than above it', () => {
359 const item = style({ display: 'list-item' })
360 const { slides } = run([el(0, -1, 'LI', 1, { marker: '\u25AA ' })], [BASE_STYLE, item])
361 expect(texts(slides[0].nodes)[0].valign).toBe('middle')
362 })
363 })
364
365 describe('the slide keeps its own background', () => {
366 it('carries the container background into the IR', () => {
367 const raw = snapshot([el(0, -1, 'DIV', 0)], [BASE_STYLE])
368 raw.slides[0].background = 'rgb(18, 18, 18)'
369 const { slides } = normalize(raw, { notes: new Map() })
370 // The walk starts at the container's children, so without this every slide
371 // exported onto PowerPoint's white and a dark theme became unreadable.
372 expect(slides[0].background).toEqual({ r: 18, g: 18, b: 18, a: 1 })
373 })
374 })
375
376 describe('a shrink-wrapped container leaves no room to wrap', () => {
377 it('widens a centered block whose container is exactly its ink', () => {
378 const centered = style({ textAlign: 'center' })
379 // The container fits the text exactly, which is what a centered statement
380 // block does. PowerPoint sets the same string slightly wider than
381 // Chromium, so with no headroom the longest line wraps and the block
382 // gains a line.
383 const box = { x: 155, y: 192, w: 487, h: 90 }
384 const nodes = [
385 el(0, -1, 'DIV', 1, { rect: box }),
386 text(1, 0, 'a long centered statement', {
387 rect: box,
388 glyphRects: [
389 { x: 155, y: 192, w: 400, h: 30 },
390 { x: 155, y: 222, w: 487, h: 30 },
391 { x: 155, y: 252, w: 380, h: 30 },
392 ],
393 }),
394 ]
395 const { slides } = run(nodes, [BASE_STYLE, centered])
396 const out = texts(slides[0].nodes)[0]
397 expect(out.rect.w).toBeGreaterThan(487)
398 // Grown about the center, so the text does not slide sideways.
399 expect(out.rect.x + out.rect.w / 2).toBeCloseTo(155 + 487 / 2, 5)
400 })
401
402 it('leaves a column-constrained paragraph at its container width', () => {
403 const nodes = [
404 el(0, -1, 'DIV', 0, { rect: { x: 0, y: 0, w: 400, h: 60 } }),
405 text(1, 0, 'wrapped body copy', {
406 rect: { x: 0, y: 0, w: 400, h: 60 },
407 glyphRects: [
408 { x: 0, y: 0, w: 380, h: 20 },
409 { x: 0, y: 24, w: 200, h: 20 },
410 ],
411 }),
412 ]
413 const { slides } = run(nodes, [BASE_STYLE])
414 // 20px of headroom is already more than the 2% this needs, so widening it
415 // would push the text into whatever sits beside it.
416 expect(texts(slides[0].nodes)[0].rect.w).toBe(400)
417 })
418 })
419
420 describe('--range is applied by the caller, not the page', () => {
421 it('exposes the slide number so out-of-range slides can be filtered', () => {
422 // The print route renders EVERY slide whatever the `range` query says, so
423 // out-of-range containers are fully laid out rather than hidden, and an
424 // exporter that trusts the page exports the whole deck. `no` and
425 // `containerId` are what let the caller drop them, the same way the image
426 // exporter does with `pages.includes(slideNo)`.
427 const raw = snapshot([el(0, -1, 'DIV', 0), text(1, 0, 'content')], [BASE_STYLE])
428 raw.slides[0].no = 7
429 raw.slides[0].containerId = '007-03'
430 const { slides } = normalize(raw, { notes: new Map() })
431 expect(slides[0].no).toBe(7)
432 expect(slides[0].containerId).toBe('007-03')
433 })
434 })
435
436 describe('pseudo-element decorations are drawn', () => {
437 it('draws an ::after that paints a background image', () => {
438 const mark = style({
439 position: 'absolute',
440 backgroundImage: 'url(/img/logo.png)',
441 width: '84px',
442 height: '33px',
443 })
444 const rect = { x: 858, y: 493, w: 84, h: 33 }
445 const nodes = [
446 el(0, -1, 'DIV', 0),
447 el(1, 0, '::AFTER', 1, { rect, pageRect: rect }),
448 ]
449 const { slides, rasterRequests } = run(nodes, [BASE_STYLE, mark])
450 // A pseudo-element has no DOM node, so a walk over the tree cannot see it.
451 // A corporate logo drawn this way was silently absent from every slide.
452 const raster = slides[0].nodes.find(n => n.kind === 'raster') as any
453 expect(raster).toBeDefined()
454 expect(raster.rect).toEqual(rect)
455 // And no locator can point at it, so the capture clips the page instead.
456 expect(rasterRequests[0].clip).toEqual(rect)
457 })
458
459 it('draws an ::before that paints a string', () => {
460 const quote = style({ position: 'absolute', width: '20px', height: '20px' })
461 const rect = { x: 10, y: 10, w: 20, h: 20 }
462 const nodes = [
463 el(0, -1, 'DIV', 0),
464 el(1, 0, '::BEFORE', 1, { rect, pageRect: rect, text: '\u201C' }),
465 ]
466 const { slides } = run(nodes, [BASE_STYLE, quote])
467 expect(texts(slides[0].nodes)[0].runs[0].text).toBe('\u201C')
468 })
469 })
470
471 describe('line counting tolerates sub-pixel layout', () => {
472 it('treats fragments a fraction of a pixel apart as one line', () => {
473 const nodes = [
474 el(0, -1, 'DIV', 0),
475 text(1, 0, 'one visual line', {
476 glyphRects: [
477 { x: 0, y: 10.4, w: 50, h: 20 },
478 { x: 50, y: 10.6, w: 50, h: 20 },
479 ],
480 }),
481 ]
482 const { slides } = run(nodes, [BASE_STYLE])
483 // Rounding put these on 10 and 11 and reported two lines, which flips
484 // `wrap` in the builder, the one decision this number exists to make.
485 expect(texts(slides[0].nodes)[0].lineCount).toBe(1)
486 })
487 })
488
489 describe('layout containers are not text blocks', () => {
490 it('keeps grid cells apart instead of concatenating them', () => {
491 const grid = style({ display: 'grid' })
492 // The cells are INLINE on purpose. CSS blockifies the children of a grid
493 // container into grid items whatever their specified display says, so they
494 // are separate boxes. An earlier fixture used block children, which fall
495 // into separate text groups anyway, so it passed even with the rule
496 // disabled and tested nothing.
497 const cell = style({ display: 'inline' })
498 const nodes = [
499 el(0, -1, 'DIV', 1),
500 el(1, 0, 'SPAN', 2),
501 text(2, 1, 'Left cell'),
502 el(3, 0, 'SPAN', 2),
503 text(4, 3, 'Right cell'),
504 ]
505 const { slides } = run(nodes, [BASE_STYLE, grid, cell])
506 const out = texts(slides[0].nodes)
507 // The original bug produced one run reading "Left cellRight cell".
508 expect(out).toHaveLength(2)
509 expect(out[0].runs[0].text).toBe('Left cell')
510 expect(out[1].runs[0].text).toBe('Right cell')
511 })
512 })
513
514 describe('consecutive inline nodes form one anonymous block', () => {
515 it('joins a bold lead-in with the rest of its sentence', () => {
516 const inline = style({ display: 'inline' })
517 const bold = style({ display: 'inline', fontWeight: '700' })
518 const nodes = [
519 el(0, -1, 'DIV', 0),
520 el(1, 0, 'B', 2),
521 text(2, 1, 'A bold lead-in'),
522 text(3, 0, ', and the rest of the sentence'),
523 el(4, 0, 'DIV', 0),
524 text(5, 4, 'A following block.'),
525 ]
526 const { slides } = run(nodes, [BASE_STYLE, inline, bold])
527 const out = texts(slides[0].nodes)
528 // Two boxes: the anonymous block, then the nested block. Emitting three
529 // separate shapes made the bold lead-in and the tail lay out from the same
530 // origin and print on top of each other.
531 expect(out).toHaveLength(2)
532 expect(out[0].runs.map(r => r.text)).toEqual(['A bold lead-in', ', and the rest of the sentence'])
533 expect(out[0].runs[0].bold).toBe(true)
534 expect(out[0].runs[1].bold).toBeUndefined()
535 expect(out[1].runs[0].text).toBe('A following block.')
536 })
537 })
538
539 describe('inline decorations paint before their text', () => {
540 it('emits a chip background ahead of the white label on it', () => {
541 const chip = style({ display: 'inline', backgroundColor: 'rgb(0, 128, 0)', color: 'rgb(255, 255, 255)' })
542 const nodes = [
543 el(0, -1, 'DIV', 0),
544 el(1, 0, 'SPAN', 1),
545 text(2, 1, 'NEW'),
546 ]
547 const { slides } = run(nodes, [BASE_STYLE, chip])
548 const kinds = slides[0].nodes.map(n => n.kind)
549 // Paint order is array order. Emitted after the text, the chip covers its
550 // own label and white-on-green becomes white-on-white.
551 expect(kinds.indexOf('box')).toBeLessThan(kinds.indexOf('text'))
552 expect(boxes(slides[0].nodes)[0].fill).toEqual({ r: 0, g: 128, b: 0, a: 1 })
553 })
554
555 it('anchors a chip label in its own box so it cannot drift off the chip', () => {
556 const inline = style({ display: 'inline' })
557 const chip = style({ display: 'inline', backgroundColor: 'rgb(0, 128, 0)' })
558 const label = { x: 122, y: 0, w: 30, h: 20 }
559 const nodes = [
560 el(0, -1, 'DIV', 0),
561 el(1, 0, 'SPAN', 2, { rect: label }),
562 text(2, 1, 'NEW', { rect: label, glyphRects: [label] }),
563 ]
564 const { slides } = run(nodes, [BASE_STYLE, inline, chip])
565 const out = texts(slides[0].nodes)
566 // The chip's background is an absolutely positioned shape while the text
567 // around it flows, so sharing one box lets PowerPoint's metrics
568 // accumulate across the earlier runs and slide the label off its chip.
569 expect(out).toHaveLength(1)
570 expect(out[0].runs[0].text).toBe('NEW')
571 // Pinned to the chip and centered in it, not to its own ink. However much
572 // wider PowerPoint sets the string than the browser did, the label stays
573 // evenly inset instead of ending up hard against one edge.
574 expect(out[0].rect).toEqual(label)
575 expect(out[0].align).toBe('center')
576 expect(out[0].valign).toBe('middle')
577 })
578
579 it('leaves inline code in the middle of a sentence alone', () => {
580 const inline = style({ display: 'inline' })
581 const code = style({ display: 'inline', backgroundColor: 'rgb(240, 240, 240)' })
582 const nodes = [
583 el(0, -1, 'P', 0),
584 text(1, 0, 'Use '),
585 el(2, 0, 'CODE', 2),
586 text(3, 2, 'npm i'),
587 text(4, 0, ' to begin.'),
588 ]
589 const { slides } = run(nodes, [BASE_STYLE, inline, code])
590 // Slidev gives inline code a background, so an unconditional chip split
591 // cut ordinary sentences into three boxes and centered the code span; when
592 // such a sentence wrapped, the halves overlapped. A chip is a label on its
593 // own, not a word in the middle of a line.
594 const out = texts(slides[0].nodes)
595 expect(out).toHaveLength(1)
596 expect(out[0].runs.map(r => r.text)).toEqual(['Use ', 'npm i', ' to begin.'])
597 })
598 })
599
600 describe('text-transform and letter-spacing are applied', () => {
601 it('uppercases the run rather than trusting PowerPoint to do it', () => {
602 const label = style({ textTransform: 'uppercase', letterSpacing: '2px' })
603 const nodes = [el(0, -1, 'DIV', 1), text(1, 0, 'section one')]
604 const { slides } = run(nodes, [BASE_STYLE, label])
605 const out = texts(slides[0].nodes)[0]
606 // PowerPoint has no text-transform, so an untransformed run exports the
607 // lower-case source text and the slide silently changes.
608 expect(out.runs[0].text).toBe('SECTION ONE')
609 expect(out.runs[0].letterSpacing).toBe(2)
610 })
611 })
612
613 describe('text is positioned from glyph bounds', () => {
614 it('uses the glyph rects for a single line, not the element box', () => {
615 const nodes = [
616 el(0, -1, 'DIV', 0, { rect: { x: 0, y: 0, w: 500, h: 100 } }),
617 text(1, 0, 'one line', {
618 rect: { x: 0, y: 0, w: 500, h: 100 },
619 glyphRects: [{ x: 40, y: 10, w: 120, h: 20 }],
620 }),
621 ]
622 const { slides } = run(nodes, [BASE_STYLE])
623 const out = texts(slides[0].nodes)[0]
624 // Positioning from the element rect offsets the text by the container's
625 // padding, and its width bears no relation to the ink.
626 expect(out.rect).toEqual({ x: 40, y: 10, w: 120, h: 20 })
627 expect(out.lineCount).toBe(1)
628 })
629
630 it('wraps multi-line text against the container content box', () => {
631 const padded = style({ paddingLeft: '8px', paddingRight: '8px' })
632 const nodes = [
633 el(0, -1, 'DIV', 1, { rect: { x: 0, y: 0, w: 200, h: 100 } }),
634 text(1, 0, 'two lines here', {
635 rect: { x: 0, y: 0, w: 200, h: 100 },
636 glyphRects: [
637 { x: 8, y: 10, w: 120, h: 20 },
638 { x: 8, y: 34, w: 90, h: 20 },
639 ],
640 }),
641 ]
642 const { slides } = run(nodes, [BASE_STYLE, padded])
643 const out = texts(slides[0].nodes)[0]
644 // Glyph bounds for wrapped text are the width of the LONGEST LINE, so
645 // handing PowerPoint 120px guarantees a second, tighter wrap: "a short caption" came back over three lines and overflowed its box. The browser
646 // wrapped against the content box, which is 200 less 8px either side.
647 expect(out.rect).toEqual({ x: 8, y: 10, w: 184, h: 44 })
648 // Vertical position still comes from the glyphs, not the element.
649 expect(out.rect.y).toBe(10)
650 expect(out.lineCount).toBe(2)
651 })
652 })
653
654 describe('list bullets survive as text', () => {
655 it('recovers a ::marker glyph that has no text node', () => {
656 const item = style({ display: 'list-item' })
657 const nodes = [
658 el(0, -1, 'LI', 1, { marker: '• ' }),
659 text(1, 0, 'first point'),
660 ]
661 const { slides } = run(nodes, [BASE_STYLE, item])
662 const out = texts(slides[0].nodes)
663 // A plain DOM walk drops every bullet in the deck, because ::marker is not
664 // in the tree.
665 expect(out.some(t => t.runs[0].text.includes('•'))).toBe(true)
666 expect(out.some(t => t.runs[0].text === 'first point')).toBe(true)
667 })
668 })
669
670 describe('what has to become a picture', () => {
671 it('rasterizes a mermaid svg whose labels are foreignObject', () => {
672 expect(rasterReasonFor(el(0, -1, 'SVG', 0, { hasForeignObject: true }), BASE_STYLE))
673 .toBe('foreign-object')
674 })
675
676 it('rasterizes svg, canvas and cross-origin iframes', () => {
677 expect(rasterReasonFor(el(0, -1, 'SVG', 0), BASE_STYLE)).toBe('svg')
678 expect(rasterReasonFor(el(0, -1, 'CANVAS', 0), BASE_STYLE)).toBe('canvas')
679 // demo/starter embeds <Tweet>, which is a cross-origin frame the walker
680 // cannot descend into at all.
681 expect(rasterReasonFor(el(0, -1, 'IFRAME', 0), BASE_STYLE)).toBe('iframe')
682 })
683
684 it('rasterizes a css background image and a gradient-filled heading', () => {
685 expect(rasterReasonFor(el(0, -1, 'DIV', 0), style({ backgroundImage: 'url(cover.jpg)' })))
686 .toBe('background-image')
687 expect(rasterReasonFor(el(0, -1, 'H1', 0), style({ webkitBackgroundClip: 'text' })))
688 .toBe('background-clip-text')
689 })
690
691 it('keeps a shadow-root diagram rather than skipping it', () => {
692 const nodes = [
693 el(0, -1, 'DIV', 0),
694 el(1, 0, 'SVG', 0, { fromShadowRoot: true, hasForeignObject: true }),
695 ]
696 const { slides, rasterRequests } = run(nodes, [BASE_STYLE])
697 // Mermaid renders into a shadow root, so a document-level query finds
698 // nothing and the diagram vanishes from the export entirely.
699 expect(slides[0].nodes.some(n => n.kind === 'raster')).toBe(true)
700 expect(rasterRequests).toHaveLength(1)
701 })
702 })
703
704 describe('a picture with content over it is captured in isolation', () => {
705 it('isolates a leaf picture that a title is drawn on top of', () => {
706 const full = { x: 0, y: 0, w: 980, h: 552 }
707 const title = { x: 80, y: 200, w: 400, h: 60 }
708 const nodes = [
709 el(0, -1, 'SVG', 0, { rect: full }),
710 el(1, -1, 'H1', 0, { rect: title }),
711 text(2, 1, 'A cover title', { rect: title, glyphRects: [title] }),
712 ]
713 const { slides, rasterRequests } = run(nodes, [BASE_STYLE])
714 // `locator.screenshot()` clips the page rather than isolating the element,
715 // so without this the title is baked into the picture AND drawn again as
716 // a shape, printing every line of the slide twice.
717 const raster = slides[0].nodes.find(n => n.kind === 'raster') as any
718 expect(raster.isolate).toBe(true)
719 expect(rasterRequests[0].isolate).toBe(true)
720 // But NOT its own children: an <svg>'s children are its artwork, and
721 // nothing redraws them, so hiding those emptied the decorative chrome out
722 // of a themed deck entirely.
723 expect(rasterRequests[0].hideDescendants).toBe(false)
724 })
725
726 it('leaves a picture alone when nothing is drawn over it', () => {
727 const corner = { x: 0, y: 0, w: 100, h: 100 }
728 const away = { x: 500, y: 400, w: 200, h: 20 }
729 const nodes = [
730 el(0, -1, 'SVG', 0, { rect: corner }),
731 el(1, -1, 'DIV', 0, { rect: away }),
732 text(2, 1, 'caption', { rect: away, glyphRects: [away] }),
733 ]
734 const { rasterRequests } = run(nodes, [BASE_STYLE])
735 // Isolation costs two DOM mutations per capture, so it is not free.
736 expect(rasterRequests[0].isolate).toBe(false)
737 })
738 })
739
740 describe('only backdrops need their descendants hidden', () => {
741 it('isolates a backdrop but not a leaf picture', () => {
742 const backdrop = run(
743 [el(0, -1, 'DIV', 1), el(1, 0, 'DIV', 0), text(2, 1, 'on top')],
744 [BASE_STYLE, style({ backgroundImage: 'linear-gradient(red, blue)' })],
745 )
746 const raster = backdrop.slides[0].nodes.find(n => n.kind === 'raster') as any
747 // Without hiding siblings, the text bakes into the backdrop picture and is
748 // then drawn again as a shape, doubling every word.
749 expect(raster.isolate).toBe(true)
750 // A backdrop's children ARE walked and redrawn, so hiding them is correct.
751 expect(raster.hideDescendants).toBe(true)
752 // Its children are still walked, so the text survives as editable text.
753 expect(texts(backdrop.slides[0].nodes)).toHaveLength(1)
754
755 const leaf = run([el(0, -1, 'SVG', 0)], [BASE_STYLE])
756 expect((leaf.slides[0].nodes[0] as any).isolate).toBe(false)
757 })
758 })
759
760 describe('a slide that cannot be rebuilt falls back to an image', () => {
761 it('falls back when a slide with text yields none', () => {
762 // Every text node sits under an <svg>, so all of it rasterizes.
763 const nodes = [el(0, -1, 'SVG', 0), text(1, 0, 'invisible to the walk')]
764 const { slides } = run(nodes, [BASE_STYLE])
765 expect(slides[0].fallbackReason).toMatch(/no text could be recovered/)
766 })
767
768 it('falls back when most of the slide is a picture with nothing over it', () => {
769 const big = { x: 0, y: 0, w: 900, h: 500 }
770 const away = { x: 905, y: 505, w: 60, h: 20 }
771 const nodes = [
772 el(0, -1, 'SVG', 0, { rect: big }),
773 el(1, -1, 'DIV', 0, { rect: away }),
774 text(2, 1, 'a caption in the corner', { rect: away, glyphRects: [away] }),
775 ]
776 const { slides } = run(nodes, [BASE_STYLE])
777 // The caption sits beside the picture rather than on it, so vectorizing
778 // really did recover almost nothing.
779 expect(slides[0].fallbackReason).toMatch(/rasterized/)
780 })
781
782 it('keeps a slide whose text sits ON TOP of a full-bleed picture', () => {
783 const full = { x: 0, y: 0, w: 980, h: 552 }
784 const title = { x: 80, y: 200, w: 400, h: 60 }
785 const nodes = [
786 el(0, -1, 'SVG', 0, { rect: full }),
787 el(1, -1, 'H1', 0, { rect: title }),
788 text(2, 1, 'A cover title', { rect: title, glyphRects: [title] }),
789 ]
790 const { slides } = run(nodes, [BASE_STYLE])
791 // A cover photo or decorative graphic covers the slide by definition. The
792 // title over it vectorizes perfectly, so counting the picture against the
793 // budget threw away the text on every cover slide in the deck.
794 expect(slides[0].fallbackReason).toBeUndefined()
795 expect(texts(slides[0].nodes)[0].runs[0].text).toBe('A cover title')
796 })
797
798 it('does not request captures for a slide that is falling back anyway', () => {
799 const nodes = [el(0, -1, 'SVG', 0), text(1, 0, 'invisible to the walk')]
800 const { rasterRequests } = run(nodes, [BASE_STYLE])
801 // The whole slide becomes one picture, so per-element captures would be
802 // paid for and thrown away.
803 expect(rasterRequests).toHaveLength(0)
804 })
805
806 it('leaves an ordinary slide alone', () => {
807 const nodes = [el(0, -1, 'DIV', 0), text(1, 0, 'ordinary content')]
808 const { slides } = run(nodes, [BASE_STYLE])
809 expect(slides[0].fallbackReason).toBeUndefined()
810 })
811 })
812
813 describe('content outside the slide is clipped', () => {
814 it('clips a shape that overflows the slide', () => {
815 const wide = style({ backgroundColor: 'rgb(1, 2, 3)' })
816 const nodes = [el(0, -1, 'DIV', 1, { rect: { x: 900, y: 0, w: 500, h: 100 } })]
817 const { slides } = run(nodes, [BASE_STYLE, wide])
818 // A slide container is overflow:hidden, but PowerPoint has no clipping, so
819 // an unclamped shape sits off the canvas where it cannot even be selected.
820 expect(boxes(slides[0].nodes)[0].rect).toEqual({ x: 900, y: 0, w: 80, h: 100 })
821 })
822
823 it('drops a shape that is entirely off the slide', () => {
824 const off = style({ backgroundColor: 'rgb(1, 2, 3)' })
825 const nodes = [el(0, -1, 'DIV', 1, { rect: { x: 2000, y: 0, w: 100, h: 100 } })]
826 const { slides } = run(nodes, [BASE_STYLE, off])
827 expect(boxes(slides[0].nodes)).toHaveLength(0)
828 })
829
830 it('captures a runaway element as a clipped region, not as itself', () => {
831 // Slidev's own starter deck carries an element measuring tens of millions
832 // of pixels. Screenshotting it whole asks Chromium for a bitmap it cannot
833 // allocate, and the renderer dies mid-export; the failure then surfaces
834 // from an unrelated call as "Target page, context or browser has been
835 // closed", which is a miserable thing to debug.
836 // Only the left edge of it is on the slide, so this does not also trip the
837 // whole-slide fallback, which withholds captures by design.
838 const huge = { x: 900, y: 0, w: 24_000_000, h: 300 }
839 const page = { x: 1000, y: 200, w: 24_000_000, h: 300 }
840 const nodes = [el(0, -1, 'CANVAS', 0, { rect: huge, pageRect: page })]
841 const { slides, rasterRequests } = run(nodes, [BASE_STYLE])
842 const raster = slides[0].nodes[0] as any
843 // Placed at the clipped rectangle, and captured at the same one, so the
844 // picture is neither squashed nor enormous.
845 expect(raster.rect).toEqual({ x: 900, y: 0, w: 80, h: 300 })
846 expect(rasterRequests[0].clip).toEqual({ x: 1000, y: 200, w: 80, h: 300 })
847 })
848
849 it('does not let a runaway rect blow up the fallback maths', () => {
850 // Slidev's own starter deck has an element measuring tens of millions of
851 // pixels, which reported "104064986954% of the slide had to be rasterized".
852 // No text here, so the area rule is the one under test.
853 const huge = { x: 0, y: 0, w: 24_000_000, h: 24_000_000 }
854 const { slides } = run([el(0, -1, 'CANVAS', 0, { rect: huge })], [BASE_STYLE])
855 expect(slides[0].fallbackReason).toMatch(/^100% of the slide/)
856 })
857 })
858
859 describe('a full-bleed backdrop does not trigger the fallback', () => {
860 it('keeps the text on a cover slide editable', () => {
861 const cover = style({ backgroundImage: 'url(https://cover.sli.dev)' })
862 const nodes = [
863 el(0, -1, 'DIV', 1, { rect: { x: 0, y: 0, w: 980, h: 552 } }),
864 el(1, 0, 'H1', 0),
865 text(2, 1, 'Welcome to Slidev'),
866 ]
867 const { slides } = run(nodes, [BASE_STYLE, cover])
868 // A cover photo covers 100% of the slide by definition, but the title on
869 // top of it is still perfectly vectorizable. Counting isolated backdrops
870 // toward the raster budget sent every deck with a cover straight to the
871 // whole-slide fallback.
872 expect(slides[0].fallbackReason).toBeUndefined()
873 expect(texts(slides[0].nodes)[0].runs[0].text).toBe('Welcome to Slidev')
874 })
875 })
876
877 describe('fonts', () => {
878 it('names the family the browser actually resolved', () => {
879 const nodes = [el(0, -1, 'DIV', 0), text(1, 0, 'hello')]
880 const { slides } = run(nodes, [BASE_STYLE])
881 expect(texts(slides[0].nodes)[0].runs[0].fontFamily).toBe('Inter')
882 })
883
884 it('strips the @fontsource Variable suffix when nothing resolved', () => {
885 const nodes = [el(0, -1, 'DIV', 1), text(1, 0, 'hello')]
886 const styles = [BASE_STYLE, style({ fontFamily: '"Inter Variable", sans-serif' })]
887 const { slides } = run(nodes, styles)
888 // Nobody has a font installed under the name "Inter Variable"; that is a
889 // packaging artifact of @fontsource-variable.
890 expect(texts(slides[0].nodes)[0].runs[0].fontFamily).toBe('Inter')
891 })
892 })
893
894 describe('boxes', () => {
895 it('ignores a fully transparent background', () => {
896 const nodes = [el(0, -1, 'DIV', 0)]
897 const { slides } = run(nodes, [BASE_STYLE])
898 expect(boxes(slides[0].nodes)).toHaveLength(0)
899 })
900
901 it('keeps one side of a border', () => {
902 const accent = style({ borderLeftWidth: '4px', borderLeftStyle: 'solid', borderLeftColor: 'rgb(0, 128, 0)' })
903 const nodes = [el(0, -1, 'DIV', 1)]
904 const { slides } = run(nodes, [BASE_STYLE, accent])
905 const box = boxes(slides[0].nodes)[0]
906 expect(box.borders?.[3]).toEqual({ width: 4, color: { r: 0, g: 128, b: 0, a: 1 }, style: 'solid' })
907 expect(box.borders?.[0]).toBeUndefined()
908 })
909 })
910
911 describe('lines are grouped by overlap, not by matching tops', () => {
912 it('counts one line when a heading mixes run sizes', () => {
913 // A browser aligns runs of different sizes on a shared BASELINE, so their
914 // rect tops differ while they sit on the same line. Grouping by top
915 // counted `Needed a **Pros-Cons comparison** in now?` as four lines where
916 // it has two, and the spacing that came out of that count set the two real
917 // lines on top of each other.
918 const small = { x: 0, y: 130, w: 120, h: 40 }
919 const large = { x: 120, y: 100, w: 400, h: 100 }
920 const nodes = [
921 el(0, -1, 'H1', 0, { rect: { x: 0, y: 100, w: 520, h: 220 } }),
922 text(1, 0, 'Needed a ', { glyphRects: [small] }),
923 text(2, 0, 'Pros-Cons', { glyphRects: [large] }),
924 text(3, 0, 'comparison', { glyphRects: [{ ...large, y: 220 }] }),
925 text(4, 0, ' in now?', { glyphRects: [{ ...small, y: 250 }] }),
926 ]
927 const [line] = texts(run(nodes, [BASE_STYLE]).slides[0].nodes)
928 expect(line.lineCount).toBe(2)
929 })
930
931 it('takes the line spacing from the measured line boxes', () => {
932 // The computed `line-height` belongs to the element while a line box is as
933 // tall as its largest run, so a heading mixing sizes reported far too
934 // little leading and PowerPoint overlapped the lines.
935 const nodes = [
936 el(0, -1, 'H1', 0, { rect: { x: 0, y: 0, w: 400, h: 240 } }),
937 text(1, 0, 'first', { glyphRects: [{ x: 0, y: 0, w: 300, h: 100 }] }),
938 text(2, 0, 'second', { glyphRects: [{ x: 0, y: 120, w: 300, h: 100 }] }),
939 ]
940 const [line] = texts(run(nodes, [BASE_STYLE]).slides[0].nodes)
941 // 120, the distance between the line tops. `line-height: normal` on a
942 // 16px font would have said 19.2.
943 expect(line.lineHeight).toBe(120)
944 })
945
946 it('still separates lines whose ink very nearly touches', () => {
947 const nodes = [
948 el(0, -1, 'P', 0, { rect: { x: 0, y: 0, w: 400, h: 40 } }),
949 text(1, 0, 'one', { glyphRects: [{ x: 0, y: 0, w: 300, h: 20 }] }),
950 text(2, 0, 'two', { glyphRects: [{ x: 0, y: 19, w: 300, h: 20 }] }),
951 ]
952 const [line] = texts(run(nodes, [BASE_STYLE]).slides[0].nodes)
953 expect(line.lineCount).toBe(2)
954 })
955 })
956
957 describe('a wrapped inline box is painted once per line', () => {
958 it('emits one shape per line fragment', () => {
959 // CSS paints an inline element's background once per line FRAGMENT, while
960 // `getBoundingClientRect` reports the union of them. Drawn as that union,
961 // an inline `<code>` running across three lines filled the ragged space at
962 // the end of every line with its own dark background.
963 const inline = style({ display: 'inline', backgroundColor: 'rgb(0, 0, 0)' })
964 const fragments = [
965 { x: 100, y: 0, w: 200, h: 20 },
966 { x: 0, y: 20, w: 300, h: 20 },
967 { x: 0, y: 40, w: 80, h: 20 },
968 ]
969 const nodes = [
970 el(0, -1, 'CODE', 1, { rect: { x: 0, y: 0, w: 300, h: 60 }, fragments }),
971 ]
972 const painted = boxes(run(nodes, [BASE_STYLE, inline]).slides[0].nodes)
973 expect(painted.map(box => box.rect)).toEqual(fragments)
974 })
975
976 it('leaves an unwrapped element as one shape', () => {
977 const filled = style({ backgroundColor: 'rgb(0, 0, 0)' })
978 const nodes = [el(0, -1, 'DIV', 1, { rect: { x: 0, y: 0, w: 300, h: 60 } })]
979 const painted = boxes(run(nodes, [BASE_STYLE, filled]).slides[0].nodes)
980 expect(painted).toHaveLength(1)
981 expect(painted[0].rect).toEqual({ x: 0, y: 0, w: 300, h: 60 })
982 })
983 })
984
985 describe('a raster that cannot be placed must not swallow its subtree', () => {
986 it('walks into a rotated wrapper that has no box of its own', () => {
987 // A theme rotating a corner decoration puts the transform on a wrapper
988 // with no size, while the artwork inside is absolutely positioned and does
989 // have one. Treating the wrapper as rasterized dropped the whole
990 // decoration: the picture had no area, so nothing was emitted for it
991 // either, and the entire corner vanished from every slide.
992 const rotated = style({ transform: 'matrix(0, 1, -1, 0, 0, 0)', position: 'absolute' })
993 const nodes = [
994 el(0, -1, 'DIV', 1, { rect: { x: 0, y: 0, w: 0, h: 0 } }),
995 el(1, 0, 'SVG', 0, { rect: { x: 10, y: 20, w: 279, h: 322 } }),
996 ]
997 const { slides, rasterRequests } = run(nodes, [BASE_STYLE, rotated])
998 expect(slides[0].nodes.map(node => node.sourceId)).toEqual([1])
999 expect(rasterRequests.map(request => request.sourceId)).toEqual([1])
1000 })
1001 })
1002
1003 describe('an image cut off by the slide edge is cropped, not squashed', () => {
1004 it('keeps the full display size and takes a window out of it', () => {
1005 // The slide container has `overflow: hidden`, so a browser shows the top
1006 // of an oversized image and cuts the rest off. Clipping the BOX alone
1007 // scaled the whole image into it instead, which came out vertically
1008 // compressed and showed content the audience never saw.
1009 const nodes = [el(0, -1, 'IMG', 0, { rect: { x: 56, y: 40, w: 434, h: 772 }, src: 'a.png' })]
1010 const [image] = run(nodes, [BASE_STYLE]).slides[0].nodes as any[]
1011 expect(image.rect).toEqual({ x: 56, y: 40, w: 434, h: 512 })
1012 expect(image.crop).toEqual({ x: 0, y: 0, w: 434, h: 772 })
1013 })
1014
1015 it('leaves an image that fits uncropped', () => {
1016 const nodes = [el(0, -1, 'IMG', 0, { rect: { x: 10, y: 10, w: 100, h: 50 }, src: 'a.png' })]
1017 const [image] = run(nodes, [BASE_STYLE]).slides[0].nodes as any[]
1018 expect(image.crop).toBeUndefined()
1019 })
1020 })
1021
1022 describe('a rendered formula is a picture', () => {
1023 it('rasterizes a KaTeX root instead of walking its spans', () => {
1024 // KaTeX sets a formula as dozens of separately positioned spans in its own
1025 // metric fonts, with radicals, braces and fraction rules drawn as bare
1026 // boxes. Walked as text it comes apart: glyphs land off their baselines
1027 // and every rule disappears.
1028 const nodes = [
1029 el(0, -1, 'DIV', 0),
1030 el(1, 0, 'SPAN', 0, { isMath: true, rect: { x: 10, y: 10, w: 200, h: 40 } }),
1031 el(2, 1, 'SPAN', 0, { rect: { x: 10, y: 10, w: 20, h: 40 } }),
1032 text(3, 2, '3x'),
1033 el(4, 0, 'P', 0, { rect: { x: 10, y: 80, w: 200, h: 20 } }),
1034 text(5, 4, 'Block', { glyphRects: [{ x: 10, y: 80, w: 60, h: 20 }] }),
1035 ]
1036 const { slides, rasterRequests } = run(nodes, [BASE_STYLE])
1037 const math = slides[0].nodes.filter(node => node.kind === 'raster')
1038 expect(math).toHaveLength(1)
1039 expect((math[0] as any).reason).toBe('math')
1040 // Its own spans are not ALSO drawn as text.
1041 expect(texts(slides[0].nodes).map(t => t.runs.map(r => r.text).join(''))).toEqual(['Block'])
1042 expect(rasterRequests.map(request => request.sourceId)).toEqual([1])
1043 })
1044
1045 it('leaves an ordinary span alone', () => {
1046 const nodes = [el(0, -1, 'SPAN', 0), text(1, 0, 'plain')]
1047 const { slides } = run(nodes, [BASE_STYLE])
1048 expect(slides[0].nodes.map(node => node.kind)).toEqual(['text'])
1049 })
1050 })
1051
1052 describe('gradient text is the picture, not a backdrop for it', () => {
1053 it('reports background-clip before background-image', () => {
1054 // Gradient text is both at once: a linear-gradient clipped to the glyphs,
1055 // with `color` left as a flat fallback for browsers without the clip.
1056 // Called a background image it counts as a backdrop, so its own text is
1057 // drawn again on top and the fallback color hides the gradient.
1058 const gradient = style({
1059 backgroundImage: 'linear-gradient(45deg, rgb(78, 197, 212) 10%, rgb(20, 107, 140) 20%)',
1060 webkitBackgroundClip: 'text',
1061 color: 'rgb(93, 131, 146)',
1062 })
1063 expect(rasterReasonFor(el(0, -1, 'H1', 1), gradient)).toBe('background-clip-text')
1064 })
1065
1066 it('leaves the text of a real backdrop editable', () => {
1067 const backdrop = style({ backgroundImage: 'url(cover.png)' })
1068 expect(rasterReasonFor(el(0, -1, 'DIV', 1), backdrop)).toBe('background-image')
1069 })
1070
1071 it('does not draw the heading twice', () => {
1072 const gradient = style({
1073 backgroundImage: 'linear-gradient(45deg, rgb(78, 197, 212) 10%, rgb(20, 107, 140) 20%)',
1074 webkitBackgroundClip: 'text',
1075 })
1076 const nodes = [
1077 el(0, -1, 'H1', 1, { rect: { x: 54, y: 40, w: 870, h: 40 } }),
1078 text(1, 0, 'What is Slidev?', { glyphRects: [{ x: 54, y: 36, w: 256, h: 47 }] }),
1079 el(2, -1, 'P', 0, { rect: { x: 54, y: 88, w: 700, h: 23 } }),
1080 text(3, 2, 'Body copy', { glyphRects: [{ x: 54, y: 88, w: 200, h: 23 }] }),
1081 ]
1082 const { slides } = run(nodes, [BASE_STYLE, gradient])
1083 expect(slides[0].nodes.filter(n => n.kind === 'raster')).toHaveLength(1)
1084 // The body copy survives; only the heading became a picture.
1085 expect(texts(slides[0].nodes).map(t => t.runs.map(r => r.text).join(''))).toEqual(['Body copy'])
1086 })
1087 })
1088
1089 describe('an inline rule is an underline, not a shape', () => {
1090 const linkStyle = style({
1091 display: 'inline',
1092 borderBottomWidth: '1px',
1093 borderBottomStyle: 'dashed',
1094 borderBottomColor: 'rgb(0, 0, 0)',
1095 })
1096
1097 it('draws a dashed border-bottom as a dashed underline', () => {
1098 // Slidev rules its links this way. As a separate line it has to be placed
1099 // against text PowerPoint may re-lay, cannot follow an edit, and is a
1100 // second thing that can draw a line under a link PowerPoint may underline
1101 // itself. DrawingML dashes an underline, so none of that is needed.
1102 const nodes = [
1103 el(0, -1, 'P', 0),
1104 el(1, 0, 'A', 1, { href: 'https://sli.dev' }),
1105 text(2, 1, 'Why Slidev?'),
1106 ]
1107 const { slides } = run(nodes, [BASE_STYLE, linkStyle])
1108 expect(boxes(slides[0].nodes)).toHaveLength(0)
1109 const [line] = texts(slides[0].nodes)
1110 expect(line.runs[0].underline).toBe(true)
1111 expect(line.runs[0].underlineStyle).toBe('dash')
1112 })
1113
1114 it('leaves a bordered box a box', () => {
1115 // A bottom border on a BLOCK is a rule under a section, not an underline.
1116 const blockRule = style({ borderBottomWidth: '1px', borderBottomStyle: 'solid', borderBottomColor: 'rgb(0, 0, 0)' })
1117 const nodes = [el(0, -1, 'DIV', 1), text(1, 0, 'Section')]
1118 const { slides } = run(nodes, [BASE_STYLE, blockRule])
1119 expect(boxes(slides[0].nodes)).toHaveLength(1)
1120 expect(texts(slides[0].nodes)[0].runs[0].underline).toBeUndefined()
1121 })
1122
1123 it('leaves a chip alone, because it is more than a rule', () => {
1124 const chip = style({
1125 display: 'inline',
1126 backgroundColor: 'rgb(200, 200, 200)',
1127 borderBottomWidth: '1px',
1128 borderBottomStyle: 'dashed',
1129 borderBottomColor: 'rgb(0, 0, 0)',
1130 })
1131 const nodes = [el(0, -1, 'P', 0), el(1, 0, 'CODE', 1), text(2, 1, 'npm i')]
1132 const { slides } = run(nodes, [BASE_STYLE, chip])
1133 expect(boxes(slides[0].nodes).length).toBeGreaterThan(0)
1134 })
1135 })
1136
1137 describe('a table cell flows its children, it does not lay them out', () => {
1138 it('places each key of a shortcut row on its own', () => {
1139 // A chip is padded and spaced by its own box, which flowed text cannot
1140 // reproduce: the labels run together while the keys keep their gaps.
1141 const cell = style({ display: 'table-cell' })
1142 const chip = style({ display: 'inline-block', backgroundColor: 'rgb(240, 240, 240)' })
1143 const nodes = [
1144 el(0, -1, 'TD', 1),
1145 el(1, 0, 'KBD', 2, { rect: { x: 69, y: 226, w: 48, h: 27 } }),
1146 text(2, 1, 'left', { rect: { x: 73, y: 233, w: 40, h: 20 }, glyphRects: [{ x: 73, y: 233, w: 40, h: 20 }] }),
1147 text(3, 0, ' / ', { rect: { x: 117, y: 233, w: 13, h: 20 }, glyphRects: [{ x: 117, y: 233, w: 13, h: 20 }] }),
1148 el(4, 0, 'KBD', 2, { rect: { x: 130, y: 226, w: 48, h: 27 } }),
1149 text(5, 4, 'shift', { rect: { x: 134, y: 233, w: 40, h: 20 }, glyphRects: [{ x: 134, y: 233, w: 40, h: 20 }] }),
1150 el(6, 0, 'KBD', 2, { rect: { x: 186, y: 226, w: 48, h: 27 } }),
1151 text(7, 6, 'space', { rect: { x: 190, y: 233, w: 40, h: 20 }, glyphRects: [{ x: 190, y: 233, w: 40, h: 20 }] }),
1152 ]
1153 const lines = texts(run(nodes, [BASE_STYLE, cell, chip]).slides[0].nodes)
1154 expect(lines.map(l => l.runs.map(r => r.text).join(''))).toEqual(['left', ' / ', 'shift', 'space'])
1155 // The separator keeps the spaces its glyph bounds were measured across:
1156 // trimmed, it drew where the leading space had been, on the key beside it.
1157 expect(lines[1].runs[0].text).toBe(' / ')
1158 })
1159
1160 it('keeps a cell of inline content in one text box', () => {
1161 // A table lays out its rows and a row its cells, but the contents of a
1162 // cell flow like any block. Treated as a layout container,
1163 // `<kbd>right</kbd> / <kbd>space</kbd>` became three boxes, and the
1164 // separator was positioned from glyph bounds that included the spaces
1165 // around it while its own text had them trimmed, so it sat on the key.
1166 const cell = style({ display: 'table-cell' })
1167 const chip = style({ display: 'inline-block', backgroundColor: 'rgb(240, 240, 240)' })
1168 const nodes = [
1169 el(0, -1, 'TD', 1),
1170 el(1, 0, 'SPAN', 3, { rect: { x: 69, y: 233, w: 48, h: 20 } }),
1171 text(2, 1, 'right', { rect: { x: 73, y: 233, w: 40, h: 20 }, glyphRects: [{ x: 73, y: 233, w: 40, h: 20 }] }),
1172 text(3, 0, ' / ', { rect: { x: 117, y: 233, w: 13, h: 20 }, glyphRects: [{ x: 117, y: 233, w: 13, h: 20 }] }),
1173 el(4, 0, 'SPAN', 3, { rect: { x: 130, y: 233, w: 48, h: 20 } }),
1174 text(5, 4, 'space', { rect: { x: 134, y: 233, w: 40, h: 20 }, glyphRects: [{ x: 134, y: 233, w: 40, h: 20 }] }),
1175 ]
1176 const plain = style({ display: 'inline' })
1177 const lines = texts(run(nodes, [BASE_STYLE, cell, chip, plain]).slides[0].nodes)
1178 expect(lines).toHaveLength(1)
1179 expect(lines[0].runs.map(r => r.text)).toEqual(['right', ' / ', 'space'])
1180 })
1181
1182 it('still lays out the rows of a table', () => {
1183 const table = style({ display: 'table' })
1184 const nodes = [
1185 el(0, -1, 'TABLE', 1),
1186 el(1, 0, 'TR', 0, { rect: { x: 0, y: 0, w: 200, h: 20 } }),
1187 text(2, 1, 'one', { glyphRects: [{ x: 0, y: 0, w: 60, h: 20 }] }),
1188 el(3, 0, 'TR', 0, { rect: { x: 0, y: 30, w: 200, h: 20 } }),
1189 text(4, 3, 'two', { glyphRects: [{ x: 0, y: 30, w: 60, h: 20 }] }),
1190 ]
1191 const lines = texts(run(nodes, [BASE_STYLE, table]).slides[0].nodes)
1192 expect(lines.map(l => l.runs.map(r => r.text).join(''))).toEqual(['one', 'two'])
1193 })
1194 })
1195
1196 describe('a block box inside an inline run is not folded into the paragraph', () => {
1197 // Shiki's `<code>` is `display: inline`, and TwoSlash renders a diagnostic as
1198 // a `<div>` inside it. Collecting text through the inline subtree without
1199 // testing display ran the message onto the end of the code line it belongs
1200 // to: "doubled.value = 2Cannot assign to 'value' because it is a read-only
1201 // property." on `demo/starter`.
1202 const styles = [
1203 style({ display: 'block', whiteSpace: 'pre' }), // 0: <pre>
1204 style({ display: 'inline', whiteSpace: 'pre' }), // 1: <code>
1205 style({ display: 'block', whiteSpace: 'pre' }), // 2: the diagnostic
1206 ]
1207 const nodes = [
1208 el(1, -1, 'PRE', 0),
1209 el(2, 1, 'CODE', 1),
1210 text(3, 2, 'doubled.value = 2'),
1211 el(4, 2, 'DIV', 2, { rect: { x: 0, y: 30, w: 200, h: 20 } }),
1212 text(5, 4, 'Cannot assign to \'value\'.', { rect: { x: 0, y: 30, w: 200, h: 20 }, glyphRects: [{ x: 0, y: 30, w: 200, h: 20 }] }),
1213 ]
1214
1215 it('gives the diagnostic its own text box', () => {
1216 const found = texts(run(nodes, styles).slides[0].nodes)
1217 const joined = found.map(t => t.runs.map(r => r.text).join('')).join('|')
1218 expect(joined).not.toContain('2Cannot assign')
1219 expect(found).toHaveLength(2)
1220 })
1221 })
1222
1222 lines TYPESCRIPT