返回 slidev
normalize.ts
根目录 / packages / slidev / node / commands / pptx / normalize.ts
1 import type { UnparsedColors } from './color'
2 import type {
3 Border,
4 IrBox,
5 IrNode,
6 IrRun,
7 IrText,
8 RasterReason,
9 RawNode,
10 RawSlide,
11 RawSnapshot,
12 RawStyle,
13 Rect,
14 SlideIr,
15 } from './ir'
16 import { isVisible, parseColor, withOpacity } from './color'
17
18 /**
19 * `RawSnapshot` to `SlideIr`: every judgement in the exporter, in one pure module with
20 * no DOM or Playwright access, so each rule is testable from a hand-written fixture.
21 * Where a rule looks over-specific, it is a bug that already happened; see `normalize.test.ts`.
22 */
23
24 /**
25 * Fraction of the slide that may be rasterized before the whole slide falls back to
26 * a picture: past this, a half-vector slide invites editing that will not work.
27 */
28 const RASTER_AREA_LIMIT = 0.6
29
30 /**
31 * Fraction of a line's width kept free so PowerPoint does not re-wrap it: PowerPoint
32 * sets the same string a little wider than Chromium, so a shrink-wrapped container
33 * gains a wrap. The headroom scales with the line.
34 */
35 /**
36 * How much loose text a row of chips may hold and still be a row of chips.
37 *
38 * A separator between keys is punctuation; a sentence carrying inline `<code>`
39 * is words, and that stays one editable box.
40 */
41 const SEPARATOR_MAX_CHARS = 3
42
43 const WRAP_HEADROOM = 0.02
44 const WRAP_HEADROOM_MIN_PX = 4
45
46 /**
47 * CSS `display` values whose children are laid out, not flowed as text.
48 *
49 * `table-cell` and `table-caption` are excluded: a table lays out its rows and
50 * a row its cells, but the CONTENTS of a cell flow like any block, so treating
51 * one as a layout container split `<kbd>right</kbd> / <kbd>space</kbd>` into
52 * three boxes and left the slash sitting on the first key.
53 *
54 * `list-item` is absent for the same reason: it flows its children as text,
55 * and its ::marker is emitted before they are visited.
56 */
57 const LAYOUT_DISPLAY = /^(?:flex|grid|inline-flex|inline-grid|table(?!-cell|-caption))/
58
59 /**
60 * `display` values whose box participates in a line box and whose children flow as
61 * text. `inline-flex`, `inline-grid` and `inline-table` are excluded on purpose: they
62 * lay their children out as boxes, so a naive `^inline` match would concatenate their cells.
63 */
64 const INLINE_DISPLAY = /^(?:inline(?:-block)?$|contents|ruby)/
65
66 const RASTER_TAGS: Record<string, RasterReason> = {
67 SVG: 'svg',
68 CANVAS: 'canvas',
69 VIDEO: 'media',
70 AUDIO: 'media',
71 IFRAME: 'iframe',
72 }
73
74 export interface RasterRequest {
75 /** The `data-slidev-export-id` of the element to capture. */
76 sourceId: number
77 /** Page coordinates to clip, for a pseudo-element that has no element to select. */
78 clip?: Rect
79 /** Hide everything outside this element's own subtree. */
80 isolate: boolean
81 /** Also hide its children, which is only safe when they are redrawn. */
82 hideDescendants: boolean
83 /** The element to isolate against for a pseudo-element, which has no id attribute of its own; its originating element does. */
84 isolateId?: number
85 }
86
87 export interface NormalizeOptions {
88 /** Note text per 1-based slide number. */
89 notes: Map<number, string | undefined>
90 }
91
92 export interface NormalizeResult {
93 slides: SlideIr[]
94 rasterRequests: RasterRequest[]
95 /** Color strings no parser understood, so the caller can report them. */
96 unparsedColors: string[]
97 }
98
99 export function parseLength(value: string | undefined, basis?: number): number {
100 if (!value)
101 return 0
102 const n = Number.parseFloat(value)
103 if (Number.isNaN(n))
104 return 0
105 if (value.trim().endsWith('%'))
106 return basis === undefined ? 0 : (n / 100) * basis
107 return n
108 }
109
110 /**
111 * The part of `rect` on the slide, or undefined. Slides are `overflow: hidden`
112 * while PowerPoint has no clipping, so an unclamped shape stays on the canvas off
113 * the edge; a rect can also run to tens of millions of pixels.
114 */
115 export function clipToSlide(rect: Rect, size: { w: number, h: number }): Rect | undefined {
116 const x = Math.max(rect.x, 0)
117 const y = Math.max(rect.y, 0)
118 const right = Math.min(rect.x + rect.w, size.w)
119 const bottom = Math.min(rect.y + rect.h, size.h)
120 if (right <= x || bottom <= y)
121 return undefined
122 return { x, y, w: right - x, h: bottom - y }
123 }
124
125 /** Whether two rects share any area at all. */
126 function overlaps(a: Rect, b: Rect): boolean {
127 return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
128 }
129
130 function borderOf(style: RawStyle, side: 'Top' | 'Right' | 'Bottom' | 'Left', unparsed: UnparsedColors): Border | undefined {
131 const width = parseLength((style as any)[`border${side}Width`])
132 const lineStyle = (style as any)[`border${side}Style`] as string
133 const color = parseColor((style as any)[`border${side}Color`], unparsed)
134 if (width <= 0 || !lineStyle || lineStyle === 'none' || lineStyle === 'hidden' || !isVisible(color))
135 return undefined
136 return {
137 width,
138 color: color!,
139 style: lineStyle === 'dashed' ? 'dashed' : lineStyle === 'dotted' ? 'dotted' : 'solid',
140 }
141 }
142
143 /**
144 * Why this element cannot be drawn as shapes, or undefined if it can. Everything listed
145 * lacks a DrawingML primitive; the honest move is one picture at the exact box.
146 */
147 export function rasterReasonFor(node: RawNode, style: RawStyle | undefined): RasterReason | undefined {
148 // Before the tag map, which would report a Mermaid diagram as a plain 'svg';
149 // the reason reaches the log and the picture's alt text.
150 if (node.hasForeignObject)
151 return 'foreign-object'
152 // Also before the tag map: a formula's root is a `<span>`, so nothing else
153 // here would catch it.
154 if (node.isMath)
155 return 'math'
156 const tagReason = RASTER_TAGS[node.tag]
157 if (tagReason)
158 return tagReason
159 if (!style)
160 return undefined
161 // Before `background-image`, because gradient text is BOTH: a gradient
162 // clipped to the glyphs, with `color` left as a flat fallback. Reported as a
163 // background image it counts as a backdrop, so the text is drawn again over
164 // the picture, and the fallback color hides the gradient it was standing in
165 // for.
166 if (style.webkitBackgroundClip === 'text')
167 return 'background-clip-text'
168 if (style.backgroundImage && style.backgroundImage !== 'none')
169 return 'background-image'
170 if (style.filter && style.filter !== 'none')
171 return 'filter'
172 if (style.backdropFilter && style.backdropFilter !== 'none')
173 return 'backdrop-filter'
174 if (style.mixBlendMode && style.mixBlendMode !== 'normal')
175 return 'mix-blend-mode'
176 if (style.clipPath && style.clipPath !== 'none')
177 return 'clip-path'
178 if (style.transform && style.transform !== 'none')
179 return 'transform'
180 if (style.writingMode && style.writingMode !== 'horizontal-tb')
181 return 'writing-mode'
182 return undefined
183 }
184
185 /**
186 * Whether a rasterized element needs its siblings hidden during capture: a backdrop has
187 * content painted on top of it, which would bake into the picture and be drawn again as
188 * shapes. A leaf such as an `<svg>` has nothing on top.
189 */
190 function needsIsolation(reason: RasterReason): boolean {
191 return reason === 'background-image'
192 || reason === 'backdrop-filter'
193 || reason === 'filter'
194 || reason === 'mix-blend-mode'
195 }
196
197 function applyTransform(text: string, transform: string | undefined): string {
198 switch (transform) {
199 case 'uppercase':
200 return text.toUpperCase()
201 case 'lowercase':
202 return text.toLowerCase()
203 case 'capitalize':
204 return text.replace(/\b\p{L}/gu, c => c.toUpperCase())
205 default:
206 return text
207 }
208 }
209
210 function resolveLineHeight(style: RawStyle): number {
211 const fontSize = parseLength(style.fontSize)
212 if (!style.lineHeight || style.lineHeight === 'normal')
213 return fontSize * 1.2
214 return parseLength(style.lineHeight) || fontSize * 1.2
215 }
216
217 function alignOf(style: RawStyle | undefined): IrText['align'] {
218 switch (style?.textAlign) {
219 case 'center':
220 return 'center'
221 case 'right':
222 case 'end':
223 return 'right'
224 case 'justify':
225 return 'justify'
226 default:
227 return 'left'
228 }
229 }
230
231 /**
232 * CSS keywords rather than typefaces; naming one in the file makes PowerPoint
233 * substitute its default silently, so the fallback must skip these as well.
234 */
235 const GENERIC_FAMILIES = new Set([
236 'system-ui',
237 'ui-sans-serif',
238 'ui-serif',
239 'ui-monospace',
240 'ui-rounded',
241 'sans-serif',
242 'serif',
243 'monospace',
244 'cursive',
245 'fantasy',
246 'math',
247 'emoji',
248 'fangsong',
249 'inherit',
250 'initial',
251 'unset',
252 ])
253
254 export function fallbackFamily(stack: string): string {
255 for (const raw of stack.split(',')) {
256 const family = raw.trim().replace(/^["']|["']$/g, '').replace(/\s+Variable$/, '')
257 if (!family || family.startsWith('-') || GENERIC_FAMILIES.has(family.toLowerCase()))
258 continue
259 return family
260 }
261 return 'Arial'
262 }
263
264 function runFrom(text: string, style: RawStyle, fontResolution: Record<string, string>, unparsed: UnparsedColors, link?: string, opacity?: number, rule?: IrRun['underlineStyle']): IrRun {
265 const weight = Number(style.fontWeight)
266 const decoration = style.textDecorationLine || ''
267 const family = fontResolution[style.fontFamily] || fallbackFamily(style.fontFamily)
268 const run: IrRun = {
269 text: applyTransform(text, style.textTransform),
270 fontSize: parseLength(style.fontSize),
271 fontFamily: family,
272 }
273 if (weight >= 600)
274 run.bold = true
275 if (style.fontStyle === 'italic' || style.fontStyle === 'oblique')
276 run.italic = true
277 if (decoration.includes('underline')) {
278 run.underline = true
279 }
280 else if (rule) {
281 // An inline `border-bottom` IS an underline. Drawn as a separate line it
282 // has to be positioned against text PowerPoint may re-lay, and it cannot
283 // move when the text is edited, which is the whole point of this format.
284 run.underline = true
285 run.underlineStyle = rule
286 }
287 if (decoration.includes('line-through'))
288 run.strike = true
289 const color = withOpacity(parseColor(style.color, unparsed), opacity)
290 if (isVisible(color))
291 run.color = color
292 const spacing = parseLength(style.letterSpacing)
293 if (spacing)
294 run.letterSpacing = spacing
295 if (link)
296 run.link = link
297 return run
298 }
299
300 function boundsOf(rects: Rect[]): Rect {
301 const x = Math.min(...rects.map(r => r.x))
302 const y = Math.min(...rects.map(r => r.y))
303 return {
304 x,
305 y,
306 w: Math.max(...rects.map(r => r.x + r.w)) - x,
307 h: Math.max(...rects.map(r => r.y + r.h)) - y,
308 }
309 }
310
311 /**
312 * Group glyph rects into line boxes by vertical overlap, not by equal top: runs of
313 * different sizes on the same line align on the baseline, so their tops differ, and
314 * sub-pixel layout defeats rounding. A shared line overlaps by more than half the shorter rect.
315 */
316 function lineGroups(rects: Rect[]): { top: number, bottom: number }[] {
317 const groups: { top: number, bottom: number }[] = []
318 for (const rect of [...rects].sort((a, b) => a.y - b.y)) {
319 const current = groups[groups.length - 1]
320 const overlap = current ? Math.min(current.bottom, rect.y + rect.h) - Math.max(current.top, rect.y) : 0
321 if (current && overlap > 0.5 * Math.min(rect.h, current.bottom - current.top)) {
322 current.bottom = Math.max(current.bottom, rect.y + rect.h)
323 current.top = Math.min(current.top, rect.y)
324 }
325 else {
326 groups.push({ top: rect.y, bottom: rect.y + rect.h })
327 }
328 }
329 return groups
330 }
331
332 function countLines(rects: Rect[]): number {
333 return lineGroups(rects).length
334 }
335
336 /**
337 * How far apart the browser actually set the lines, or undefined for one line. The computed
338 * `line-height` is too small whenever a heading mixes sizes; the median keeps one
339 * unusually tall line from stretching the rest.
340 */
341 export function measuredLineHeight(rects: Rect[]): number | undefined {
342 const groups = lineGroups(rects)
343 if (groups.length < 2)
344 return undefined
345 const gaps = groups.slice(1).map((group, index) => group.top - groups[index].top)
346 gaps.sort((a, b) => a - b)
347 const median = gaps[Math.floor(gaps.length / 2)]
348 return median > 0 ? median : undefined
349 }
350
351 /**
352 * The first non-inset `box-shadow`, as PowerPoint's polar form: an angle and a
353 * distance. Multiple shadows and inset shadows have no equivalent and are dropped.
354 */
355 export function parseShadow(value: string | undefined, unparsed?: UnparsedColors): IrBox['shadow'] {
356 if (!value || value === 'none' || value.includes('inset'))
357 return undefined
358 // Any functional color notation; `rgb()` included.
359 const color = parseColor(value.match(/^[a-z-]+\([^)]+\)/i)?.[0], unparsed)
360 if (!isVisible(color))
361 return undefined
362 const lengths = [...value.matchAll(/(-?[\d.]+)px/g)].map(m => Number(m[1]))
363 if (lengths.length < 2)
364 return undefined
365 const [x, y, blur = 0] = lengths
366 return {
367 blur,
368 offset: Math.round(Math.hypot(x, y) * 100) / 100,
369 // DrawingML measures clockwise from the positive x axis, like CSS's y-down space; no sign flip.
370 angle: Math.round(((Math.atan2(y, x) * 180) / Math.PI + 360) % 360),
371 color: color!,
372 }
373 }
374
375 /** One slide's `RawNode` tree to its `IrNode` list. Every helper below closes over the per-slide index and accumulators. */
376 function buildSlideIr(
377 slide: RawSlide,
378 styles: RawStyle[],
379 fontResolution: Record<string, string>,
380 unparsed: UnparsedColors,
381 ): { nodes: IrNode[], requests: RasterRequest[], rasterArea: number, textCount: number } {
382 const byId = new Map<number, RawNode>()
383 const childIndex = new Map<number, RawNode[]>()
384 let nodes: IrNode[] = []
385 const requests: RasterRequest[] = []
386 let rasterArea = 0
387 const pageRects = new Map<number, Rect | undefined>()
388 const boxed = new Set<number>()
389 /** Paint layer per emitted node, parallel to `nodes`. */
390 const layers: { tier: number, z: number }[] = []
391 /** Pseudo-element id to the id of the element it belongs to. */
392 const originators = new Map<number, number>()
393 const size = slide.size
394
395 for (const node of slide.nodes) {
396 byId.set(node.id, node)
397 const siblings = childIndex.get(node.parent) ?? []
398 siblings.push(node)
399 childIndex.set(node.parent, siblings)
400 }
401
402 /**
403 * Where an element paints: CSS puts positioned elements above in-flow content
404 * whatever the document order says. Two tiers and a z-index cover what a slide deck actually does.
405 */
406 function layerOf(node: RawNode): { tier: number, z: number } {
407 // The nearest positioned ancestor, not the nearest styled one: a page
408 // counter is a positioned `<footer>` wrapping a plain `<div>`.
409 let current: RawNode | undefined = node
410 while (current) {
411 const style = styleOf(current)
412 if (style && style.position !== 'static' && style.position !== '') {
413 const z = Number.parseInt(style.zIndex, 10)
414 return { tier: 1, z: Number.isNaN(z) ? 0 : z }
415 }
416 current = byId.get(current.parent)
417 }
418 return { tier: 0, z: 0 }
419 }
420
421 function push(node: IrNode, source: RawNode): void {
422 nodes.push(node)
423 layers.push(layerOf(source))
424 }
425
426 function styleOf(node: RawNode): RawStyle | undefined {
427 return node.style >= 0 ? styles[node.style] : undefined
428 }
429
430 function childrenOf(id: number): RawNode[] {
431 return childIndex.get(id) ?? []
432 }
433
434 /** The nearest ancestor element's style, which is what styles a text node. */
435 function inheritedStyle(node: RawNode): RawStyle | undefined {
436 let current: RawNode | undefined = node
437 while (current) {
438 const style = styleOf(current)
439 if (style)
440 return style
441 current = byId.get(current.parent)
442 }
443 return undefined
444 }
445
446 function linkFor(node: RawNode): string | undefined {
447 let current: RawNode | undefined = node
448 while (current) {
449 if (current.href)
450 return current.href
451 current = byId.get(current.parent)
452 }
453 return undefined
454 }
455
456 /** Whether an inline element paints something of its own behind its text. */
457 function hasDecoration(node: RawNode): boolean {
458 const style = styleOf(node)
459 if (!style)
460 return false
461 if (isVisible(withOpacity(parseColor(style.backgroundColor, unparsed), node.opacity)))
462 return true
463 return (['Top', 'Right', 'Bottom', 'Left'] as const).some(side => borderOf(style, side, unparsed))
464 }
465
466 function isInline(node: RawNode): boolean {
467 if (node.tag === '#text')
468 return true
469 const style = styleOf(node)
470 return !!style && INLINE_DISPLAY.test(style.display)
471 }
472
473 function run(): { nodes: IrNode[], requests: RasterRequest[], rasterArea: number, textCount: number } {
474 for (const root of childrenOf(-1))
475 visit(root)
476
477 // Everything downstream treats array order as paint order, so reorder into
478 // CSS paint order first; a stable sort keeps document order within a layer.
479 const order = nodes.map((node, index) => ({ node, index, layer: layers[index] }))
480 order.sort((a, b) =>
481 a.layer.tier - b.layer.tier || a.layer.z - b.layer.z || a.index - b.index)
482 nodes = order.map(entry => entry.node)
483
484 const texts = nodes.filter(n => n.kind === 'text')
485 // Picture over picture is not the doubling problem below: each is drawn from its own screenshot.
486 const drawnOver = nodes.filter(n => n.kind === 'text' || n.kind === 'image')
487
488 for (const node of nodes) {
489 if (node.kind !== 'raster')
490 continue
491
492 // `locator.screenshot()` clips the page rather than isolating, so
493 // anything also drawn as a shape inside this picture's box would print
494 // twice. Overlap tests that directly; the CSS reason was only a proxy.
495 const covered = drawnOver.some(other => overlaps(other.rect, node.rect))
496 if (covered)
497 node.isolate = true
498
499 requests.push({
500 sourceId: node.sourceId,
501 isolate: node.isolate,
502 hideDescendants: node.hideDescendants,
503 clip: pageRects.get(node.sourceId),
504 isolateId: originators.get(node.sourceId),
505 })
506
507 // Count only raster area with no editable text over it: a cover photo
508 // covers the whole slide by definition while its title vectorizes
509 // perfectly, and counting those sent every cover slide to the fallback.
510 if (texts.some(text => overlaps(text.rect, node.rect)))
511 continue
512 const visible = clipToSlide(node.rect, size)
513 if (visible)
514 rasterArea += visible.w * visible.h
515 }
516
517 return { nodes, requests, rasterArea, textCount: texts.length }
518 }
519
520 /** Whether a picture was actually emitted for this element. */
521 function emitRaster(node: RawNode, reason: RasterReason): boolean {
522 // A pseudo-element has no DOM node to screenshot, so it alone is captured
523 // by clipping the page at coordinates measured here; everything else is
524 // captured from its box read live at capture time, which cannot be stale.
525 if (node.tag === '::BEFORE' || node.tag === '::AFTER') {
526 pageRects.set(node.id, node.pageRect)
527 originators.set(node.id, node.parent)
528 }
529 const isolate = needsIsolation(reason)
530 const visible = clipToSlide(node.rect, size)
531 if (!visible)
532 return false
533 // An element that runs past the slide is captured as a page clip and
534 // placed at that same clipped rectangle so the picture is not squashed.
535 // Screenshotting it whole can ask Chromium to rasterize tens of millions
536 // of pixels, which kills the renderer.
537 const overflows = visible.w !== node.rect.w || visible.h !== node.rect.h
538 if (overflows && node.pageRect) {
539 pageRects.set(node.id, {
540 x: node.pageRect.x + (visible.x - node.rect.x),
541 y: node.pageRect.y + (visible.y - node.rect.y),
542 w: visible.w,
543 h: visible.h,
544 })
545 }
546
547 push({
548 kind: 'raster',
549 sourceId: node.id,
550 // Children are walked and redrawn only for a backdrop; only then is hiding them safe.
551 hideDescendants: isolate,
552 rect: overflows ? visible : node.rect,
553 data: '',
554 reason,
555 isolate,
556 }, node)
557 return true
558 }
559
560 function emitImage(node: RawNode): void {
561 if (!node.src)
562 return
563 // Clipped like every other shape, or a partly off-slide image lands outside the canvas.
564 const rect = clipToSlide(node.rect, size)
565 if (!rect)
566 return
567 // Show the visible part of an oversized image and crop the rest, as
568 // `overflow: hidden` does; scaling it to fit would compress it.
569 const clipped = rect.w !== node.rect.w || rect.h !== node.rect.h
570 push({
571 kind: 'image',
572 sourceId: node.id,
573 rect,
574 data: node.src,
575 alt: node.alt,
576 link: linkFor(node),
577 ...(clipped
578 ? {
579 crop: {
580 x: rect.x - node.rect.x,
581 y: rect.y - node.rect.y,
582 w: node.rect.w,
583 h: node.rect.h,
584 },
585 }
586 : {}),
587 }, node)
588 }
589
590 function emitBox(node: RawNode, style: RawStyle): void {
591 // `emitTextGroup` and `visit` can both reach the same node; two
592 // translucent fills would composite visibly darker.
593 if (boxed.has(node.id))
594 return
595 boxed.add(node.id)
596 const fill = withOpacity(parseColor(style.backgroundColor, unparsed), node.opacity)
597 const borders: [Border?, Border?, Border?, Border?] = [
598 borderOf(style, 'Top', unparsed),
599 borderOf(style, 'Right', unparsed),
600 borderOf(style, 'Bottom', unparsed),
601 borderOf(style, 'Left', unparsed),
602 ]
603 const hasBorder = borders.some(Boolean)
604 // Chromium keeps `border-radius` percentages in the computed value, so a
605 // basis is needed: `border-radius: 50%` on a 200px box is 100px, not 50px.
606 const radius = parseLength(style.borderTopLeftRadius, Math.min(node.rect.w, node.rect.h))
607 if (!isVisible(fill) && !hasBorder)
608 return
609 const shadow = parseShadow(style.boxShadow, unparsed)
610 // One shape per line fragment for a wrapped inline element, as the browser
611 // paints; one rect over the union would fill the ragged line ends.
612 const boxes = node.fragments?.length ? node.fragments : [node.rect]
613 for (const source of boxes) {
614 const rect = clipToSlide(source, size)
615 if (!rect)
616 continue
617 const box: IrBox = { kind: 'box', sourceId: node.id, rect }
618 if (isVisible(fill))
619 box.fill = fill
620 if (hasBorder)
621 box.borders = borders
622 if (radius > 0)
623 box.radius = radius
624 if (shadow)
625 box.shadow = shadow
626 push(box, node)
627 }
628 }
629
630 function visit(node: RawNode): void {
631 // A pseudo-element paints either an image or a short string, and has no children.
632 if (node.tag === '::BEFORE' || node.tag === '::AFTER') {
633 const style = styleOf(node)
634 if (!style)
635 return
636 const reason = rasterReasonFor(node, style)
637 if (reason && emitRaster(node, reason))
638 return
639 emitBox(node, style)
640 if (node.text) {
641 push({
642 kind: 'text',
643 sourceId: node.id,
644 rect: node.rect,
645 elementRect: node.rect,
646 lineCount: 1,
647 align: alignOf(style),
648 valign: 'middle',
649 lineHeight: resolveLineHeight(style),
650 runs: [runFrom(node.text, style, fontResolution, unparsed)],
651 }, node)
652 }
653 return
654 }
655
656 if (node.tag === '#text') {
657 // Reached only when a text node has no block container of its own; guarded so a stray one is not lost.
658 emitTextGroup([node])
659 return
660 }
661
662 const style = styleOf(node)
663 const reason = rasterReasonFor(node, style)
664 // Treat the element as rasterized only when a picture was actually placed:
665 // a zero-sized wrapper has no box of its own while its positioned
666 // descendants do, and dropping the subtree would lose them.
667 if (reason && emitRaster(node, reason)) {
668 // A leaf such as an <svg> has no shapes worth recovering underneath it;
669 // a backdrop does, its children being drawn on top of the isolated picture.
670 if (!needsIsolation(reason))
671 return
672 // But not its own box: the screenshot already contains this element's
673 // background and borders, so drawing them again composites twice.
674 visitChildren(node)
675 return
676 }
677
678 if (style)
679 emitBox(node, style)
680
681 if (node.tag === 'IMG') {
682 emitImage(node)
683 return
684 }
685
686 // A ::marker is a pseudo-element: the bullet glyph has no text node, so a
687 // plain walk drops every bullet in the deck.
688 if (node.marker && style) {
689 const markerRect: Rect = {
690 x: Math.max(0, node.rect.x - parseLength(style.fontSize) * 1.2),
691 y: node.rect.y,
692 w: parseLength(style.fontSize) * 1.2,
693 h: resolveLineHeight(style),
694 }
695 push({
696 kind: 'text',
697 sourceId: node.id,
698 rect: markerRect,
699 elementRect: markerRect,
700 lineCount: 1,
701 align: 'left',
702 // Centered in the line box, where a browser puts a marker; anchored to
703 // the top it rides above its own item's first line.
704 valign: 'middle',
705 lineHeight: resolveLineHeight(style),
706 runs: [runFrom(node.marker, style, fontResolution, unparsed, undefined, node.opacity)],
707 }, node)
708 }
709
710 visitChildren(node)
711 }
712
713 function visitChildren(node: RawNode): void {
714 const style = styleOf(node)
715 const children = childrenOf(node.id)
716 if (!children.length)
717 return
718
719 // A grid or flex container lays its children out as boxes; grouping them
720 // as one run of text concatenates unrelated cells.
721 if (style && LAYOUT_DISPLAY.test(style.display)) {
722 for (const child of children)
723 visit(child)
724 return
725 }
726
727 // Consecutive inline children form one anonymous block box, which is one
728 // text box; one shape each would lay out from the same origin and overlap.
729 let group: RawNode[] = []
730 const flush = () => {
731 if (group.length) {
732 emitTextGroup(group)
733 group = []
734 }
735 }
736 // A chip is padded and spaced by its own box, which flowed text cannot
737 // reproduce: `<kbd>right</kbd> / <kbd>space</kbd>` set as one run drifts
738 // off keys that keep their measured gaps, and two adjacent chips run their
739 // labels together entirely. So each part of a row of chips is placed on
740 // its own glyph bounds instead.
741 //
742 // Only when the row occupies a single line. Fragments can be positioned
743 // exactly while they do not wrap; a wrapping sentence has to flow, or its
744 // halves lay out from the same origin and overlap. That is also what keeps
745 // an ordinary sentence carrying one inline `<code>` in one editable box.
746 // Glyph bounds for a text node: its element rect is not where its ink is.
747 const lineBox = (child: RawNode): Rect =>
748 child.glyphRects?.length ? boundsOf(child.glyphRects) : child.rect
749 const placed = children.filter(child => isInline(child) && lineBox(child).h > 0)
750 // By overlap, not by matching tops: a padded chip's box starts above the
751 // glyph bounds of the text beside it, so equal tops never held here.
752 const oneLine = placed.length > 1 && lineGroups(placed.map(lineBox)).length === 1
753 // Everything outside the chips. A separator is punctuation; a sentence is
754 // words, and a sentence stays one editable box even though its inline
755 // `<code>` drifts by the width of its own padding.
756 const loose = children
757 .filter(child => child.tag === '#text')
758 .map(child => (child.text ?? '').replace(/\s+/g, ''))
759 .join('')
760 const labelRow = oneLine && loose.length <= SEPARATOR_MAX_CHARS
761 && placed.some(child => hasDecoration(child))
762
763 for (const child of children) {
764 if (isInline(child)) {
765 // An inline element carrying its own background or border is a chip: its
766 // decoration is a positioned shape, so its label gets its own text box anchored
767 // to the glyph bounds, keeping metric differences from sliding the label off
768 // the chip. Only when it stands alone: Slidev styles inline `<code>` with a
769 // background, and an unconditional split overlaps a wrapped sentence.
770 if (labelRow || (hasDecoration(child) && children.length === 1)) {
771 flush()
772 emitTextGroup([child], labelRow)
773 continue
774 }
775 group.push(child)
776 }
777 else {
778 flush()
779 visit(child)
780 }
781 }
782 flush()
783 }
784
785 /** Every text node under an inline subtree, in document order. */
786 function collectText(node: RawNode, out: RawNode[]): void {
787 if (node.tag === '#text') {
788 out.push(node)
789 return
790 }
791 const style = styleOf(node)
792 const reason = rasterReasonFor(node, style)
793 // An inline <svg> icon still becomes a picture, but only if it has a box;
794 // otherwise its subtree is walked as usual rather than dropped.
795 if (reason && emitRaster(node, reason))
796 return
797 // A <br> has no text node of its own, so without this marker the text on
798 // either side of it concatenates with no break.
799 if (node.tag === 'BR') {
800 out.push(node)
801 return
802 }
803 // An inline <img> reaches here rather than `visit`; drawn at its measured box.
804 if (node.tag === 'IMG') {
805 emitImage(node)
806 return
807 }
808 // Anything that is not inline establishes its own box rather than flowing
809 // with the text around it, so it lays out on its own instead of being
810 // folded into this paragraph. Shiki's `<code>` is `display: inline` and
811 // TwoSlash renders a diagnostic as a `<div>` inside it, which ran the
812 // message onto the end of the code line: "= 2Cannot assign to 'value'".
813 //
814 // Not the same test as `visitChildren` makes. There `LAYOUT_DISPLAY` asks
815 // whether to stop grouping children, which a `table-cell` must not do.
816 // Here the question is only whether this subtree flows with its
817 // surroundings, and no non-inline box does.
818 if (style && !isInline(node)) {
819 visit(node)
820 return
821 }
822 for (const child of childrenOf(node.id))
823 collectText(child, out)
824 }
825
826 /**
827 * The underline an inline element draws with `border-bottom`, if that is all
828 * it draws. Slidev rules its links this way.
829 *
830 * Only when the bottom is the only border and there is no fill: anything
831 * more is a box, and a box has to stay a shape.
832 */
833 function underlineRule(node: RawNode): IrRun['underlineStyle'] | undefined {
834 const style = styleOf(node)
835 if (!style || !isInline(node))
836 return undefined
837 if (isVisible(parseColor(style.backgroundColor, unparsed)))
838 return undefined
839 const bottom = borderOf(style, 'Bottom', unparsed)
840 if (!bottom)
841 return undefined
842 for (const side of ['Top', 'Right', 'Left'] as const) {
843 if (borderOf(style, side, unparsed))
844 return undefined
845 }
846 return bottom.style === 'dashed' ? 'dash' : bottom.style === 'dotted' ? 'dotted' : 'sng'
847 }
848
849 /** The underline rule an ancestor of this text node draws, if any. */
850 function ruleOver(node: RawNode): IrRun['underlineStyle'] | undefined {
851 let current: RawNode | undefined = byId.get(node.parent)
852 while (current) {
853 const rule = underlineRule(current)
854 if (rule)
855 return rule
856 current = byId.get(current.parent)
857 }
858 return undefined
859 }
860
861 function emitTextGroup(group: RawNode[], fragment = false): void {
862 // Inline decorations first: paint order is array order, so a chip emitted
863 // after its label would cover it.
864 for (const node of group) {
865 if (node.tag === '#text')
866 continue
867 // The group node itself too: the chip is usually the outermost inline element in the group.
868 const own = styleOf(node)
869 // A node whose only decoration is an underline is drawn by the run.
870 if (underlineRule(node))
871 boxed.add(node.id)
872 if (own)
873 emitBox(node, own)
874 forEachDescendant(node, (descendant) => {
875 const style = styleOf(descendant)
876 if (style)
877 emitBox(descendant, style)
878 })
879 }
880
881 const textNodes: RawNode[] = []
882 for (const node of group)
883 collectText(node, textNodes)
884 if (!textNodes.length)
885 return
886
887 const runs: IrRun[] = []
888 const rects: Rect[] = []
889
890 /**
891 * Line breaks waiting for the next run; a count, since two consecutive `<br>` need an
892 * empty run between them. Held against the next run so a trailing break does not add an empty last line.
893 */
894 let pendingBreaks = 0
895
896 const addRun = (text: string, style: RawStyle, node: RawNode) => {
897 // One empty run per surplus break, so the blank lines survive.
898 while (pendingBreaks > 1 && runs.length) {
899 runs.push({ ...runFrom('', style, fontResolution, unparsed, undefined, node.opacity), breakBefore: true })
900 pendingBreaks--
901 }
902 const newRun = runFrom(text, style, fontResolution, unparsed, linkFor(node), node.opacity, ruleOver(node))
903 if (pendingBreaks && runs.length)
904 newRun.breakBefore = true
905 pendingBreaks = 0
906 runs.push(newRun)
907 }
908
909 for (const textNode of textNodes) {
910 if (textNode.tag === 'BR') {
911 pendingBreaks++
912 continue
913 }
914 const style = inheritedStyle(textNode)
915 if (!style)
916 continue
917 const raw = textNode.text ?? ''
918 if (!raw)
919 continue
920
921 // `white-space: pre` keeps its newlines, the only record of where a code
922 // block's lines end: Shiki separates line spans with "\n" text nodes, so
923 // folding them into spaces re-wraps the block into one paragraph.
924 if (style.whiteSpace.startsWith('pre')) {
925 const lines = raw.split('\n')
926 lines.forEach((line, index) => {
927 if (index > 0)
928 pendingBreaks++
929 if (line)
930 addRun(line, style, textNode)
931 })
932 rects.push(...(textNode.glyphRects ?? []))
933 continue
934 }
935
936 const text = raw.replace(/\s+/g, ' ')
937 if (!text.trim()) {
938 // A whitespace-only node is the space between two inline elements;
939 // folded into the previous run so it keeps no styling of its own.
940 if (runs.length && !runs[runs.length - 1].text.endsWith(' '))
941 runs[runs.length - 1].text += ' '
942 continue
943 }
944 addRun(text, style, textNode)
945 rects.push(...(textNode.glyphRects ?? [textNode.rect]))
946 }
947 if (!runs.length || !rects.length)
948 return
949
950 // Whitespace at line boundaries collapses in the browser but survives the
951 // fold above and shifts a centered line by half a space. Every line
952 // boundary counts, not just the box edges: a run after a break opens a new line.
953 // Not for a fragment: its edges are mid-line, where the browser rendered
954 // the spaces and the glyph bounds include them. Trimming there drew the
955 // text where the leading space had been, putting a separator on the key
956 // beside it.
957 if (!fragment) {
958 runs.forEach((run, index) => {
959 const opensLine = index === 0 || run.breakBefore
960 const closesLine = index === runs.length - 1 || runs[index + 1]?.breakBefore
961 if (opensLine)
962 run.text = run.text.replace(/^ +/, '')
963 if (closesLine)
964 run.text = run.text.replace(/ +$/, '')
965 })
966 }
967
968 const container = byId.get(group[0].parent)
969 const containerStyle = container ? styleOf(container) : undefined
970 const anchorStyle = containerStyle ?? inheritedStyle(textNodes[0])!
971
972 const glyphs = boundsOf(rects)
973 const lineCount = countLines(rects)
974
975 /**
976 * The box PowerPoint will wrap inside. Wrapped text's glyph bounds are the width of
977 * the longest line, which guarantees a second, tighter wrap; the browser wrapped
978 * against the container's content box, so reproduce that. Single-line text keeps its exact glyph bounds.
979 */
980 let rect = glyphs
981 let align = alignOf(anchorStyle)
982
983 /** Widen a box that has no room for PowerPoint's wider metrics. */
984 const withHeadroom = (box: Rect): Rect => {
985 const headroom = box.w - glyphs.w
986 const wanted = Math.max(WRAP_HEADROOM_MIN_PX, glyphs.w * WRAP_HEADROOM)
987 if (headroom >= wanted)
988 return box
989 const extra = wanted - Math.max(0, headroom)
990 // Grown about the anchor, so the text does not slide sideways.
991 const x = align === 'center'
992 ? box.x - extra / 2
993 : align === 'right' ? box.x - extra : box.x
994 return { x, y: box.y, w: box.w + extra, h: box.h }
995 }
996 let valign: IrText['valign']
997
998 /**
999 * A chip label is pinned to its chip's box and centered there, keeping the inset even
1000 * however the font measures; positioned from the glyphs it ends hard against one edge
1001 * as soon as PowerPoint sets the string wider.
1002 */
1003 const decorated = group.length === 1 && group[0].tag !== '#text' && hasDecoration(group[0])
1004 if (decorated) {
1005 rect = group[0].rect
1006 align = 'center'
1007 valign = 'middle'
1008 }
1009 else if (lineCount > 1 && container && containerStyle) {
1010 const left = parseLength(containerStyle.paddingLeft)
1011 const right = parseLength(containerStyle.paddingRight)
1012 const width = container.rect.w - left - right
1013 if (width > 0) {
1014 rect = withHeadroom({ x: container.rect.x + left, y: glyphs.y, w: width, h: glyphs.h })
1015 }
1016 }
1017
1018 // Clipped like every other node, or browser-clipped text lands off the
1019 // canvas while still suppressing the whole-slide fallback.
1020 if (!clipToSlide(rect, size))
1021 return
1022
1023 push({
1024 kind: 'text',
1025 sourceId: group[0].id,
1026 rect,
1027 elementRect: container?.rect ?? glyphs,
1028 lineCount,
1029 align,
1030 valign,
1031 lineHeight: measuredLineHeight(rects) ?? resolveLineHeight(anchorStyle),
1032 runs,
1033 }, group[0])
1034 }
1035
1036 function forEachDescendant(node: RawNode, fn: (node: RawNode) => void): void {
1037 for (const child of childrenOf(node.id)) {
1038 if (child.tag !== '#text')
1039 fn(child)
1040 forEachDescendant(child, fn)
1041 }
1042 }
1043
1044 return run()
1045 }
1046
1047 export function normalize(snapshot: RawSnapshot, options: NormalizeOptions): NormalizeResult {
1048 const unparsedColors = new Set<string>()
1049 const slides: SlideIr[] = []
1050 const rasterRequests: RasterRequest[] = []
1051
1052 for (const raw of snapshot.slides) {
1053 const { nodes, requests, rasterArea, textCount } = buildSlideIr(raw, snapshot.styles, snapshot.fontResolution, unparsedColors)
1054
1055 const ir: SlideIr = {
1056 no: raw.no,
1057 clickIndex: raw.clickIndex,
1058 containerId: raw.containerId,
1059 size: raw.size,
1060 // Without the slide's own background, a dark theme exports as light text
1061 // on PowerPoint's default white.
1062 background: parseColor(raw.background, unparsedColors),
1063 nodes,
1064 note: options.notes.get(raw.no),
1065 }
1066
1067 const slideArea = raw.size.w * raw.size.h
1068 const hasSourceText = raw.nodes.some(n => n.tag === '#text' && (n.text ?? '').trim())
1069
1070 // Both conditions describe a slide where vectorizing produced something
1071 // worse than the picture it replaced; degrade to what `--format pptx` does.
1072 if (hasSourceText && textCount === 0) {
1073 ir.fallbackReason = 'no text could be recovered from a slide that has text'
1074 }
1075 else if (slideArea > 0 && rasterArea / slideArea > RASTER_AREA_LIMIT) {
1076 // Capped: rasters can overlap, so the raw sum can exceed the slide.
1077 const percent = Math.min(100, Math.round((rasterArea / slideArea) * 100))
1078 ir.fallbackReason = `${percent}% of the slide had to be rasterized`
1079 }
1080
1081 if (!ir.fallbackReason)
1082 rasterRequests.push(...requests)
1083
1084 slides.push(ir)
1085 }
1086
1087 return { slides, rasterRequests, unparsedColors: [...unparsedColors].sort() }
1088 }
1089
1089 lines TYPESCRIPT