返回 slidev
walker.ts
根目录 / packages / slidev / node / commands / pptx / walker.ts
1 import type { RawNode, RawSlide, RawSnapshot, RawStyle } from './ir'
2
3 /**
4 * The one function that runs inside the browser. Invariant: zero free variables.
5 * Playwright serializes this function's source and evaluates it where none of this
6 * module's imports exist, so everything it needs is nested inside it or passed as a
7 * parameter; `walker.test.ts` enforces this mechanically. The type-only import above
8 * is erased at compile time and is safe.
9 *
10 * It extracts facts only a live layout engine can supply and makes no decisions;
11 * every judgement lives in `normalize.ts`, where it can be unit-tested.
12 */
13 export function collectSnapshot(options: {
14 containerSelector: string
15 idAttribute: string
16 }): RawSnapshot {
17 const STYLE_KEYS = [
18 'display',
19 'position',
20 'zIndex',
21 'visibility',
22 'opacity',
23 'color',
24 'backgroundColor',
25 'backgroundImage',
26 'fontFamily',
27 'fontSize',
28 'fontWeight',
29 'fontStyle',
30 'textAlign',
31 'textDecorationLine',
32 'textTransform',
33 'letterSpacing',
34 'lineHeight',
35 'whiteSpace',
36 'paddingLeft',
37 'paddingRight',
38 'borderTopWidth',
39 'borderTopStyle',
40 'borderTopColor',
41 'borderRightWidth',
42 'borderRightStyle',
43 'borderRightColor',
44 'borderBottomWidth',
45 'borderBottomStyle',
46 'borderBottomColor',
47 'borderLeftWidth',
48 'borderLeftStyle',
49 'borderLeftColor',
50 'borderTopLeftRadius',
51 'boxShadow',
52 'filter',
53 'backdropFilter',
54 'mixBlendMode',
55 'clipPath',
56 'transform',
57 'writingMode',
58 'webkitBackgroundClip',
59 'top',
60 'right',
61 'bottom',
62 'left',
63 'width',
64 'height',
65 'overflow',
66 ] as const
67
68 /** Strips the quotes CSS keeps around a `content` or font family value. */
69 const RE_QUOTES = /^["']|["']$/g
70
71 const styles: RawStyle[] = []
72 const styleIndex = new Map<string, number>()
73
74 /**
75 * Intern a computed style: `getComputedStyle` exposes several hundred properties,
76 * so serializing a full style per node turns a snapshot into megabytes crossing
77 * the CDP boundary. A few hundred nodes collapse to a few dozen distinct styles.
78 */
79 function intern(computed: CSSStyleDeclaration): number {
80 const record: Record<string, string> = {}
81 for (const key of STYLE_KEYS)
82 record[key] = computed[key as any] ?? ''
83 const key = JSON.stringify(record)
84 const existing = styleIndex.get(key)
85 if (existing !== undefined)
86 return existing
87 const index = styles.length
88 // Complete by construction: every `RawStyle` key comes from `STYLE_KEYS`.
89 styles.push(record as unknown as RawStyle)
90 styleIndex.set(key, index)
91 return index
92 }
93
94 /**
95 * Which family a CSS font stack actually resolves to on this machine, measured by
96 * rendering a probe string and comparing widths: `document.fonts.check()` returns
97 * true for families that do not exist.
98 */
99 const unplaceablePseudos: string[] = []
100 const fontResolution: Record<string, string> = {}
101 const probeCanvas = document.createElement('canvas')
102 const probeContext = probeCanvas.getContext('2d')!
103 const PROBE = 'mmmmmmmmmmlliWWWWWW0Oo'
104 const BASES = ['monospace', 'sans-serif', 'serif']
105 const baseWidths: Record<string, number> = {}
106 for (const base of BASES) {
107 probeContext.font = `72px ${base}`
108 baseWidths[base] = probeContext.measureText(PROBE).width
109 }
110
111 function isAvailable(family: string): boolean {
112 for (const base of BASES) {
113 probeContext.font = `72px "${family}", ${base}`
114 // A missing family falls through to the base, so an identical width
115 // against every base means it never took effect.
116 if (probeContext.measureText(PROBE).width !== baseWidths[base])
117 return true
118 }
119 return false
120 }
121
122 // CSS keywords, not typefaces; naming one in a .pptx makes PowerPoint substitute its default.
123 const GENERIC = [
124 'system-ui',
125 'ui-sans-serif',
126 'ui-serif',
127 'ui-monospace',
128 'ui-rounded',
129 'sans-serif',
130 'serif',
131 'monospace',
132 'cursive',
133 'fantasy',
134 'math',
135 'emoji',
136 'fangsong',
137 'inherit',
138 'initial',
139 'unset',
140 ]
141
142 function resolveStack(stack: string): void {
143 if (stack in fontResolution)
144 return
145 for (const raw of stack.split(',')) {
146 const family = raw.trim().replace(RE_QUOTES, '')
147 if (!family)
148 continue
149 if (GENERIC.includes(family.toLowerCase()) || family.charAt(0) === '-')
150 continue
151 // `@fontsource-variable` names its family "Inter Variable"; the face
152 // people actually have installed is "Inter". So the stack is probed
153 // under the name the page really registered, and only the name written
154 // into the file is stripped. Testing the stripped name first finds
155 // nothing whenever the theme ships the font as a webfont and the machine
156 // has no static copy, which is the usual case, and the whole deck then
157 // falls through to a system face the author never chose.
158 const cleaned = family.replace(/\s+Variable$/, '')
159 if (isAvailable(family) || isAvailable(cleaned)) {
160 fontResolution[stack] = cleaned
161 return
162 }
163 }
164 fontResolution[stack] = ''
165 }
166
167 /**
168 * The nearest painted background color at or above the slide container; themes
169 * can put `bg-main` on an ancestor rather than on the container itself.
170 */
171 function backgroundOf(container: Element): string | undefined {
172 // Any fully transparent color ends nothing: matching only the literal
173 // `rgba(0, 0, 0, 0)` misses `rgba(255, 255, 255, 0)` and `oklch(... / 0)`.
174 // The browser judges, since it accepts syntaxes no parser here does.
175 const probe = document.createElement('canvas').getContext('2d')
176 let node: Element | null = container
177 while (node) {
178 const color = getComputedStyle(node).backgroundColor
179 if (color && color !== 'transparent') {
180 if (!probe)
181 return color
182 // Painting over an opaque backdrop leaves it untouched only when the color contributes nothing.
183 probe.clearRect(0, 0, 1, 1)
184 probe.fillStyle = color
185 probe.fillRect(0, 0, 1, 1)
186 if (probe.getImageData(0, 0, 1, 1).data[3] !== 0)
187 return color
188 }
189 node = node.parentElement
190 }
191 return undefined
192 }
193
194 /**
195 * The text an ordinary list marker renders, which the DOM never exposes; ordered
196 * lists need the ordinal counted over previous list-item siblings.
197 */
198 function markerGlyph(el: Element, listStyleType: string): string {
199 const BULLETS: Record<string, string> = {
200 'disc': '\u2022 ',
201 'circle': '\u25E6 ',
202 'square': '\u25AA ',
203 'disclosure-open': '\u25BE ',
204 'disclosure-closed': '\u25B8 ',
205 }
206 if (listStyleType in BULLETS)
207 return BULLETS[listStyleType]
208
209 let ordinal = 1
210 let sibling = el.previousElementSibling
211 while (sibling) {
212 if (getComputedStyle(sibling).display === 'list-item')
213 ordinal++
214 sibling = sibling.previousElementSibling
215 }
216 const parent = el.parentElement
217 const start = parent && parent.tagName.toUpperCase() === 'OL'
218 ? Number(parent.getAttribute('start') || '1')
219 : 1
220 const value = ordinal + (Number.isFinite(start) ? start : 1) - 1
221
222 if (listStyleType === 'lower-alpha' || listStyleType === 'lower-latin')
223 return `${String.fromCharCode(96 + ((value - 1) % 26) + 1)}. `
224 if (listStyleType === 'upper-alpha' || listStyleType === 'upper-latin')
225 return `${String.fromCharCode(64 + ((value - 1) % 26) + 1)}. `
226 return `${value}. `
227 }
228
229 const containers = Array.from(document.querySelectorAll(options.containerSelector))
230 const slides: RawSlide[] = []
231 let nextId = 0
232
233 for (const container of containers) {
234 const containerRect = container.getBoundingClientRect()
235
236 // A print page stacks every slide into one tall viewport; a container not
237 // being rendered has a zero-sized rect. `--range` is the caller's filter.
238 if (containerRect.width === 0 || containerRect.height === 0)
239 continue
240
241 // `003-02` is slide 3, click step 2 (1-based in the id).
242 const parts = (container.id || '').split('-')
243 const no = Number(parts[0])
244 const clickIndex = Number(parts[1]) - 1
245 if (!Number.isFinite(no))
246 continue
247
248 const nodes: RawNode[] = []
249
250 function relative(rect: DOMRect | { left: number, top: number, width: number, height: number }) {
251 return {
252 x: rect.left - containerRect.left,
253 y: rect.top - containerRect.top,
254 w: rect.width,
255 h: rect.height,
256 }
257 }
258
259 function walk(node: Node, parent: number, fromShadowRoot: boolean, inheritedOpacity: number): void {
260 if (node.nodeType === 3) {
261 const text = node.textContent ?? ''
262 // A whitespace-only node can hold the only space between two inline
263 // elements; kept when it occupies a line box, ignored when collapsed.
264 if (!text)
265 return
266 const range = document.createRange()
267 range.selectNodeContents(node)
268 const rects = Array.from(range.getClientRects())
269 range.detach()
270 // A rect-less node still matters twice over: inside `white-space: pre`
271 // a newline is the only record of where a line ends, and a
272 // whitespace-only node is the only record that two inline elements are
273 // separated at all. Chromium reports no rect for the space between two
274 // inline-blocks, and without it `<kbd>shift</kbd> <kbd>space</kbd>`
275 // came out as "shiftspace".
276 if (!rects.length) {
277 if (text.trim())
278 return
279 nodes.push({ id: nextId++, parent, tag: '#text', style: -1, rect: { x: 0, y: 0, w: 0, h: 0 }, glyphRects: [], text })
280 return
281 }
282 // Glyph rects, not the element box: positioned from the element rect the text lands offset and re-wraps.
283 const bounds = {
284 left: Math.min(...rects.map(r => r.left)),
285 top: Math.min(...rects.map(r => r.top)),
286 width: Math.max(...rects.map(r => r.right)) - Math.min(...rects.map(r => r.left)),
287 height: Math.max(...rects.map(r => r.bottom)) - Math.min(...rects.map(r => r.top)),
288 }
289 nodes.push({
290 id: nextId++,
291 parent,
292 tag: '#text',
293 style: -1,
294 rect: relative(bounds),
295 glyphRects: rects.map(relative),
296 text,
297 // A text node inherits the compounded opacity; without it a greyed paragraph exports solid.
298 ...(inheritedOpacity < 1 ? { opacity: inheritedOpacity } : {}),
299 })
300 return
301 }
302
303 if (node.nodeType !== 1)
304 return
305
306 const el = node as Element
307 const id = nextId++
308 const computed = getComputedStyle(el)
309
310 // `visibility: hidden` and `opacity: 0` still have boxes: a not-yet-revealed
311 // v-click element sits at opacity 0 and would export onto every click step.
312 if (computed.display === 'none' || computed.visibility === 'hidden')
313 return
314 const own = Number(computed.opacity)
315 // CSS `opacity` does not inherit, and DrawingML has no group opacity to stand
316 // in for a wrapper's, so it is compounded here while the tree is available.
317 const effectiveOpacity = inheritedOpacity * (Number.isFinite(own) ? own : 1)
318 if (effectiveOpacity === 0)
319 return
320
321 el.setAttribute(options.idAttribute, String(id))
322 resolveStack(computed.fontFamily)
323
324 const box = el.getBoundingClientRect()
325 const record: RawNode = {
326 id,
327 parent,
328 tag: el.tagName.toUpperCase(),
329 style: intern(computed),
330 rect: relative(box),
331 // Document coordinates too: an element overflowing the slide is
332 // captured as a page clip, which needs a page-space rectangle.
333 pageRect: {
334 x: box.left + window.scrollX,
335 y: box.top + window.scrollY,
336 w: box.width,
337 h: box.height,
338 },
339 }
340 if (effectiveOpacity < 1)
341 record.opacity = effectiveOpacity
342 // An inline box that wraps paints once per line, so backgrounds belong to
343 // the fragments. Only `inline` proper: an inline-block has one rect.
344 if (computed.display === 'inline') {
345 const fragments = Array.from(el.getClientRects())
346 if (fragments.length > 1)
347 record.fragments = fragments.map(relative)
348 }
349 if (fromShadowRoot)
350 record.fromShadowRoot = true
351 // KaTeX marks its root by class; the MathML it writes for screen readers is display:none.
352 if (el.classList.contains('katex'))
353 record.isMath = true
354 if (el.tagName.toUpperCase() === 'IMG') {
355 record.src = (el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src
356 const alt = (el as HTMLImageElement).alt
357 if (alt)
358 record.alt = alt
359 }
360 if (el.tagName.toUpperCase() === 'A') {
361 const href = (el as HTMLAnchorElement).href
362 if (href)
363 record.href = href
364 }
365 if (el.tagName.toUpperCase() === 'SVG' && el.querySelector('foreignObject'))
366 // Mermaid puts its labels in <foreignObject> HTML with no <text>
367 // element; the normalizer routes these to a picture.
368 record.hasForeignObject = true
369
370 // `::marker` is a pseudo-element, so a bullet has no text node. Reading
371 // `content` alone recovers only custom markers: Chromium reports it as
372 // `normal` for ordinary lists and leaves the glyph to `list-style-type`.
373 if (computed.display === 'list-item') {
374 const marker = getComputedStyle(el, '::marker')
375 const explicit = marker && marker.content
376 && marker.content !== 'none'
377 && marker.content !== 'normal'
378 if (explicit) {
379 record.marker = marker.content.replace(RE_QUOTES, '')
380 }
381 else if (computed.listStyleType !== 'none') {
382 record.marker = markerGlyph(el, computed.listStyleType)
383 }
384 }
385
386 nodes.push(record)
387
388 // `::before` and `::after` have no DOM node, so a tree walk cannot see
389 // them, and themes use them for decorative marks. Only absolutely
390 // positioned pseudos are placeable: anything in flow has geometry not
391 // recoverable from computed style alone, and is reported instead.
392 for (const which of ['::before', '::after']) {
393 const pseudo = getComputedStyle(el, which)
394 if (!pseudo || !pseudo.content || pseudo.content === 'none')
395 continue
396 const paints = pseudo.content !== 'normal'
397 || (pseudo.backgroundImage && pseudo.backgroundImage !== 'none')
398 if (!paints)
399 continue
400 if (pseudo.position !== 'absolute' || computed.position === 'static') {
401 unplaceablePseudos.push(`${el.tagName.toLowerCase()}${which}`)
402 continue
403 }
404
405 const own = el.getBoundingClientRect()
406 const width = Number.parseFloat(pseudo.width) || 0
407 const height = Number.parseFloat(pseudo.height) || 0
408 if (width <= 0 || height <= 0) {
409 unplaceablePseudos.push(`${el.tagName.toLowerCase()}${which}`)
410 continue
411 }
412 // `auto` parses to NaN, which spreads through the whole `pageRect` and
413 // loses the decoration to a failed clip. Zero is what `auto` resolves
414 // to for an absolutely positioned box with no other constraint.
415 const px = (value: string): number => Number.parseFloat(value) || 0
416 const left = pseudo.left === 'auto'
417 ? own.width - px(pseudo.right) - width
418 : px(pseudo.left)
419 const top = pseudo.top === 'auto'
420 ? own.height - px(pseudo.bottom) - height
421 : px(pseudo.top)
422
423 const box = {
424 left: own.left + left,
425 top: own.top + top,
426 width,
427 height,
428 }
429 // Document coordinates, not viewport ones: the clip screenshot is taken
430 // after other captures have scrolled the page, so viewport coordinates
431 // read at measurement time are stale by then.
432 const pageRect = {
433 x: box.left + window.scrollX,
434 y: box.top + window.scrollY,
435 w: width,
436 h: height,
437 }
438 const text = pseudo.content.replace(RE_QUOTES, '')
439 nodes.push({
440 id: nextId++,
441 parent: id,
442 tag: which === '::before' ? '::BEFORE' : '::AFTER',
443 style: intern(pseudo),
444 rect: relative(box),
445 // A pseudo has no element to screenshot, so it is captured by clipping the page.
446 pageRect,
447 ...(text && pseudo.content !== 'normal' ? { text } : {}),
448 })
449 }
450
451 // Descend into the shadow root as well; Mermaid renders into one.
452 const shadow = (el as any).shadowRoot
453 if (shadow) {
454 for (const child of Array.from(shadow.childNodes) as Node[])
455 walk(child, id, true, effectiveOpacity)
456 }
457
458 // Recurse through zero-sized boxes rather than pruning them: a cover
459 // slide commonly hangs its title off a wrapper that measures 0 high.
460 for (const child of Array.from(el.childNodes) as Node[])
461 walk(child, id, fromShadowRoot, effectiveOpacity)
462 }
463
464 for (const child of Array.from(container.childNodes) as Node[])
465 walk(child, -1, false, 1)
466
467 slides.push({
468 no,
469 clickIndex: Number.isFinite(clickIndex) ? clickIndex : 0,
470 // The exact id: with `--with-clicks` a prefix match plus `.first()`
471 // hands every step a picture of step one.
472 containerId: container.id,
473 size: { w: containerRect.width, h: containerRect.height },
474 // The container's own background is never reached by a walk that starts
475 // at its children; without it a dark theme exports onto default white.
476 background: backgroundOf(container),
477 nodes,
478 })
479 }
480
481 return { slides, styles, fontResolution, unplaceablePseudos } as RawSnapshot
482 }
483
483 lines TYPESCRIPT