@slidev/cli 52.19.1 → 52.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1907 @@
1
+ import fs from "node:fs/promises";
2
+ import { dim, yellow } from "ansis";
3
+ import { Buffer } from "node:buffer";
4
+ //#region node/commands/pptx/ir.ts
5
+ const INCHES_PER_PX = 1 / 96;
6
+ /** Points per pixel (72 pt per inch against 96 px per inch), for font size, letter spacing and line height. */
7
+ const PT_PER_PX = .75;
8
+ //#endregion
9
+ //#region node/commands/pptx/build.ts
10
+ /**
11
+ * Extra width beyond the measured glyph bounds: PowerPoint sets the same
12
+ * string slightly wider than Chromium, so a tight single-line box re-wraps.
13
+ * Single-line boxes get the generous value and `wrap: false`; multi-line
14
+ * boxes re-wrap anyway, and widening them pushes into the next column.
15
+ */
16
+ const SLACK_SINGLE_LINE_PX = 12;
17
+ const SLACK_MULTI_LINE_PX = 2;
18
+ function hex(color) {
19
+ const channel = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0").toUpperCase();
20
+ return `${channel(color.r)}${channel(color.g)}${channel(color.b)}`;
21
+ }
22
+ /**
23
+ * CSS alpha (0 opaque..1) to PowerPoint transparency (0 opaque..100), or
24
+ * undefined when fully opaque: `pptxgenjs` tests the field for truthiness.
25
+ */
26
+ function transparency(color) {
27
+ if (color.a >= 1) return void 0;
28
+ return Math.round((1 - color.a) * 100);
29
+ }
30
+ /** Canvas pixels to inches, which is what every `pptxgenjs` coordinate wants. */
31
+ function inch(px) {
32
+ return px * INCHES_PER_PX;
33
+ }
34
+ /** Canvas pixels to points, which is what every `pptxgenjs` type size wants. */
35
+ function pt(px) {
36
+ return px * PT_PER_PX;
37
+ }
38
+ function runProps(run) {
39
+ const options = {
40
+ fontFace: run.fontFamily,
41
+ fontSize: pt(run.fontSize)
42
+ };
43
+ if (run.bold) options.bold = true;
44
+ if (run.italic) options.italic = true;
45
+ if (run.strike) options.strike = true;
46
+ if (run.underline) options.underline = { style: run.underlineStyle ?? "sng" };
47
+ else if (run.link) options.underline = { style: "none" };
48
+ if (run.color) {
49
+ options.color = hex(run.color);
50
+ const alpha = transparency(run.color);
51
+ if (alpha !== void 0) options.transparency = alpha;
52
+ }
53
+ if (run.letterSpacing) options.charSpacing = pt(run.letterSpacing);
54
+ if (run.link) options.hyperlink = { url: run.link };
55
+ if (run.endsParagraph) options.breakLine = true;
56
+ if (run.breakBefore) options.softBreakBefore = true;
57
+ return options;
58
+ }
59
+ function addText(slide, node) {
60
+ const slack = node.lineCount === 1 ? SLACK_SINGLE_LINE_PX : SLACK_MULTI_LINE_PX;
61
+ let x = node.rect.x;
62
+ if (node.align === "center") x -= slack / 2;
63
+ else if (node.align === "right") x -= slack;
64
+ slide.addText(node.runs.map((run) => ({
65
+ text: run.text,
66
+ options: runProps(run)
67
+ })), {
68
+ x: inch(x),
69
+ y: inch(node.rect.y),
70
+ w: inch(node.rect.w + slack),
71
+ h: inch(node.rect.h),
72
+ align: node.align,
73
+ margin: 0,
74
+ valign: node.valign ?? "top",
75
+ fit: "none",
76
+ isTextBox: true,
77
+ wrap: node.lineCount > 1,
78
+ ...node.lineCount > 1 ? { lineSpacing: pt(node.lineHeight) } : {}
79
+ });
80
+ }
81
+ /**
82
+ * One side of a border, as its own filled rectangle: `pptxgenjs` gives a shape
83
+ * one uniform `line`, and `addShape(LINE)` centers its stroke on the geometry,
84
+ * putting half the border outside the element box.
85
+ */
86
+ function addEdge(slide, shapeType, box, side, border) {
87
+ const { x, y, w, h } = box.rect;
88
+ const t = border.width;
89
+ const rect = side === 0 ? {
90
+ x,
91
+ y,
92
+ w,
93
+ h: t
94
+ } : side === 1 ? {
95
+ x: x + w - t,
96
+ y,
97
+ w: t,
98
+ h
99
+ } : side === 2 ? {
100
+ x,
101
+ y: y + h - t,
102
+ w,
103
+ h: t
104
+ } : {
105
+ x,
106
+ y,
107
+ w: t,
108
+ h
109
+ };
110
+ if (border.style !== "solid") {
111
+ slide.addShape(shapeType.line, {
112
+ x: inch(rect.x),
113
+ y: inch(rect.y),
114
+ w: inch(side === 0 || side === 2 ? rect.w : 0),
115
+ h: inch(side === 0 || side === 2 ? 0 : rect.h),
116
+ line: {
117
+ color: hex(border.color),
118
+ transparency: transparency(border.color),
119
+ width: pt(border.width),
120
+ dashType: border.style === "dotted" ? "sysDot" : "dash"
121
+ }
122
+ });
123
+ return;
124
+ }
125
+ slide.addShape(shapeType.rect, {
126
+ x: inch(rect.x),
127
+ y: inch(rect.y),
128
+ w: inch(rect.w),
129
+ h: inch(rect.h),
130
+ fill: {
131
+ color: hex(border.color),
132
+ transparency: transparency(border.color)
133
+ }
134
+ });
135
+ }
136
+ function sameBorder(a, b) {
137
+ if (!a || !b) return a === b;
138
+ return a.width === b.width && a.style === b.style && hex(a.color) === hex(b.color) && a.color.a === b.color.a;
139
+ }
140
+ function addBox(slide, shapeType, node) {
141
+ const borders = node.borders;
142
+ const uniform = borders && borders[0] && sameBorder(borders[0], borders[1]) && sameBorder(borders[1], borders[2]) && sameBorder(borders[2], borders[3]);
143
+ if (!node.fill && !uniform && !node.shadow) {
144
+ for (const side of [
145
+ 0,
146
+ 1,
147
+ 2,
148
+ 3
149
+ ]) {
150
+ const border = borders?.[side];
151
+ if (border && border.width > 0) addEdge(slide, shapeType, node, side, border);
152
+ }
153
+ return;
154
+ }
155
+ const options = {
156
+ x: inch(node.rect.x),
157
+ y: inch(node.rect.y),
158
+ w: inch(node.rect.w),
159
+ h: inch(node.rect.h)
160
+ };
161
+ if (node.fill) options.fill = {
162
+ color: hex(node.fill),
163
+ transparency: transparency(node.fill)
164
+ };
165
+ if (uniform && borders[0]) options.line = {
166
+ color: hex(borders[0].color),
167
+ width: pt(borders[0].width),
168
+ dashType: borders[0].style === "solid" ? "solid" : borders[0].style === "dotted" ? "sysDot" : "dash",
169
+ transparency: transparency(borders[0].color)
170
+ };
171
+ if (node.shadow) options.shadow = {
172
+ type: "outer",
173
+ blur: pt(node.shadow.blur),
174
+ offset: pt(node.shadow.offset),
175
+ angle: node.shadow.angle,
176
+ color: hex(node.shadow.color),
177
+ opacity: node.shadow.color.a
178
+ };
179
+ if (node.radius) {
180
+ const shorter = Math.min(node.rect.w, node.rect.h);
181
+ options.rectRadius = inch(Math.min(node.radius, shorter / 2));
182
+ slide.addShape(shapeType.roundRect, options);
183
+ } else slide.addShape(shapeType.rect, options);
184
+ if (borders && !uniform) for (const side of [
185
+ 0,
186
+ 1,
187
+ 2,
188
+ 3
189
+ ]) {
190
+ const border = borders[side];
191
+ if (border && border.width > 0) addEdge(slide, shapeType, node, side, border);
192
+ }
193
+ }
194
+ function addPicture(slide, node) {
195
+ const options = {
196
+ data: node.data,
197
+ x: inch(node.rect.x),
198
+ y: inch(node.rect.y),
199
+ w: inch(node.rect.w),
200
+ h: inch(node.rect.h)
201
+ };
202
+ if (node.kind === "image") {
203
+ if (node.alt) options.altText = node.alt;
204
+ if (node.link) options.hyperlink = { url: node.link };
205
+ if (node.crop) {
206
+ options.w = inch(node.crop.w);
207
+ options.h = inch(node.crop.h);
208
+ options.sizing = {
209
+ type: "crop",
210
+ x: inch(node.crop.x),
211
+ y: inch(node.crop.y),
212
+ w: inch(node.rect.w),
213
+ h: inch(node.rect.h)
214
+ };
215
+ }
216
+ } else options.altText = `Rendered as an image because of CSS ${node.reason}`;
217
+ slide.addImage(options);
218
+ }
219
+ /**
220
+ * Build the deck. The constructor is passed in so the caller keeps its dynamic
221
+ * `import('pptxgenjs')`, which keeps the library off the CLI's startup path.
222
+ */
223
+ async function buildPptx(Pptx, slides, options) {
224
+ const pptx = new Pptx();
225
+ const layoutName = `${options.width}x${options.height}`;
226
+ pptx.defineLayout({
227
+ name: layoutName,
228
+ width: inch(options.width),
229
+ height: inch(options.height)
230
+ });
231
+ pptx.layout = layoutName;
232
+ pptx.company = "Created using Slidev";
233
+ if (options.title) pptx.title = options.title;
234
+ if (options.author) pptx.author = options.author;
235
+ if (options.subject) pptx.subject = options.subject;
236
+ for (const ir of slides) {
237
+ const slide = pptx.addSlide();
238
+ if (ir.fallbackPng) slide.background = { data: ir.fallbackPng };
239
+ else {
240
+ if (ir.background) slide.background = {
241
+ color: hex(ir.background),
242
+ transparency: transparency(ir.background)
243
+ };
244
+ for (const node of ir.nodes) switch (node.kind) {
245
+ case "box":
246
+ addBox(slide, pptx.ShapeType, node);
247
+ break;
248
+ case "text":
249
+ addText(slide, node);
250
+ break;
251
+ case "image":
252
+ case "raster": addPicture(slide, node);
253
+ }
254
+ }
255
+ if (ir.note) slide.addNotes(ir.note);
256
+ }
257
+ return await pptx.write({ outputType: "nodebuffer" });
258
+ }
259
+ //#endregion
260
+ //#region node/commands/pptx/capture.ts
261
+ /**
262
+ * The only Playwright glue in the exporter. Rasterization runs as a second phase,
263
+ * after every measurement is in hand, because isolating an element for a screenshot
264
+ * means mutating the DOM of the live app. Only inline `visibility`/`background-color`
265
+ * (no reflow) and the viewport height are touched; width is what `PrintContainer`
266
+ * scales from, so the coordinates the walker measured stay valid here.
267
+ */
268
+ const ID_ATTRIBUTE = "data-slidev-export-id";
269
+ /** Marks an element this module hid, and remembers what to put back. */
270
+ const RESTORE_ATTRIBUTE = "data-slidev-export-restore";
271
+ /**
272
+ * Cache the document plus every open shadow root on `window`. `isolate` and `restore`
273
+ * both need to see inside shadow roots (Mermaid renders into one), and rescanning the
274
+ * tree per capture is a full walk per picture. Built on first use, after every slide has rendered.
275
+ */
276
+ async function installRootFinder(page) {
277
+ await page.evaluate(() => {
278
+ if (window.__slidevExportRoots) return;
279
+ let cache;
280
+ window.__slidevExportRoots = () => {
281
+ if (cache) return cache;
282
+ const found = [document];
283
+ const collect = (root) => {
284
+ for (const el of Array.from(root.querySelectorAll("*"))) {
285
+ const shadow = el.shadowRoot;
286
+ if (shadow) {
287
+ found.push(shadow);
288
+ collect(shadow);
289
+ }
290
+ }
291
+ };
292
+ collect(document);
293
+ cache = found;
294
+ return cache;
295
+ };
296
+ });
297
+ }
298
+ /**
299
+ * Hide everything except the target and its ancestors. `locator.screenshot()` clips
300
+ * the page to the element's box rather than isolating it, so overlapping content would
301
+ * be captured and then drawn again as shapes. `visibility` rather than `display`:
302
+ * `display: none` reflows and moves the target.
303
+ */
304
+ async function isolate(page, id, hideDescendants) {
305
+ return await page.evaluate(({ id, idAttribute, restoreAttribute, hideDescendants }) => {
306
+ const roots = window.__slidevExportRoots();
307
+ let target = null;
308
+ for (const root of roots) {
309
+ const hit = root.querySelector(`[${idAttribute}="${id}"]`);
310
+ if (hit) {
311
+ target = hit;
312
+ break;
313
+ }
314
+ }
315
+ if (!target) return false;
316
+ const hide = (el) => {
317
+ const html = el;
318
+ html.setAttribute(restoreAttribute, html.style.visibility || "");
319
+ html.style.visibility = "hidden";
320
+ };
321
+ const clear = (el) => {
322
+ const html = el;
323
+ html.setAttribute(`${restoreAttribute}-bg`, html.style.backgroundColor || "");
324
+ html.style.backgroundColor = "transparent";
325
+ };
326
+ if (hideDescendants) {
327
+ for (const child of Array.from(target.children)) hide(child);
328
+ const html = target;
329
+ html.setAttribute(`${restoreAttribute}-color`, html.style.color || "");
330
+ html.style.color = "transparent";
331
+ }
332
+ let node = target;
333
+ for (;;) {
334
+ const parent = node.parentElement;
335
+ if (parent) {
336
+ for (const sibling of Array.from(parent.children)) if (sibling !== node) hide(sibling);
337
+ clear(parent);
338
+ node = parent;
339
+ continue;
340
+ }
341
+ const root = node.getRootNode();
342
+ if (!root || !root.host) break;
343
+ for (const sibling of Array.from(root.children)) if (sibling !== node) hide(sibling);
344
+ node = root.host;
345
+ }
346
+ return true;
347
+ }, {
348
+ id,
349
+ idAttribute: ID_ATTRIBUTE,
350
+ restoreAttribute: RESTORE_ATTRIBUTE,
351
+ hideDescendants
352
+ });
353
+ }
354
+ /**
355
+ * Put back everything `isolate` hid. Driven off an attribute rather than a remembered
356
+ * list, so it is correct even if a previous restore was interrupted. Runs in a
357
+ * `finally`: a half-hidden deck corrupts every capture after it.
358
+ */
359
+ async function restore(page) {
360
+ await page.evaluate((restoreAttribute) => {
361
+ const roots = window.__slidevExportRoots();
362
+ const all = (selector) => roots.flatMap((root) => Array.from(root.querySelectorAll(selector)));
363
+ for (const el of all(`[${restoreAttribute}-bg]`)) {
364
+ const previous = el.getAttribute(`${restoreAttribute}-bg`) ?? "";
365
+ const style = el.style;
366
+ if (previous) style.backgroundColor = previous;
367
+ else style.removeProperty("background-color");
368
+ el.removeAttribute(`${restoreAttribute}-bg`);
369
+ }
370
+ for (const el of all(`[${restoreAttribute}-color]`)) {
371
+ const previous = el.getAttribute(`${restoreAttribute}-color`) ?? "";
372
+ const style = el.style;
373
+ if (previous) style.color = previous;
374
+ else style.removeProperty("color");
375
+ el.removeAttribute(`${restoreAttribute}-color`);
376
+ }
377
+ for (const el of all(`[${restoreAttribute}]`)) {
378
+ const previous = el.getAttribute(restoreAttribute) ?? "";
379
+ const style = el.style;
380
+ if (previous) style.visibility = previous;
381
+ else style.removeProperty("visibility");
382
+ el.removeAttribute(restoreAttribute);
383
+ }
384
+ }, RESTORE_ATTRIBUTE);
385
+ }
386
+ /**
387
+ * Screenshot an element, or return undefined; never throws, since two of the three
388
+ * call sites are fallback paths. `locator.screenshot()` is not used: on a print page
389
+ * far taller than its viewport it returns a region from elsewhere on the page
390
+ * entirely, so the element's live box is read and the page clipped at it instead.
391
+ */
392
+ async function shoot(page, selector) {
393
+ try {
394
+ const locator = page.locator(selector).first();
395
+ if (!await locator.count()) return void 0;
396
+ const box = await locator.boundingBox();
397
+ if (!box || box.width <= 0 || box.height <= 0) return void 0;
398
+ const scrollY = await page.evaluate(() => window.scrollY);
399
+ return await shootClip(page, {
400
+ x: box.x,
401
+ y: box.y + scrollY,
402
+ w: box.width,
403
+ h: box.height
404
+ });
405
+ } catch {
406
+ return;
407
+ }
408
+ }
409
+ /**
410
+ * Chromium silently truncates captures past roughly twenty thousand CSS pixels, and
411
+ * the print route sizes its viewport to the whole deck, so on a long deck every clip
412
+ * below that point fails. Clips are taken through a short, scrolled viewport instead.
413
+ */
414
+ const CLIP_VIEWPORT_MIN_HEIGHT = 2e3;
415
+ /**
416
+ * A viewport tall enough to hold the tallest thing being captured: with `canvasWidth: 3840`
417
+ * a single 16:9 slide is 2160 tall, so a fixed height would fail every whole-slide clip.
418
+ */
419
+ function clipViewportHeight(slides, requests) {
420
+ const tallest = Math.max(0, ...slides.map((slide) => slide.size.h), ...requests.map((request) => request.clip?.h ?? 0));
421
+ return Math.max(CLIP_VIEWPORT_MIN_HEIGHT, Math.ceil(tallest) + 200);
422
+ }
423
+ /**
424
+ * Screenshot a rectangle of the document. `clip` is in document coordinates while
425
+ * Playwright reads it as viewport-relative, so the region is scrolled to first and the
426
+ * clip rebased onto the scroll position actually reached. `fullPage` is not an option:
427
+ * on a long deck it asks Chromium for a bitmap large enough to kill the page.
428
+ */
429
+ async function shootClip(page, clip) {
430
+ const x = Math.max(0, clip.x);
431
+ const y = Math.max(0, clip.y);
432
+ const w = clip.w - (x - clip.x);
433
+ const h = clip.h - (y - clip.y);
434
+ if (w <= 0 || h <= 0) return void 0;
435
+ try {
436
+ const scrollY = await page.evaluate((top) => {
437
+ window.scrollTo(0, top);
438
+ return window.scrollY;
439
+ }, Math.max(0, y - 1));
440
+ return `data:image/png;base64,${(await page.screenshot({
441
+ clip: {
442
+ x,
443
+ y: y - scrollY,
444
+ width: w,
445
+ height: h
446
+ },
447
+ omitBackground: true,
448
+ timeout: 1e4
449
+ })).toString("base64")}`;
450
+ } catch {
451
+ return;
452
+ }
453
+ }
454
+ /**
455
+ * Whether a data URI can be embedded as-is. pptxgenjs needs `image/<type>;base64,` and
456
+ * emits nothing for a URL-encoded data URI; SVG is rasterized on this path regardless.
457
+ * Both fall through to a screenshot.
458
+ */
459
+ function isUsableDataUri(url) {
460
+ return /^data:image\/(?!svg\+xml)[\w.+-]+;base64,/.test(url);
461
+ }
462
+ /**
463
+ * Fetch an image through `APIRequestContext`, which is not subject to CORS;
464
+ * the in-page route, `canvas.toDataURL()`, throws on a cross-origin image.
465
+ */
466
+ async function fetchImage(page, url) {
467
+ if (url.startsWith("data:")) return isUsableDataUri(url) ? url : void 0;
468
+ try {
469
+ const response = await page.context().request.get(url, { timeout: 15e3 });
470
+ if (!response.ok()) return void 0;
471
+ const type = response.headers()["content-type"]?.split(";")[0] ?? "image/png";
472
+ if (type.includes("svg")) return void 0;
473
+ const body = await response.body();
474
+ return `data:${type};base64,${Buffer.from(body).toString("base64")}`;
475
+ } catch {
476
+ return;
477
+ }
478
+ }
479
+ /**
480
+ * Run every capture through one short, scrollable viewport. Resizing reflows the page,
481
+ * so it happens once around all of them rather than per capture; boxes are read live
482
+ * inside this viewport, so they stay consistent with it.
483
+ */
484
+ async function throughShortViewport(page, needed, height, fn) {
485
+ if (!needed) {
486
+ await fn();
487
+ return;
488
+ }
489
+ const viewport = page.viewportSize();
490
+ try {
491
+ if (viewport && viewport.height > height) await page.setViewportSize({
492
+ width: viewport.width,
493
+ height
494
+ });
495
+ await fn();
496
+ } finally {
497
+ if (viewport) await page.setViewportSize(viewport);
498
+ await page.evaluate(() => window.scrollTo(0, 0));
499
+ }
500
+ }
501
+ /** Fill in every picture the IR asked for, and shoot whole-slide fallbacks. Mutates `slides` in place. */
502
+ async function capture(page, slides, requests) {
503
+ const report = {
504
+ rastersCaptured: 0,
505
+ rastersFailed: 0,
506
+ imagesFetched: 0,
507
+ imagesDropped: 0,
508
+ isolationMissed: 0,
509
+ fallbackSlides: []
510
+ };
511
+ await installRootFinder(page);
512
+ const rasterBySource = /* @__PURE__ */ new Map();
513
+ const imagesBySource = /* @__PURE__ */ new Map();
514
+ for (const slide of slides) for (const node of slide.nodes) if (node.kind === "raster") {
515
+ const list = rasterBySource.get(node.sourceId) ?? [];
516
+ list.push(node);
517
+ rasterBySource.set(node.sourceId, list);
518
+ } else if (node.kind === "image") {
519
+ const list = imagesBySource.get(node.sourceId) ?? [];
520
+ list.push(node);
521
+ imagesBySource.set(node.sourceId, list);
522
+ }
523
+ const fulfil = async (request) => {
524
+ let data;
525
+ try {
526
+ if (request.isolate && !await isolate(page, request.isolateId ?? request.sourceId, request.hideDescendants)) report.isolationMissed++;
527
+ data = request.clip ? await shootClip(page, request.clip) : await shoot(page, `[${ID_ATTRIBUTE}="${request.sourceId}"]`);
528
+ } catch {
529
+ data = void 0;
530
+ } finally {
531
+ if (request.isolate) await restore(page);
532
+ }
533
+ for (const node of rasterBySource.get(request.sourceId) ?? []) if (data) {
534
+ node.data = data;
535
+ report.rastersCaptured++;
536
+ } else report.rastersFailed++;
537
+ };
538
+ const viewportHeight = clipViewportHeight(slides, requests);
539
+ await throughShortViewport(page, !!requests.length || !!imagesBySource.size, viewportHeight, async () => {
540
+ for (const request of requests) await fulfil(request);
541
+ for (const [sourceId, nodes] of imagesBySource) {
542
+ let data = await fetchImage(page, nodes[0].data);
543
+ if (data) report.imagesFetched++;
544
+ else try {
545
+ if (!await isolate(page, sourceId, false)) report.isolationMissed++;
546
+ data = await shoot(page, `[${ID_ATTRIBUTE}="${sourceId}"]`);
547
+ if (data) report.imagesFetched++;
548
+ } finally {
549
+ await restore(page);
550
+ }
551
+ if (!data) continue;
552
+ for (const node of nodes) node.data = data;
553
+ }
554
+ });
555
+ for (const slide of slides) slide.nodes = slide.nodes.filter((node) => {
556
+ if (node.kind === "raster") return !!node.data;
557
+ if (node.kind === "image" && !node.data.startsWith("data:")) {
558
+ report.imagesDropped++;
559
+ return false;
560
+ }
561
+ return true;
562
+ });
563
+ await throughShortViewport(page, slides.some((slide) => !!slide.fallbackReason), viewportHeight, async () => {
564
+ for (const slide of slides) {
565
+ if (!slide.fallbackReason) continue;
566
+ const shot = await shoot(page, `[id="${slide.containerId}"]`);
567
+ if (shot) {
568
+ slide.fallbackPng = shot;
569
+ report.fallbackSlides.push({
570
+ no: slide.no,
571
+ reason: slide.fallbackReason
572
+ });
573
+ } else {
574
+ report.fallbackSlides.push({
575
+ no: slide.no,
576
+ reason: `${slide.fallbackReason}, and the replacement screenshot failed, so the slide is incomplete`
577
+ });
578
+ slide.fallbackReason = void 0;
579
+ }
580
+ }
581
+ });
582
+ return report;
583
+ }
584
+ //#endregion
585
+ //#region node/commands/pptx/color.ts
586
+ function numbers(body) {
587
+ return body.split(/[\s,/]+/).filter(Boolean).map((token) => {
588
+ const n = Number.parseFloat(token);
589
+ return token.endsWith("%") ? n / 100 : n;
590
+ });
591
+ }
592
+ function srgbChannel(v) {
593
+ const c = v <= .0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - .055;
594
+ return Math.max(0, Math.min(255, Math.round(c * 255)));
595
+ }
596
+ /** Oklab to sRGB, per the CSS Color 4 conversion. */
597
+ function oklabToRgb(L, a, b) {
598
+ const l = (L + .3963377774 * a + .2158037573 * b) ** 3;
599
+ const m = (L - .1055613458 * a - .0638541728 * b) ** 3;
600
+ const s = (L - .0894841775 * a - 1.291485548 * b) ** 3;
601
+ return [
602
+ srgbChannel(4.0767416621 * l - 3.3077115913 * m + .2309699292 * s),
603
+ srgbChannel(-1.2684380046 * l + 2.6097574011 * m - .3413193965 * s),
604
+ srgbChannel(-.0041960863 * l - .7034186147 * m + 1.707614701 * s)
605
+ ];
606
+ }
607
+ function hueToRgb(h, s, l) {
608
+ const c = (1 - Math.abs(2 * l - 1)) * s;
609
+ const hp = (h % 360 + 360) % 360 / 60;
610
+ const x = c * (1 - Math.abs(hp % 2 - 1));
611
+ const [r, g, b] = hp < 1 ? [
612
+ c,
613
+ x,
614
+ 0
615
+ ] : hp < 2 ? [
616
+ x,
617
+ c,
618
+ 0
619
+ ] : hp < 3 ? [
620
+ 0,
621
+ c,
622
+ x
623
+ ] : hp < 4 ? [
624
+ 0,
625
+ x,
626
+ c
627
+ ] : hp < 5 ? [
628
+ x,
629
+ 0,
630
+ c
631
+ ] : [
632
+ c,
633
+ 0,
634
+ x
635
+ ];
636
+ const m = l - c / 2;
637
+ const to255 = (v) => Math.max(0, Math.min(255, Math.round((v + m) * 255)));
638
+ return [
639
+ to255(r),
640
+ to255(g),
641
+ to255(b)
642
+ ];
643
+ }
644
+ /**
645
+ * A computed CSS color, in any syntax Chromium actually serializes: a theme
646
+ * authored in modern syntax keeps `oklch()`, `color()` or `hsl()` in the
647
+ * computed value, and dropping those loses every fill on the slide.
648
+ */
649
+ function parseColor(value, unparsed) {
650
+ if (!value) return void 0;
651
+ const text = value.trim();
652
+ if (text === "transparent") return {
653
+ r: 0,
654
+ g: 0,
655
+ b: 0,
656
+ a: 0
657
+ };
658
+ const fn = text.match(/^([a-z-]+)\(([^)]+)\)$/i);
659
+ if (!fn) {
660
+ unparsed?.add(text);
661
+ return;
662
+ }
663
+ const name = fn[1].toLowerCase();
664
+ if (name === "color") {
665
+ const tokens = fn[2].trim().split(/[\s,/]+/).filter(Boolean);
666
+ const channels = numbers(tokens.slice(1).join(" "));
667
+ if (tokens[0] === "srgb" && channels.length >= 3 && !channels.slice(0, 3).some(Number.isNaN)) return {
668
+ r: Math.round(channels[0] * 255),
669
+ g: Math.round(channels[1] * 255),
670
+ b: Math.round(channels[2] * 255),
671
+ a: channels.length > 3 && !Number.isNaN(channels[3]) ? channels[3] : 1
672
+ };
673
+ unparsed?.add(text);
674
+ return;
675
+ }
676
+ const parts = numbers(fn[2]);
677
+ if (parts.some(Number.isNaN)) {
678
+ unparsed?.add(text);
679
+ return;
680
+ }
681
+ const alpha = (index) => parts.length > index ? parts[index] : 1;
682
+ if (name === "rgb" || name === "rgba") {
683
+ if (parts.length < 3) return void 0;
684
+ const channel = (v, raw) => raw.includes("%") ? v * 255 : v;
685
+ const raw = fn[2].split(/[\s,/]+/).filter(Boolean);
686
+ return {
687
+ r: channel(parts[0], raw[0] ?? ""),
688
+ g: channel(parts[1], raw[1] ?? ""),
689
+ b: channel(parts[2], raw[2] ?? ""),
690
+ a: alpha(3)
691
+ };
692
+ }
693
+ if (name === "hsl" || name === "hsla") {
694
+ if (parts.length < 3) return void 0;
695
+ const [r, g, b] = hueToRgb(parts[0], parts[1], parts[2]);
696
+ return {
697
+ r,
698
+ g,
699
+ b,
700
+ a: alpha(3)
701
+ };
702
+ }
703
+ if (name === "oklch" || name === "oklab") {
704
+ if (parts.length < 3) return void 0;
705
+ const L = parts[0];
706
+ const [a, b] = name === "oklch" ? [parts[1] * Math.cos(parts[2] * Math.PI / 180), parts[1] * Math.sin(parts[2] * Math.PI / 180)] : [parts[1], parts[2]];
707
+ const [r, g, bb] = oklabToRgb(L, a, b);
708
+ return {
709
+ r,
710
+ g,
711
+ b: bb,
712
+ a: alpha(3)
713
+ };
714
+ }
715
+ unparsed?.add(text);
716
+ }
717
+ /** Whether a color contributes any ink at all. */
718
+ function isVisible(color) {
719
+ return !!color && color.a > 0;
720
+ }
721
+ /**
722
+ * Fold an element's effective opacity into a color's own alpha: DrawingML has
723
+ * no element-level or group opacity, only per-fill alpha. The walker compounds
724
+ * the value down the tree, since CSS `opacity` does not inherit.
725
+ */
726
+ function withOpacity(color, opacity) {
727
+ if (!color) return void 0;
728
+ if (opacity === void 0 || !Number.isFinite(opacity) || opacity >= 1) return color;
729
+ return {
730
+ ...color,
731
+ a: color.a * Math.max(0, opacity)
732
+ };
733
+ }
734
+ //#endregion
735
+ //#region node/commands/pptx/normalize.ts
736
+ /**
737
+ * `RawSnapshot` to `SlideIr`: every judgement in the exporter, in one pure module with
738
+ * no DOM or Playwright access, so each rule is testable from a hand-written fixture.
739
+ * Where a rule looks over-specific, it is a bug that already happened; see `normalize.test.ts`.
740
+ */
741
+ /**
742
+ * Fraction of the slide that may be rasterized before the whole slide falls back to
743
+ * a picture: past this, a half-vector slide invites editing that will not work.
744
+ */
745
+ const RASTER_AREA_LIMIT = .6;
746
+ /**
747
+ * Fraction of a line's width kept free so PowerPoint does not re-wrap it: PowerPoint
748
+ * sets the same string a little wider than Chromium, so a shrink-wrapped container
749
+ * gains a wrap. The headroom scales with the line.
750
+ */
751
+ /**
752
+ * How much loose text a row of chips may hold and still be a row of chips.
753
+ *
754
+ * A separator between keys is punctuation; a sentence carrying inline `<code>`
755
+ * is words, and that stays one editable box.
756
+ */
757
+ const SEPARATOR_MAX_CHARS = 3;
758
+ const WRAP_HEADROOM = .02;
759
+ const WRAP_HEADROOM_MIN_PX = 4;
760
+ /**
761
+ * CSS `display` values whose children are laid out, not flowed as text.
762
+ *
763
+ * `table-cell` and `table-caption` are excluded: a table lays out its rows and
764
+ * a row its cells, but the CONTENTS of a cell flow like any block, so treating
765
+ * one as a layout container split `<kbd>right</kbd> / <kbd>space</kbd>` into
766
+ * three boxes and left the slash sitting on the first key.
767
+ *
768
+ * `list-item` is absent for the same reason: it flows its children as text,
769
+ * and its ::marker is emitted before they are visited.
770
+ */
771
+ const LAYOUT_DISPLAY = /^(?:flex|grid|inline-flex|inline-grid|table(?!-cell|-caption))/;
772
+ /**
773
+ * `display` values whose box participates in a line box and whose children flow as
774
+ * text. `inline-flex`, `inline-grid` and `inline-table` are excluded on purpose: they
775
+ * lay their children out as boxes, so a naive `^inline` match would concatenate their cells.
776
+ */
777
+ const INLINE_DISPLAY = /^(?:inline(?:-block)?$|contents|ruby)/;
778
+ const RASTER_TAGS = {
779
+ SVG: "svg",
780
+ CANVAS: "canvas",
781
+ VIDEO: "media",
782
+ AUDIO: "media",
783
+ IFRAME: "iframe"
784
+ };
785
+ function parseLength(value, basis) {
786
+ if (!value) return 0;
787
+ const n = Number.parseFloat(value);
788
+ if (Number.isNaN(n)) return 0;
789
+ if (value.trim().endsWith("%")) return basis === void 0 ? 0 : n / 100 * basis;
790
+ return n;
791
+ }
792
+ /**
793
+ * The part of `rect` on the slide, or undefined. Slides are `overflow: hidden`
794
+ * while PowerPoint has no clipping, so an unclamped shape stays on the canvas off
795
+ * the edge; a rect can also run to tens of millions of pixels.
796
+ */
797
+ function clipToSlide(rect, size) {
798
+ const x = Math.max(rect.x, 0);
799
+ const y = Math.max(rect.y, 0);
800
+ const right = Math.min(rect.x + rect.w, size.w);
801
+ const bottom = Math.min(rect.y + rect.h, size.h);
802
+ if (right <= x || bottom <= y) return void 0;
803
+ return {
804
+ x,
805
+ y,
806
+ w: right - x,
807
+ h: bottom - y
808
+ };
809
+ }
810
+ /** Whether two rects share any area at all. */
811
+ function overlaps(a, b) {
812
+ 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;
813
+ }
814
+ function borderOf(style, side, unparsed) {
815
+ const width = parseLength(style[`border${side}Width`]);
816
+ const lineStyle = style[`border${side}Style`];
817
+ const color = parseColor(style[`border${side}Color`], unparsed);
818
+ if (width <= 0 || !lineStyle || lineStyle === "none" || lineStyle === "hidden" || !isVisible(color)) return void 0;
819
+ return {
820
+ width,
821
+ color,
822
+ style: lineStyle === "dashed" ? "dashed" : lineStyle === "dotted" ? "dotted" : "solid"
823
+ };
824
+ }
825
+ /**
826
+ * Why this element cannot be drawn as shapes, or undefined if it can. Everything listed
827
+ * lacks a DrawingML primitive; the honest move is one picture at the exact box.
828
+ */
829
+ function rasterReasonFor(node, style) {
830
+ if (node.hasForeignObject) return "foreign-object";
831
+ if (node.isMath) return "math";
832
+ const tagReason = RASTER_TAGS[node.tag];
833
+ if (tagReason) return tagReason;
834
+ if (!style) return void 0;
835
+ if (style.webkitBackgroundClip === "text") return "background-clip-text";
836
+ if (style.backgroundImage && style.backgroundImage !== "none") return "background-image";
837
+ if (style.filter && style.filter !== "none") return "filter";
838
+ if (style.backdropFilter && style.backdropFilter !== "none") return "backdrop-filter";
839
+ if (style.mixBlendMode && style.mixBlendMode !== "normal") return "mix-blend-mode";
840
+ if (style.clipPath && style.clipPath !== "none") return "clip-path";
841
+ if (style.transform && style.transform !== "none") return "transform";
842
+ if (style.writingMode && style.writingMode !== "horizontal-tb") return "writing-mode";
843
+ }
844
+ /**
845
+ * Whether a rasterized element needs its siblings hidden during capture: a backdrop has
846
+ * content painted on top of it, which would bake into the picture and be drawn again as
847
+ * shapes. A leaf such as an `<svg>` has nothing on top.
848
+ */
849
+ function needsIsolation(reason) {
850
+ return reason === "background-image" || reason === "backdrop-filter" || reason === "filter" || reason === "mix-blend-mode";
851
+ }
852
+ function applyTransform(text, transform) {
853
+ switch (transform) {
854
+ case "uppercase": return text.toUpperCase();
855
+ case "lowercase": return text.toLowerCase();
856
+ case "capitalize": return text.replace(/\b\p{L}/gu, (c) => c.toUpperCase());
857
+ default: return text;
858
+ }
859
+ }
860
+ function resolveLineHeight(style) {
861
+ const fontSize = parseLength(style.fontSize);
862
+ if (!style.lineHeight || style.lineHeight === "normal") return fontSize * 1.2;
863
+ return parseLength(style.lineHeight) || fontSize * 1.2;
864
+ }
865
+ function alignOf(style) {
866
+ switch (style?.textAlign) {
867
+ case "center": return "center";
868
+ case "right":
869
+ case "end": return "right";
870
+ case "justify": return "justify";
871
+ default: return "left";
872
+ }
873
+ }
874
+ /**
875
+ * CSS keywords rather than typefaces; naming one in the file makes PowerPoint
876
+ * substitute its default silently, so the fallback must skip these as well.
877
+ */
878
+ const GENERIC_FAMILIES = /* @__PURE__ */ new Set([
879
+ "system-ui",
880
+ "ui-sans-serif",
881
+ "ui-serif",
882
+ "ui-monospace",
883
+ "ui-rounded",
884
+ "sans-serif",
885
+ "serif",
886
+ "monospace",
887
+ "cursive",
888
+ "fantasy",
889
+ "math",
890
+ "emoji",
891
+ "fangsong",
892
+ "inherit",
893
+ "initial",
894
+ "unset"
895
+ ]);
896
+ function fallbackFamily(stack) {
897
+ for (const raw of stack.split(",")) {
898
+ const family = raw.trim().replace(/^["']|["']$/g, "").replace(/\s+Variable$/, "");
899
+ if (!family || family.startsWith("-") || GENERIC_FAMILIES.has(family.toLowerCase())) continue;
900
+ return family;
901
+ }
902
+ return "Arial";
903
+ }
904
+ function runFrom(text, style, fontResolution, unparsed, link, opacity, rule) {
905
+ const weight = Number(style.fontWeight);
906
+ const decoration = style.textDecorationLine || "";
907
+ const family = fontResolution[style.fontFamily] || fallbackFamily(style.fontFamily);
908
+ const run = {
909
+ text: applyTransform(text, style.textTransform),
910
+ fontSize: parseLength(style.fontSize),
911
+ fontFamily: family
912
+ };
913
+ if (weight >= 600) run.bold = true;
914
+ if (style.fontStyle === "italic" || style.fontStyle === "oblique") run.italic = true;
915
+ if (decoration.includes("underline")) run.underline = true;
916
+ else if (rule) {
917
+ run.underline = true;
918
+ run.underlineStyle = rule;
919
+ }
920
+ if (decoration.includes("line-through")) run.strike = true;
921
+ const color = withOpacity(parseColor(style.color, unparsed), opacity);
922
+ if (isVisible(color)) run.color = color;
923
+ const spacing = parseLength(style.letterSpacing);
924
+ if (spacing) run.letterSpacing = spacing;
925
+ if (link) run.link = link;
926
+ return run;
927
+ }
928
+ function boundsOf(rects) {
929
+ const x = Math.min(...rects.map((r) => r.x));
930
+ const y = Math.min(...rects.map((r) => r.y));
931
+ return {
932
+ x,
933
+ y,
934
+ w: Math.max(...rects.map((r) => r.x + r.w)) - x,
935
+ h: Math.max(...rects.map((r) => r.y + r.h)) - y
936
+ };
937
+ }
938
+ /**
939
+ * Group glyph rects into line boxes by vertical overlap, not by equal top: runs of
940
+ * different sizes on the same line align on the baseline, so their tops differ, and
941
+ * sub-pixel layout defeats rounding. A shared line overlaps by more than half the shorter rect.
942
+ */
943
+ function lineGroups(rects) {
944
+ const groups = [];
945
+ for (const rect of [...rects].sort((a, b) => a.y - b.y)) {
946
+ const current = groups[groups.length - 1];
947
+ const overlap = current ? Math.min(current.bottom, rect.y + rect.h) - Math.max(current.top, rect.y) : 0;
948
+ if (current && overlap > .5 * Math.min(rect.h, current.bottom - current.top)) {
949
+ current.bottom = Math.max(current.bottom, rect.y + rect.h);
950
+ current.top = Math.min(current.top, rect.y);
951
+ } else groups.push({
952
+ top: rect.y,
953
+ bottom: rect.y + rect.h
954
+ });
955
+ }
956
+ return groups;
957
+ }
958
+ function countLines(rects) {
959
+ return lineGroups(rects).length;
960
+ }
961
+ /**
962
+ * How far apart the browser actually set the lines, or undefined for one line. The computed
963
+ * `line-height` is too small whenever a heading mixes sizes; the median keeps one
964
+ * unusually tall line from stretching the rest.
965
+ */
966
+ function measuredLineHeight(rects) {
967
+ const groups = lineGroups(rects);
968
+ if (groups.length < 2) return void 0;
969
+ const gaps = groups.slice(1).map((group, index) => group.top - groups[index].top);
970
+ gaps.sort((a, b) => a - b);
971
+ const median = gaps[Math.floor(gaps.length / 2)];
972
+ return median > 0 ? median : void 0;
973
+ }
974
+ /**
975
+ * The first non-inset `box-shadow`, as PowerPoint's polar form: an angle and a
976
+ * distance. Multiple shadows and inset shadows have no equivalent and are dropped.
977
+ */
978
+ function parseShadow(value, unparsed) {
979
+ if (!value || value === "none" || value.includes("inset")) return void 0;
980
+ const color = parseColor(value.match(/^[a-z-]+\([^)]+\)/i)?.[0], unparsed);
981
+ if (!isVisible(color)) return void 0;
982
+ const lengths = [...value.matchAll(/(-?[\d.]+)px/g)].map((m) => Number(m[1]));
983
+ if (lengths.length < 2) return void 0;
984
+ const [x, y, blur = 0] = lengths;
985
+ return {
986
+ blur,
987
+ offset: Math.round(Math.hypot(x, y) * 100) / 100,
988
+ angle: Math.round((Math.atan2(y, x) * 180 / Math.PI + 360) % 360),
989
+ color
990
+ };
991
+ }
992
+ /** One slide's `RawNode` tree to its `IrNode` list. Every helper below closes over the per-slide index and accumulators. */
993
+ function buildSlideIr(slide, styles, fontResolution, unparsed) {
994
+ const byId = /* @__PURE__ */ new Map();
995
+ const childIndex = /* @__PURE__ */ new Map();
996
+ let nodes = [];
997
+ const requests = [];
998
+ let rasterArea = 0;
999
+ const pageRects = /* @__PURE__ */ new Map();
1000
+ const boxed = /* @__PURE__ */ new Set();
1001
+ /** Paint layer per emitted node, parallel to `nodes`. */
1002
+ const layers = [];
1003
+ /** Pseudo-element id to the id of the element it belongs to. */
1004
+ const originators = /* @__PURE__ */ new Map();
1005
+ const size = slide.size;
1006
+ for (const node of slide.nodes) {
1007
+ byId.set(node.id, node);
1008
+ const siblings = childIndex.get(node.parent) ?? [];
1009
+ siblings.push(node);
1010
+ childIndex.set(node.parent, siblings);
1011
+ }
1012
+ /**
1013
+ * Where an element paints: CSS puts positioned elements above in-flow content
1014
+ * whatever the document order says. Two tiers and a z-index cover what a slide deck actually does.
1015
+ */
1016
+ function layerOf(node) {
1017
+ let current = node;
1018
+ while (current) {
1019
+ const style = styleOf(current);
1020
+ if (style && style.position !== "static" && style.position !== "") {
1021
+ const z = Number.parseInt(style.zIndex, 10);
1022
+ return {
1023
+ tier: 1,
1024
+ z: Number.isNaN(z) ? 0 : z
1025
+ };
1026
+ }
1027
+ current = byId.get(current.parent);
1028
+ }
1029
+ return {
1030
+ tier: 0,
1031
+ z: 0
1032
+ };
1033
+ }
1034
+ function push(node, source) {
1035
+ nodes.push(node);
1036
+ layers.push(layerOf(source));
1037
+ }
1038
+ function styleOf(node) {
1039
+ return node.style >= 0 ? styles[node.style] : void 0;
1040
+ }
1041
+ function childrenOf(id) {
1042
+ return childIndex.get(id) ?? [];
1043
+ }
1044
+ /** The nearest ancestor element's style, which is what styles a text node. */
1045
+ function inheritedStyle(node) {
1046
+ let current = node;
1047
+ while (current) {
1048
+ const style = styleOf(current);
1049
+ if (style) return style;
1050
+ current = byId.get(current.parent);
1051
+ }
1052
+ }
1053
+ function linkFor(node) {
1054
+ let current = node;
1055
+ while (current) {
1056
+ if (current.href) return current.href;
1057
+ current = byId.get(current.parent);
1058
+ }
1059
+ }
1060
+ /** Whether an inline element paints something of its own behind its text. */
1061
+ function hasDecoration(node) {
1062
+ const style = styleOf(node);
1063
+ if (!style) return false;
1064
+ if (isVisible(withOpacity(parseColor(style.backgroundColor, unparsed), node.opacity))) return true;
1065
+ return [
1066
+ "Top",
1067
+ "Right",
1068
+ "Bottom",
1069
+ "Left"
1070
+ ].some((side) => borderOf(style, side, unparsed));
1071
+ }
1072
+ function isInline(node) {
1073
+ if (node.tag === "#text") return true;
1074
+ const style = styleOf(node);
1075
+ return !!style && INLINE_DISPLAY.test(style.display);
1076
+ }
1077
+ function run() {
1078
+ for (const root of childrenOf(-1)) visit(root);
1079
+ const order = nodes.map((node, index) => ({
1080
+ node,
1081
+ index,
1082
+ layer: layers[index]
1083
+ }));
1084
+ order.sort((a, b) => a.layer.tier - b.layer.tier || a.layer.z - b.layer.z || a.index - b.index);
1085
+ nodes = order.map((entry) => entry.node);
1086
+ const texts = nodes.filter((n) => n.kind === "text");
1087
+ const drawnOver = nodes.filter((n) => n.kind === "text" || n.kind === "image");
1088
+ for (const node of nodes) {
1089
+ if (node.kind !== "raster") continue;
1090
+ if (drawnOver.some((other) => overlaps(other.rect, node.rect))) node.isolate = true;
1091
+ requests.push({
1092
+ sourceId: node.sourceId,
1093
+ isolate: node.isolate,
1094
+ hideDescendants: node.hideDescendants,
1095
+ clip: pageRects.get(node.sourceId),
1096
+ isolateId: originators.get(node.sourceId)
1097
+ });
1098
+ if (texts.some((text) => overlaps(text.rect, node.rect))) continue;
1099
+ const visible = clipToSlide(node.rect, size);
1100
+ if (visible) rasterArea += visible.w * visible.h;
1101
+ }
1102
+ return {
1103
+ nodes,
1104
+ requests,
1105
+ rasterArea,
1106
+ textCount: texts.length
1107
+ };
1108
+ }
1109
+ /** Whether a picture was actually emitted for this element. */
1110
+ function emitRaster(node, reason) {
1111
+ if (node.tag === "::BEFORE" || node.tag === "::AFTER") {
1112
+ pageRects.set(node.id, node.pageRect);
1113
+ originators.set(node.id, node.parent);
1114
+ }
1115
+ const isolate = needsIsolation(reason);
1116
+ const visible = clipToSlide(node.rect, size);
1117
+ if (!visible) return false;
1118
+ const overflows = visible.w !== node.rect.w || visible.h !== node.rect.h;
1119
+ if (overflows && node.pageRect) pageRects.set(node.id, {
1120
+ x: node.pageRect.x + (visible.x - node.rect.x),
1121
+ y: node.pageRect.y + (visible.y - node.rect.y),
1122
+ w: visible.w,
1123
+ h: visible.h
1124
+ });
1125
+ push({
1126
+ kind: "raster",
1127
+ sourceId: node.id,
1128
+ hideDescendants: isolate,
1129
+ rect: overflows ? visible : node.rect,
1130
+ data: "",
1131
+ reason,
1132
+ isolate
1133
+ }, node);
1134
+ return true;
1135
+ }
1136
+ function emitImage(node) {
1137
+ if (!node.src) return;
1138
+ const rect = clipToSlide(node.rect, size);
1139
+ if (!rect) return;
1140
+ const clipped = rect.w !== node.rect.w || rect.h !== node.rect.h;
1141
+ push({
1142
+ kind: "image",
1143
+ sourceId: node.id,
1144
+ rect,
1145
+ data: node.src,
1146
+ alt: node.alt,
1147
+ link: linkFor(node),
1148
+ ...clipped ? { crop: {
1149
+ x: rect.x - node.rect.x,
1150
+ y: rect.y - node.rect.y,
1151
+ w: node.rect.w,
1152
+ h: node.rect.h
1153
+ } } : {}
1154
+ }, node);
1155
+ }
1156
+ function emitBox(node, style) {
1157
+ if (boxed.has(node.id)) return;
1158
+ boxed.add(node.id);
1159
+ const fill = withOpacity(parseColor(style.backgroundColor, unparsed), node.opacity);
1160
+ const borders = [
1161
+ borderOf(style, "Top", unparsed),
1162
+ borderOf(style, "Right", unparsed),
1163
+ borderOf(style, "Bottom", unparsed),
1164
+ borderOf(style, "Left", unparsed)
1165
+ ];
1166
+ const hasBorder = borders.some(Boolean);
1167
+ const radius = parseLength(style.borderTopLeftRadius, Math.min(node.rect.w, node.rect.h));
1168
+ if (!isVisible(fill) && !hasBorder) return;
1169
+ const shadow = parseShadow(style.boxShadow, unparsed);
1170
+ const boxes = node.fragments?.length ? node.fragments : [node.rect];
1171
+ for (const source of boxes) {
1172
+ const rect = clipToSlide(source, size);
1173
+ if (!rect) continue;
1174
+ const box = {
1175
+ kind: "box",
1176
+ sourceId: node.id,
1177
+ rect
1178
+ };
1179
+ if (isVisible(fill)) box.fill = fill;
1180
+ if (hasBorder) box.borders = borders;
1181
+ if (radius > 0) box.radius = radius;
1182
+ if (shadow) box.shadow = shadow;
1183
+ push(box, node);
1184
+ }
1185
+ }
1186
+ function visit(node) {
1187
+ if (node.tag === "::BEFORE" || node.tag === "::AFTER") {
1188
+ const style = styleOf(node);
1189
+ if (!style) return;
1190
+ const reason = rasterReasonFor(node, style);
1191
+ if (reason && emitRaster(node, reason)) return;
1192
+ emitBox(node, style);
1193
+ if (node.text) push({
1194
+ kind: "text",
1195
+ sourceId: node.id,
1196
+ rect: node.rect,
1197
+ elementRect: node.rect,
1198
+ lineCount: 1,
1199
+ align: alignOf(style),
1200
+ valign: "middle",
1201
+ lineHeight: resolveLineHeight(style),
1202
+ runs: [runFrom(node.text, style, fontResolution, unparsed)]
1203
+ }, node);
1204
+ return;
1205
+ }
1206
+ if (node.tag === "#text") {
1207
+ emitTextGroup([node]);
1208
+ return;
1209
+ }
1210
+ const style = styleOf(node);
1211
+ const reason = rasterReasonFor(node, style);
1212
+ if (reason && emitRaster(node, reason)) {
1213
+ if (!needsIsolation(reason)) return;
1214
+ visitChildren(node);
1215
+ return;
1216
+ }
1217
+ if (style) emitBox(node, style);
1218
+ if (node.tag === "IMG") {
1219
+ emitImage(node);
1220
+ return;
1221
+ }
1222
+ if (node.marker && style) {
1223
+ const markerRect = {
1224
+ x: Math.max(0, node.rect.x - parseLength(style.fontSize) * 1.2),
1225
+ y: node.rect.y,
1226
+ w: parseLength(style.fontSize) * 1.2,
1227
+ h: resolveLineHeight(style)
1228
+ };
1229
+ push({
1230
+ kind: "text",
1231
+ sourceId: node.id,
1232
+ rect: markerRect,
1233
+ elementRect: markerRect,
1234
+ lineCount: 1,
1235
+ align: "left",
1236
+ valign: "middle",
1237
+ lineHeight: resolveLineHeight(style),
1238
+ runs: [runFrom(node.marker, style, fontResolution, unparsed, void 0, node.opacity)]
1239
+ }, node);
1240
+ }
1241
+ visitChildren(node);
1242
+ }
1243
+ function visitChildren(node) {
1244
+ const style = styleOf(node);
1245
+ const children = childrenOf(node.id);
1246
+ if (!children.length) return;
1247
+ if (style && LAYOUT_DISPLAY.test(style.display)) {
1248
+ for (const child of children) visit(child);
1249
+ return;
1250
+ }
1251
+ let group = [];
1252
+ const flush = () => {
1253
+ if (group.length) {
1254
+ emitTextGroup(group);
1255
+ group = [];
1256
+ }
1257
+ };
1258
+ const lineBox = (child) => child.glyphRects?.length ? boundsOf(child.glyphRects) : child.rect;
1259
+ const placed = children.filter((child) => isInline(child) && lineBox(child).h > 0);
1260
+ const oneLine = placed.length > 1 && lineGroups(placed.map(lineBox)).length === 1;
1261
+ const loose = children.filter((child) => child.tag === "#text").map((child) => (child.text ?? "").replace(/\s+/g, "")).join("");
1262
+ const labelRow = oneLine && loose.length <= SEPARATOR_MAX_CHARS && placed.some((child) => hasDecoration(child));
1263
+ for (const child of children) if (isInline(child)) {
1264
+ if (labelRow || hasDecoration(child) && children.length === 1) {
1265
+ flush();
1266
+ emitTextGroup([child], labelRow);
1267
+ continue;
1268
+ }
1269
+ group.push(child);
1270
+ } else {
1271
+ flush();
1272
+ visit(child);
1273
+ }
1274
+ flush();
1275
+ }
1276
+ /** Every text node under an inline subtree, in document order. */
1277
+ function collectText(node, out) {
1278
+ if (node.tag === "#text") {
1279
+ out.push(node);
1280
+ return;
1281
+ }
1282
+ const style = styleOf(node);
1283
+ const reason = rasterReasonFor(node, style);
1284
+ if (reason && emitRaster(node, reason)) return;
1285
+ if (node.tag === "BR") {
1286
+ out.push(node);
1287
+ return;
1288
+ }
1289
+ if (node.tag === "IMG") {
1290
+ emitImage(node);
1291
+ return;
1292
+ }
1293
+ if (style && !isInline(node)) {
1294
+ visit(node);
1295
+ return;
1296
+ }
1297
+ for (const child of childrenOf(node.id)) collectText(child, out);
1298
+ }
1299
+ /**
1300
+ * The underline an inline element draws with `border-bottom`, if that is all
1301
+ * it draws. Slidev rules its links this way.
1302
+ *
1303
+ * Only when the bottom is the only border and there is no fill: anything
1304
+ * more is a box, and a box has to stay a shape.
1305
+ */
1306
+ function underlineRule(node) {
1307
+ const style = styleOf(node);
1308
+ if (!style || !isInline(node)) return void 0;
1309
+ if (isVisible(parseColor(style.backgroundColor, unparsed))) return void 0;
1310
+ const bottom = borderOf(style, "Bottom", unparsed);
1311
+ if (!bottom) return void 0;
1312
+ for (const side of [
1313
+ "Top",
1314
+ "Right",
1315
+ "Left"
1316
+ ]) if (borderOf(style, side, unparsed)) return void 0;
1317
+ return bottom.style === "dashed" ? "dash" : bottom.style === "dotted" ? "dotted" : "sng";
1318
+ }
1319
+ /** The underline rule an ancestor of this text node draws, if any. */
1320
+ function ruleOver(node) {
1321
+ let current = byId.get(node.parent);
1322
+ while (current) {
1323
+ const rule = underlineRule(current);
1324
+ if (rule) return rule;
1325
+ current = byId.get(current.parent);
1326
+ }
1327
+ }
1328
+ function emitTextGroup(group, fragment = false) {
1329
+ for (const node of group) {
1330
+ if (node.tag === "#text") continue;
1331
+ const own = styleOf(node);
1332
+ if (underlineRule(node)) boxed.add(node.id);
1333
+ if (own) emitBox(node, own);
1334
+ forEachDescendant(node, (descendant) => {
1335
+ const style = styleOf(descendant);
1336
+ if (style) emitBox(descendant, style);
1337
+ });
1338
+ }
1339
+ const textNodes = [];
1340
+ for (const node of group) collectText(node, textNodes);
1341
+ if (!textNodes.length) return;
1342
+ const runs = [];
1343
+ const rects = [];
1344
+ /**
1345
+ * Line breaks waiting for the next run; a count, since two consecutive `<br>` need an
1346
+ * empty run between them. Held against the next run so a trailing break does not add an empty last line.
1347
+ */
1348
+ let pendingBreaks = 0;
1349
+ const addRun = (text, style, node) => {
1350
+ while (pendingBreaks > 1 && runs.length) {
1351
+ runs.push({
1352
+ ...runFrom("", style, fontResolution, unparsed, void 0, node.opacity),
1353
+ breakBefore: true
1354
+ });
1355
+ pendingBreaks--;
1356
+ }
1357
+ const newRun = runFrom(text, style, fontResolution, unparsed, linkFor(node), node.opacity, ruleOver(node));
1358
+ if (pendingBreaks && runs.length) newRun.breakBefore = true;
1359
+ pendingBreaks = 0;
1360
+ runs.push(newRun);
1361
+ };
1362
+ for (const textNode of textNodes) {
1363
+ if (textNode.tag === "BR") {
1364
+ pendingBreaks++;
1365
+ continue;
1366
+ }
1367
+ const style = inheritedStyle(textNode);
1368
+ if (!style) continue;
1369
+ const raw = textNode.text ?? "";
1370
+ if (!raw) continue;
1371
+ if (style.whiteSpace.startsWith("pre")) {
1372
+ raw.split("\n").forEach((line, index) => {
1373
+ if (index > 0) pendingBreaks++;
1374
+ if (line) addRun(line, style, textNode);
1375
+ });
1376
+ rects.push(...textNode.glyphRects ?? []);
1377
+ continue;
1378
+ }
1379
+ const text = raw.replace(/\s+/g, " ");
1380
+ if (!text.trim()) {
1381
+ if (runs.length && !runs[runs.length - 1].text.endsWith(" ")) runs[runs.length - 1].text += " ";
1382
+ continue;
1383
+ }
1384
+ addRun(text, style, textNode);
1385
+ rects.push(...textNode.glyphRects ?? [textNode.rect]);
1386
+ }
1387
+ if (!runs.length || !rects.length) return;
1388
+ if (!fragment) runs.forEach((run, index) => {
1389
+ const opensLine = index === 0 || run.breakBefore;
1390
+ const closesLine = index === runs.length - 1 || runs[index + 1]?.breakBefore;
1391
+ if (opensLine) run.text = run.text.replace(/^ +/, "");
1392
+ if (closesLine) run.text = run.text.replace(/ +$/, "");
1393
+ });
1394
+ const container = byId.get(group[0].parent);
1395
+ const containerStyle = container ? styleOf(container) : void 0;
1396
+ const anchorStyle = containerStyle ?? inheritedStyle(textNodes[0]);
1397
+ const glyphs = boundsOf(rects);
1398
+ const lineCount = countLines(rects);
1399
+ /**
1400
+ * The box PowerPoint will wrap inside. Wrapped text's glyph bounds are the width of
1401
+ * the longest line, which guarantees a second, tighter wrap; the browser wrapped
1402
+ * against the container's content box, so reproduce that. Single-line text keeps its exact glyph bounds.
1403
+ */
1404
+ let rect = glyphs;
1405
+ let align = alignOf(anchorStyle);
1406
+ /** Widen a box that has no room for PowerPoint's wider metrics. */
1407
+ const withHeadroom = (box) => {
1408
+ const headroom = box.w - glyphs.w;
1409
+ const wanted = Math.max(WRAP_HEADROOM_MIN_PX, glyphs.w * WRAP_HEADROOM);
1410
+ if (headroom >= wanted) return box;
1411
+ const extra = wanted - Math.max(0, headroom);
1412
+ return {
1413
+ x: align === "center" ? box.x - extra / 2 : align === "right" ? box.x - extra : box.x,
1414
+ y: box.y,
1415
+ w: box.w + extra,
1416
+ h: box.h
1417
+ };
1418
+ };
1419
+ let valign;
1420
+ if (group.length === 1 && group[0].tag !== "#text" && hasDecoration(group[0])) {
1421
+ rect = group[0].rect;
1422
+ align = "center";
1423
+ valign = "middle";
1424
+ } else if (lineCount > 1 && container && containerStyle) {
1425
+ const left = parseLength(containerStyle.paddingLeft);
1426
+ const right = parseLength(containerStyle.paddingRight);
1427
+ const width = container.rect.w - left - right;
1428
+ if (width > 0) rect = withHeadroom({
1429
+ x: container.rect.x + left,
1430
+ y: glyphs.y,
1431
+ w: width,
1432
+ h: glyphs.h
1433
+ });
1434
+ }
1435
+ if (!clipToSlide(rect, size)) return;
1436
+ push({
1437
+ kind: "text",
1438
+ sourceId: group[0].id,
1439
+ rect,
1440
+ elementRect: container?.rect ?? glyphs,
1441
+ lineCount,
1442
+ align,
1443
+ valign,
1444
+ lineHeight: measuredLineHeight(rects) ?? resolveLineHeight(anchorStyle),
1445
+ runs
1446
+ }, group[0]);
1447
+ }
1448
+ function forEachDescendant(node, fn) {
1449
+ for (const child of childrenOf(node.id)) {
1450
+ if (child.tag !== "#text") fn(child);
1451
+ forEachDescendant(child, fn);
1452
+ }
1453
+ }
1454
+ return run();
1455
+ }
1456
+ function normalize(snapshot, options) {
1457
+ const unparsedColors = /* @__PURE__ */ new Set();
1458
+ const slides = [];
1459
+ const rasterRequests = [];
1460
+ for (const raw of snapshot.slides) {
1461
+ const { nodes, requests, rasterArea, textCount } = buildSlideIr(raw, snapshot.styles, snapshot.fontResolution, unparsedColors);
1462
+ const ir = {
1463
+ no: raw.no,
1464
+ clickIndex: raw.clickIndex,
1465
+ containerId: raw.containerId,
1466
+ size: raw.size,
1467
+ background: parseColor(raw.background, unparsedColors),
1468
+ nodes,
1469
+ note: options.notes.get(raw.no)
1470
+ };
1471
+ const slideArea = raw.size.w * raw.size.h;
1472
+ if (raw.nodes.some((n) => n.tag === "#text" && (n.text ?? "").trim()) && textCount === 0) ir.fallbackReason = "no text could be recovered from a slide that has text";
1473
+ else if (slideArea > 0 && rasterArea / slideArea > RASTER_AREA_LIMIT) ir.fallbackReason = `${Math.min(100, Math.round(rasterArea / slideArea * 100))}% of the slide had to be rasterized`;
1474
+ if (!ir.fallbackReason) rasterRequests.push(...requests);
1475
+ slides.push(ir);
1476
+ }
1477
+ return {
1478
+ slides,
1479
+ rasterRequests,
1480
+ unparsedColors: [...unparsedColors].sort()
1481
+ };
1482
+ }
1483
+ //#endregion
1484
+ //#region node/commands/pptx/walker.ts
1485
+ /**
1486
+ * The one function that runs inside the browser. Invariant: zero free variables.
1487
+ * Playwright serializes this function's source and evaluates it where none of this
1488
+ * module's imports exist, so everything it needs is nested inside it or passed as a
1489
+ * parameter; `walker.test.ts` enforces this mechanically. The type-only import above
1490
+ * is erased at compile time and is safe.
1491
+ *
1492
+ * It extracts facts only a live layout engine can supply and makes no decisions;
1493
+ * every judgement lives in `normalize.ts`, where it can be unit-tested.
1494
+ */
1495
+ function collectSnapshot(options) {
1496
+ const STYLE_KEYS = [
1497
+ "display",
1498
+ "position",
1499
+ "zIndex",
1500
+ "visibility",
1501
+ "opacity",
1502
+ "color",
1503
+ "backgroundColor",
1504
+ "backgroundImage",
1505
+ "fontFamily",
1506
+ "fontSize",
1507
+ "fontWeight",
1508
+ "fontStyle",
1509
+ "textAlign",
1510
+ "textDecorationLine",
1511
+ "textTransform",
1512
+ "letterSpacing",
1513
+ "lineHeight",
1514
+ "whiteSpace",
1515
+ "paddingLeft",
1516
+ "paddingRight",
1517
+ "borderTopWidth",
1518
+ "borderTopStyle",
1519
+ "borderTopColor",
1520
+ "borderRightWidth",
1521
+ "borderRightStyle",
1522
+ "borderRightColor",
1523
+ "borderBottomWidth",
1524
+ "borderBottomStyle",
1525
+ "borderBottomColor",
1526
+ "borderLeftWidth",
1527
+ "borderLeftStyle",
1528
+ "borderLeftColor",
1529
+ "borderTopLeftRadius",
1530
+ "boxShadow",
1531
+ "filter",
1532
+ "backdropFilter",
1533
+ "mixBlendMode",
1534
+ "clipPath",
1535
+ "transform",
1536
+ "writingMode",
1537
+ "webkitBackgroundClip",
1538
+ "top",
1539
+ "right",
1540
+ "bottom",
1541
+ "left",
1542
+ "width",
1543
+ "height",
1544
+ "overflow"
1545
+ ];
1546
+ /** Strips the quotes CSS keeps around a `content` or font family value. */
1547
+ const RE_QUOTES = /^["']|["']$/g;
1548
+ const styles = [];
1549
+ const styleIndex = /* @__PURE__ */ new Map();
1550
+ /**
1551
+ * Intern a computed style: `getComputedStyle` exposes several hundred properties,
1552
+ * so serializing a full style per node turns a snapshot into megabytes crossing
1553
+ * the CDP boundary. A few hundred nodes collapse to a few dozen distinct styles.
1554
+ */
1555
+ function intern(computed) {
1556
+ const record = {};
1557
+ for (const key of STYLE_KEYS) record[key] = computed[key] ?? "";
1558
+ const key = JSON.stringify(record);
1559
+ const existing = styleIndex.get(key);
1560
+ if (existing !== void 0) return existing;
1561
+ const index = styles.length;
1562
+ styles.push(record);
1563
+ styleIndex.set(key, index);
1564
+ return index;
1565
+ }
1566
+ /**
1567
+ * Which family a CSS font stack actually resolves to on this machine, measured by
1568
+ * rendering a probe string and comparing widths: `document.fonts.check()` returns
1569
+ * true for families that do not exist.
1570
+ */
1571
+ const unplaceablePseudos = [];
1572
+ const fontResolution = {};
1573
+ const probeContext = document.createElement("canvas").getContext("2d");
1574
+ const PROBE = "mmmmmmmmmmlliWWWWWW0Oo";
1575
+ const BASES = [
1576
+ "monospace",
1577
+ "sans-serif",
1578
+ "serif"
1579
+ ];
1580
+ const baseWidths = {};
1581
+ for (const base of BASES) {
1582
+ probeContext.font = `72px ${base}`;
1583
+ baseWidths[base] = probeContext.measureText(PROBE).width;
1584
+ }
1585
+ function isAvailable(family) {
1586
+ for (const base of BASES) {
1587
+ probeContext.font = `72px "${family}", ${base}`;
1588
+ if (probeContext.measureText(PROBE).width !== baseWidths[base]) return true;
1589
+ }
1590
+ return false;
1591
+ }
1592
+ const GENERIC = [
1593
+ "system-ui",
1594
+ "ui-sans-serif",
1595
+ "ui-serif",
1596
+ "ui-monospace",
1597
+ "ui-rounded",
1598
+ "sans-serif",
1599
+ "serif",
1600
+ "monospace",
1601
+ "cursive",
1602
+ "fantasy",
1603
+ "math",
1604
+ "emoji",
1605
+ "fangsong",
1606
+ "inherit",
1607
+ "initial",
1608
+ "unset"
1609
+ ];
1610
+ function resolveStack(stack) {
1611
+ if (stack in fontResolution) return;
1612
+ for (const raw of stack.split(",")) {
1613
+ const family = raw.trim().replace(RE_QUOTES, "");
1614
+ if (!family) continue;
1615
+ if (GENERIC.includes(family.toLowerCase()) || family.charAt(0) === "-") continue;
1616
+ const cleaned = family.replace(/\s+Variable$/, "");
1617
+ if (isAvailable(family) || isAvailable(cleaned)) {
1618
+ fontResolution[stack] = cleaned;
1619
+ return;
1620
+ }
1621
+ }
1622
+ fontResolution[stack] = "";
1623
+ }
1624
+ /**
1625
+ * The nearest painted background color at or above the slide container; themes
1626
+ * can put `bg-main` on an ancestor rather than on the container itself.
1627
+ */
1628
+ function backgroundOf(container) {
1629
+ const probe = document.createElement("canvas").getContext("2d");
1630
+ let node = container;
1631
+ while (node) {
1632
+ const color = getComputedStyle(node).backgroundColor;
1633
+ if (color && color !== "transparent") {
1634
+ if (!probe) return color;
1635
+ probe.clearRect(0, 0, 1, 1);
1636
+ probe.fillStyle = color;
1637
+ probe.fillRect(0, 0, 1, 1);
1638
+ if (probe.getImageData(0, 0, 1, 1).data[3] !== 0) return color;
1639
+ }
1640
+ node = node.parentElement;
1641
+ }
1642
+ }
1643
+ /**
1644
+ * The text an ordinary list marker renders, which the DOM never exposes; ordered
1645
+ * lists need the ordinal counted over previous list-item siblings.
1646
+ */
1647
+ function markerGlyph(el, listStyleType) {
1648
+ const BULLETS = {
1649
+ "disc": "• ",
1650
+ "circle": "◦ ",
1651
+ "square": "▪ ",
1652
+ "disclosure-open": "▾ ",
1653
+ "disclosure-closed": "▸ "
1654
+ };
1655
+ if (listStyleType in BULLETS) return BULLETS[listStyleType];
1656
+ let ordinal = 1;
1657
+ let sibling = el.previousElementSibling;
1658
+ while (sibling) {
1659
+ if (getComputedStyle(sibling).display === "list-item") ordinal++;
1660
+ sibling = sibling.previousElementSibling;
1661
+ }
1662
+ const parent = el.parentElement;
1663
+ const start = parent && parent.tagName.toUpperCase() === "OL" ? Number(parent.getAttribute("start") || "1") : 1;
1664
+ const value = ordinal + (Number.isFinite(start) ? start : 1) - 1;
1665
+ if (listStyleType === "lower-alpha" || listStyleType === "lower-latin") return `${String.fromCharCode(96 + (value - 1) % 26 + 1)}. `;
1666
+ if (listStyleType === "upper-alpha" || listStyleType === "upper-latin") return `${String.fromCharCode(64 + (value - 1) % 26 + 1)}. `;
1667
+ return `${value}. `;
1668
+ }
1669
+ const containers = Array.from(document.querySelectorAll(options.containerSelector));
1670
+ const slides = [];
1671
+ let nextId = 0;
1672
+ for (const container of containers) {
1673
+ const containerRect = container.getBoundingClientRect();
1674
+ if (containerRect.width === 0 || containerRect.height === 0) continue;
1675
+ const parts = (container.id || "").split("-");
1676
+ const no = Number(parts[0]);
1677
+ const clickIndex = Number(parts[1]) - 1;
1678
+ if (!Number.isFinite(no)) continue;
1679
+ const nodes = [];
1680
+ function relative(rect) {
1681
+ return {
1682
+ x: rect.left - containerRect.left,
1683
+ y: rect.top - containerRect.top,
1684
+ w: rect.width,
1685
+ h: rect.height
1686
+ };
1687
+ }
1688
+ function walk(node, parent, fromShadowRoot, inheritedOpacity) {
1689
+ if (node.nodeType === 3) {
1690
+ const text = node.textContent ?? "";
1691
+ if (!text) return;
1692
+ const range = document.createRange();
1693
+ range.selectNodeContents(node);
1694
+ const rects = Array.from(range.getClientRects());
1695
+ range.detach();
1696
+ if (!rects.length) {
1697
+ if (text.trim()) return;
1698
+ nodes.push({
1699
+ id: nextId++,
1700
+ parent,
1701
+ tag: "#text",
1702
+ style: -1,
1703
+ rect: {
1704
+ x: 0,
1705
+ y: 0,
1706
+ w: 0,
1707
+ h: 0
1708
+ },
1709
+ glyphRects: [],
1710
+ text
1711
+ });
1712
+ return;
1713
+ }
1714
+ const bounds = {
1715
+ left: Math.min(...rects.map((r) => r.left)),
1716
+ top: Math.min(...rects.map((r) => r.top)),
1717
+ width: Math.max(...rects.map((r) => r.right)) - Math.min(...rects.map((r) => r.left)),
1718
+ height: Math.max(...rects.map((r) => r.bottom)) - Math.min(...rects.map((r) => r.top))
1719
+ };
1720
+ nodes.push({
1721
+ id: nextId++,
1722
+ parent,
1723
+ tag: "#text",
1724
+ style: -1,
1725
+ rect: relative(bounds),
1726
+ glyphRects: rects.map(relative),
1727
+ text,
1728
+ ...inheritedOpacity < 1 ? { opacity: inheritedOpacity } : {}
1729
+ });
1730
+ return;
1731
+ }
1732
+ if (node.nodeType !== 1) return;
1733
+ const el = node;
1734
+ const id = nextId++;
1735
+ const computed = getComputedStyle(el);
1736
+ if (computed.display === "none" || computed.visibility === "hidden") return;
1737
+ const own = Number(computed.opacity);
1738
+ const effectiveOpacity = inheritedOpacity * (Number.isFinite(own) ? own : 1);
1739
+ if (effectiveOpacity === 0) return;
1740
+ el.setAttribute(options.idAttribute, String(id));
1741
+ resolveStack(computed.fontFamily);
1742
+ const box = el.getBoundingClientRect();
1743
+ const record = {
1744
+ id,
1745
+ parent,
1746
+ tag: el.tagName.toUpperCase(),
1747
+ style: intern(computed),
1748
+ rect: relative(box),
1749
+ pageRect: {
1750
+ x: box.left + window.scrollX,
1751
+ y: box.top + window.scrollY,
1752
+ w: box.width,
1753
+ h: box.height
1754
+ }
1755
+ };
1756
+ if (effectiveOpacity < 1) record.opacity = effectiveOpacity;
1757
+ if (computed.display === "inline") {
1758
+ const fragments = Array.from(el.getClientRects());
1759
+ if (fragments.length > 1) record.fragments = fragments.map(relative);
1760
+ }
1761
+ if (fromShadowRoot) record.fromShadowRoot = true;
1762
+ if (el.classList.contains("katex")) record.isMath = true;
1763
+ if (el.tagName.toUpperCase() === "IMG") {
1764
+ record.src = el.currentSrc || el.src;
1765
+ const alt = el.alt;
1766
+ if (alt) record.alt = alt;
1767
+ }
1768
+ if (el.tagName.toUpperCase() === "A") {
1769
+ const href = el.href;
1770
+ if (href) record.href = href;
1771
+ }
1772
+ if (el.tagName.toUpperCase() === "SVG" && el.querySelector("foreignObject")) record.hasForeignObject = true;
1773
+ if (computed.display === "list-item") {
1774
+ const marker = getComputedStyle(el, "::marker");
1775
+ if (marker && marker.content && marker.content !== "none" && marker.content !== "normal") record.marker = marker.content.replace(RE_QUOTES, "");
1776
+ else if (computed.listStyleType !== "none") record.marker = markerGlyph(el, computed.listStyleType);
1777
+ }
1778
+ nodes.push(record);
1779
+ for (const which of ["::before", "::after"]) {
1780
+ const pseudo = getComputedStyle(el, which);
1781
+ if (!pseudo || !pseudo.content || pseudo.content === "none") continue;
1782
+ if (!(pseudo.content !== "normal" || pseudo.backgroundImage && pseudo.backgroundImage !== "none")) continue;
1783
+ if (pseudo.position !== "absolute" || computed.position === "static") {
1784
+ unplaceablePseudos.push(`${el.tagName.toLowerCase()}${which}`);
1785
+ continue;
1786
+ }
1787
+ const own = el.getBoundingClientRect();
1788
+ const width = Number.parseFloat(pseudo.width) || 0;
1789
+ const height = Number.parseFloat(pseudo.height) || 0;
1790
+ if (width <= 0 || height <= 0) {
1791
+ unplaceablePseudos.push(`${el.tagName.toLowerCase()}${which}`);
1792
+ continue;
1793
+ }
1794
+ const px = (value) => Number.parseFloat(value) || 0;
1795
+ const left = pseudo.left === "auto" ? own.width - px(pseudo.right) - width : px(pseudo.left);
1796
+ const top = pseudo.top === "auto" ? own.height - px(pseudo.bottom) - height : px(pseudo.top);
1797
+ const box = {
1798
+ left: own.left + left,
1799
+ top: own.top + top,
1800
+ width,
1801
+ height
1802
+ };
1803
+ const pageRect = {
1804
+ x: box.left + window.scrollX,
1805
+ y: box.top + window.scrollY,
1806
+ w: width,
1807
+ h: height
1808
+ };
1809
+ const text = pseudo.content.replace(RE_QUOTES, "");
1810
+ nodes.push({
1811
+ id: nextId++,
1812
+ parent: id,
1813
+ tag: which === "::before" ? "::BEFORE" : "::AFTER",
1814
+ style: intern(pseudo),
1815
+ rect: relative(box),
1816
+ pageRect,
1817
+ ...text && pseudo.content !== "normal" ? { text } : {}
1818
+ });
1819
+ }
1820
+ const shadow = el.shadowRoot;
1821
+ if (shadow) for (const child of Array.from(shadow.childNodes)) walk(child, id, true, effectiveOpacity);
1822
+ for (const child of Array.from(el.childNodes)) walk(child, id, fromShadowRoot, effectiveOpacity);
1823
+ }
1824
+ for (const child of Array.from(container.childNodes)) walk(child, -1, false, 1);
1825
+ slides.push({
1826
+ no,
1827
+ clickIndex: Number.isFinite(clickIndex) ? clickIndex : 0,
1828
+ containerId: container.id,
1829
+ size: {
1830
+ w: containerRect.width,
1831
+ h: containerRect.height
1832
+ },
1833
+ background: backgroundOf(container),
1834
+ nodes
1835
+ });
1836
+ }
1837
+ return {
1838
+ slides,
1839
+ styles,
1840
+ fontResolution,
1841
+ unplaceablePseudos
1842
+ };
1843
+ }
1844
+ //#endregion
1845
+ //#region node/commands/pptx/index.ts
1846
+ /**
1847
+ * Export the deck as PowerPoint with native shapes and editable text. One
1848
+ * navigation covers everything: the print route renders every slide and click
1849
+ * step into a single tall page, so there is no per-slide loop here and
1850
+ * `--with-clicks` needs no special handling.
1851
+ */
1852
+ async function exportPptxEditable(ctx, output) {
1853
+ await ctx.go("print");
1854
+ await ctx.page.addStyleTag({ content: "*, *::before, *::after { animation-play-state: paused !important; transition: none !important; }" });
1855
+ const snapshot = await ctx.page.evaluate(collectSnapshot, {
1856
+ containerSelector: ".print-slide-container",
1857
+ idAttribute: ID_ATTRIBUTE
1858
+ });
1859
+ snapshot.slides = snapshot.slides.filter((slide) => ctx.pages.includes(slide.no));
1860
+ const notes = /* @__PURE__ */ new Map();
1861
+ ctx.slides.forEach((slide, index) => notes.set(index + 1, slide.note));
1862
+ const { slides, rasterRequests, unparsedColors } = normalize(snapshot, { notes });
1863
+ const report = await capture(ctx.page, slides, rasterRequests);
1864
+ const title = ctx.slides[0];
1865
+ const pptxgenjs = await import("pptxgenjs");
1866
+ const buffer = await buildPptx(pptxgenjs.default ?? pptxgenjs, slides, {
1867
+ width: ctx.width,
1868
+ height: ctx.height,
1869
+ title: title?.title,
1870
+ author: title?.frontmatter?.author,
1871
+ subject: title?.frontmatter?.info
1872
+ });
1873
+ const written = output.endsWith(".pptx") ? output : `${output}.pptx`;
1874
+ await fs.writeFile(written, buffer);
1875
+ return {
1876
+ output: written,
1877
+ slideCount: slides.length,
1878
+ fallbackSlides: report.fallbackSlides,
1879
+ imagesDropped: report.imagesDropped,
1880
+ isolationMissed: report.isolationMissed,
1881
+ rastersFailed: report.rastersFailed,
1882
+ unparsedColors,
1883
+ unplaceablePseudos: [...new Set(snapshot.unplaceablePseudos ?? [])].sort(),
1884
+ fontsNamed: [...new Set(Object.values(snapshot.fontResolution).filter(Boolean))].sort()
1885
+ };
1886
+ }
1887
+ /** Print what the export could not do, after the progress bar has stopped. */
1888
+ function reportEditableExport(result) {
1889
+ for (const slide of result.fallbackSlides) console.warn(yellow(` slide ${slide.no}: exported as an image (${slide.reason})`));
1890
+ if (result.imagesDropped) console.warn(yellow(` ${result.imagesDropped} image(s) could not be read and were left out`));
1891
+ if (result.rastersFailed) console.warn(yellow(` ${result.rastersFailed} element(s) could not be captured as a picture and were left out`));
1892
+ if (result.unparsedColors.length) {
1893
+ console.warn(yellow(` ${result.unparsedColors.length} color value(s) could not be read, so those fills are missing:`));
1894
+ console.warn(dim(` ${result.unparsedColors.slice(0, 5).join(", ")}`));
1895
+ }
1896
+ if (result.isolationMissed) console.warn(yellow(` ${result.isolationMissed} picture(s) could not be isolated, so their slide may show doubled text`));
1897
+ if (result.unplaceablePseudos.length) {
1898
+ console.warn(yellow(` ${result.unplaceablePseudos.length} CSS decoration(s) could not be placed and were left out:`));
1899
+ console.warn(dim(` ${result.unplaceablePseudos.slice(0, 5).join(", ")}`));
1900
+ }
1901
+ if (result.fontsNamed.length) {
1902
+ console.warn(dim(` fonts named in this file: ${result.fontsNamed.join(", ")}`));
1903
+ console.warn(dim(" a .pptx names fonts rather than embedding them, so recipients need these installed"));
1904
+ }
1905
+ }
1906
+ //#endregion
1907
+ export { exportPptxEditable, reportEditableExport };