@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.
- package/dist/{build-CnBLrEp0.mjs → build-BrwsAFCa.mjs} +3 -3
- package/dist/cli.mjs +18 -8
- package/dist/{export-B_maVZXE.mjs → export-LauAJGMx.mjs} +21 -3
- package/dist/index.d.mts +5 -5
- package/dist/index.mjs +1 -1
- package/dist/pptx-D2UE590V.mjs +1907 -0
- package/dist/{serve-DzftUF4M.mjs → serve-BRH-KJ3L.mjs} +41 -30
- package/dist/{stdio-BrKVwy-M.mjs → stdio-B5Rvh_az.mjs} +1 -1
- package/package.json +25 -24
- package/skills/slidev/references/core-cli.md +1 -1
- package/skills/slidev/references/core-components.md +3 -0
- package/skills/slidev/references/core-exporting.md +4 -1
|
@@ -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 };
|