@hajimism/dek 0.3.3 → 0.4.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.ja.md +1 -1
  3. package/README.md +1 -1
  4. package/package.json +2 -1
  5. package/skills/dek/SKILL.md +10 -3
  6. package/skills/dek-design/SKILL.md +42 -0
  7. package/src/cli/address.ts +84 -14
  8. package/src/cli/build.ts +2 -2
  9. package/src/cli/check.ts +2 -1
  10. package/src/cli/commands.ts +11 -6
  11. package/src/cli/contract.ts +9 -1
  12. package/src/cli/files.ts +1 -5
  13. package/src/cli/flags.ts +1 -1
  14. package/src/cli/goto.ts +3 -1
  15. package/src/cli/init.ts +10 -5
  16. package/src/cli/lint.ts +4 -3
  17. package/src/cli/main.ts +7 -5
  18. package/src/cli/new.ts +14 -7
  19. package/src/cli/pptx.ts +9 -4
  20. package/src/cli/ref.ts +91 -9
  21. package/src/cli/result.ts +5 -2
  22. package/src/cli/scope.ts +17 -28
  23. package/src/cli/shot.ts +2 -1
  24. package/src/cli/text.ts +27 -2
  25. package/src/cli/theme.ts +16 -2
  26. package/src/cli/voice.ts +4 -3
  27. package/src/core/alignment.ts +320 -0
  28. package/src/core/annotation-rows.ts +16 -4
  29. package/src/core/annotations.ts +19 -1
  30. package/src/core/config.ts +61 -12
  31. package/src/core/diagnostic.ts +5 -1
  32. package/src/core/escape.ts +43 -0
  33. package/src/core/line-break.ts +275 -0
  34. package/src/core/lint/asset-refs.ts +6 -2
  35. package/src/core/lint/files.ts +30 -5
  36. package/src/core/lint/keys.ts +7 -3
  37. package/src/core/lint/slide-html.ts +3 -1
  38. package/src/core/lint/slide-script.ts +7 -5
  39. package/src/core/mv.ts +30 -10
  40. package/src/core/pack.ts +2 -2
  41. package/src/core/playwright-module.d.ts +1 -0
  42. package/src/core/playwright-visual.ts +180 -20
  43. package/src/core/playwright.ts +202 -25
  44. package/src/core/pptx-package.ts +461 -33
  45. package/src/core/pptx-paint.ts +1168 -0
  46. package/src/core/pptx-plan.ts +879 -0
  47. package/src/core/pptx-text.ts +204 -47
  48. package/src/core/pptx-theme.ts +222 -0
  49. package/src/core/pptx.ts +55 -8
  50. package/src/core/prose.ts +23 -0
  51. package/src/core/pseudo-text.ts +32 -10
  52. package/src/core/ref-name.ts +47 -8
  53. package/src/core/ref.ts +172 -16
  54. package/src/core/resolve.ts +20 -9
  55. package/src/core/shot/morph.ts +1 -1
  56. package/src/core/shot/motion.ts +2 -3
  57. package/src/core/shot/still.ts +2 -2
  58. package/src/core/shot-cache.ts +25 -17
  59. package/src/core/slide-measure.ts +51 -1
  60. package/src/core/slide-script-eval.ts +10 -8
  61. package/src/core/slide-script.ts +114 -20
  62. package/src/core/sync.ts +2 -2
  63. package/src/core/visual-cache.ts +2 -0
  64. package/src/core/visual.ts +154 -15
  65. package/src/core/voice.ts +3 -5
  66. package/src/core/zod.ts +9 -1
  67. package/src/server/dev.ts +15 -4
  68. package/src/server/lock.ts +48 -5
  69. package/src/server/routes.ts +7 -2
  70. package/src/theme/default.css +4 -0
  71. package/src/video/bake.ts +6 -2
@@ -0,0 +1,1168 @@
1
+ /// <reference lib="dom" />
2
+ // What a PPTX reads of a slide's paint: each element, where the browser paints
3
+ // it and what it draws of its own, what its pseudo-elements and list marker draw, and each
4
+ // picture as itself; then, once the plan is made, each picture drawn alone and the lifted paint
5
+ // taken out of the slide's picture. Each function here runs in the page, shipped by
6
+ // `page.evaluate`, so it references nothing outside itself.
7
+
8
+ import type { PaintKey, PptxNodePaint, PptxPaintNode } from "./pptx-plan.ts";
9
+ import type { Box } from "./slide-measure.ts";
10
+
11
+ /** The attribute that marks an element whose pseudo-element or marker draws, by its node. */
12
+ export const PPTX_PSEUDO_ATTR = "data-dek-pptx-pseudo";
13
+
14
+ /** A pseudo-element or a list marker that draws, before Chromium says where it lies. */
15
+ type PptxPseudoPaint = {
16
+ /** The element it belongs to, -1 for the slide. */
17
+ host: number;
18
+ pseudo: "before" | "after" | "marker";
19
+ key: PaintKey;
20
+ /** How far its shadows and outline reach past its box. */
21
+ reach: number;
22
+ /** For a marker PowerPoint has a bullet for, which. */
23
+ bullet?: { char: string } | { ordinal: number };
24
+ };
25
+
26
+ export type PptxPagePaint = {
27
+ /** Where the slide is in the viewport, which the boxes Chromium reports are in. */
28
+ origin: { left: number; top: number };
29
+ nodes: PptxPaintNode[];
30
+ pseudos: PptxPseudoPaint[];
31
+ described: Array<{ node: number; text: string }>;
32
+ /**
33
+ * The slide's own background as computed, and whether that is all the slide paints of its own
34
+ * box, over the whole viewport, as PowerPoint paints a slide's background; and whether it is a
35
+ * stacking context.
36
+ */
37
+ slide?: {
38
+ paint: { color: [number, number, number]; alpha: number };
39
+ image: string;
40
+ plain: boolean;
41
+ stacking: boolean;
42
+ width: number;
43
+ height: number;
44
+ };
45
+ };
46
+
47
+ /**
48
+ * Numbers every element of the slide, as `data-dek-pptx-node`, its index among the nodes, and
49
+ * reads what each draws of its own: a box PowerPoint can
50
+ * draw as a shape, a picture to draw alone, or paint that stays drawn. Marks each element whose
51
+ * pseudo-elements or marker draw, for Chromium to say where they lie.
52
+ */
53
+ export function measurePptxPaintInPage(): Omit<PptxPagePaint, "described"> {
54
+ const slide = document.querySelector("#deck > .slide");
55
+ if (!(slide instanceof HTMLElement)) {
56
+ return { origin: { left: 0, top: 0 }, nodes: [], pseudos: [] };
57
+ }
58
+ const origin = slide.getBoundingClientRect();
59
+ const local = (rect: DOMRect | Box): Box => ({
60
+ left: rect.left - origin.left,
61
+ top: rect.top - origin.top,
62
+ right: rect.right - origin.left,
63
+ bottom: rect.bottom - origin.top,
64
+ });
65
+
66
+ const canvas = document.createElement("canvas");
67
+ canvas.width = 1;
68
+ canvas.height = 1;
69
+ const paint = canvas.getContext("2d", { willReadFrequently: true });
70
+ /**
71
+ * A computed color as sRGB: read as it is written when it is `rgb()`, as Chromium writes an
72
+ * sRGB color, so a faint one keeps its channels; drawn when it is in another space.
73
+ */
74
+ const rgba = (value: string): { color: [number, number, number]; alpha: number } => {
75
+ const written = /^rgba?\(\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\s*\)$/.exec(
76
+ value,
77
+ );
78
+ if (written) {
79
+ const [, r = "0", g = "0", b = "0", a = "1"] = written;
80
+ const channel = (part: string) => Math.max(0, Math.min(255, Math.round(Number(part))));
81
+ return {
82
+ color: [channel(r), channel(g), channel(b)],
83
+ alpha: Math.max(0, Math.min(1, Number(a))),
84
+ };
85
+ }
86
+ if (!paint) {
87
+ return { color: [0, 0, 0], alpha: 0 };
88
+ }
89
+ paint.clearRect(0, 0, 1, 1);
90
+ // A value the canvas cannot read leaves its fill as it was: nothing, not the last color.
91
+ paint.fillStyle = "rgba(0, 0, 0, 0)";
92
+ paint.fillStyle = value;
93
+ paint.fillRect(0, 0, 1, 1);
94
+ const [r = 0, g = 0, b = 0, a = 0] = paint.getImageData(0, 0, 1, 1).data;
95
+ return { color: [r, g, b], alpha: a / 255 };
96
+ };
97
+ const px = (value: string): number => {
98
+ const parsed = Number.parseFloat(value);
99
+ return Number.isFinite(parsed) ? parsed : 0;
100
+ };
101
+ /** How far a shadow list reaches past the box: every length in it, added up, at most. */
102
+ const reachOf = (value: string): number =>
103
+ value === "none"
104
+ ? 0
105
+ : (value.match(/-?\d*\.?\d+px/g) ?? []).reduce(
106
+ (sum, length) => sum + Math.abs(px(length)),
107
+ 0,
108
+ );
109
+
110
+ const nodes: PptxPaintNode[] = [];
111
+ const pseudos: PptxPseudoPaint[] = [];
112
+ /** The index past the last element each node holds, for what its `::after` paints over. */
113
+ const ends: number[] = [];
114
+
115
+ /** Whether one of the transform properties says anything; `transform` itself is read apart. */
116
+ const set = (value: string): boolean => value !== "none" && value !== "";
117
+
118
+ /** The element's `z-index`, when it is one that counts: positioned, or a flex or grid item. */
119
+ const zOf = (style: CSSStyleDeclaration, parent: Element | null): number | undefined => {
120
+ const display = parent ? getComputedStyle(parent).display : "";
121
+ const z = style.zIndex === "auto" ? undefined : Number(style.zIndex);
122
+ return z !== undefined &&
123
+ Number.isFinite(z) &&
124
+ (style.position !== "static" || /flex|grid/.test(display))
125
+ ? z
126
+ : undefined;
127
+ };
128
+
129
+ /** Whether the element is a stacking context of its own by what it paints, not by `z-index`. */
130
+ const isolates = (style: CSSStyleDeclaration): boolean =>
131
+ Number(style.opacity) < 1 ||
132
+ style.transform !== "none" ||
133
+ set(style.rotate) ||
134
+ set(style.scale) ||
135
+ set(style.translate) ||
136
+ style.filter !== "none" ||
137
+ style.isolation === "isolate" ||
138
+ style.mixBlendMode !== "normal" ||
139
+ style.clipPath !== "none" ||
140
+ (style.maskImage !== "none" && style.maskImage !== "");
141
+
142
+ const layerOf = (style: CSSStyleDeclaration, parent: Element | null): number => {
143
+ const z = zOf(style, parent);
144
+ if (z !== undefined) {
145
+ return z < 0 ? z : z + 1;
146
+ }
147
+ return style.position !== "static" || isolates(style) ? 1 : 0;
148
+ };
149
+
150
+ /**
151
+ * What scales the element, its own `transform` and `scale` and what it is in: none when one
152
+ * of them rotates, skews, or scales it unevenly, or `zoom` sets it at another size than its
153
+ * computed lengths say, none of which a shape follows.
154
+ */
155
+ const scaleOf = (style: CSSStyleDeclaration, outer: number | undefined): number | undefined => {
156
+ if (
157
+ outer === undefined ||
158
+ set(style.rotate) ||
159
+ (style.zoom !== "" && Number(style.zoom) !== 1)
160
+ ) {
161
+ return undefined;
162
+ }
163
+ let scale = outer;
164
+ if (set(style.scale)) {
165
+ const [x = Number.NaN, y = x, z = 1] = style.scale.split(/\s+/).map(Number);
166
+ if (!(x > 0) || Math.abs(x - y) > 1e-6 || z !== 1) {
167
+ return undefined;
168
+ }
169
+ scale *= x;
170
+ }
171
+ if (style.transform === "none" || style.transform === "") {
172
+ return scale;
173
+ }
174
+ const matrix = new DOMMatrixReadOnly(style.transform);
175
+ if (!matrix.is2D || Math.abs(matrix.b) > 1e-6 || Math.abs(matrix.c) > 1e-6) {
176
+ return undefined;
177
+ }
178
+ if (Math.abs(matrix.a - matrix.d) > 1e-6 || matrix.a <= 0) {
179
+ return undefined;
180
+ }
181
+ return scale * matrix.a;
182
+ };
183
+
184
+ /**
185
+ * How the element paints among the blocks of its stacking context (CSS 2, Appendix E): a float
186
+ * after their backgrounds, and inline-level content, an inline block, a flex or grid item, or
187
+ * replaced content such as an image, block-level or not, after the floats too; a block, unset,
188
+ * in its place among them.
189
+ */
190
+ const flowOf = (
191
+ style: CSSStyleDeclaration,
192
+ parent: Element,
193
+ replaced: boolean,
194
+ ): PptxPaintNode["flow"] => {
195
+ if (style.float !== "none") {
196
+ return "float";
197
+ }
198
+ if (
199
+ replaced ||
200
+ style.display.startsWith("inline") ||
201
+ /flex|grid/.test(getComputedStyle(parent).display)
202
+ ) {
203
+ return "inline";
204
+ }
205
+ return undefined;
206
+ };
207
+
208
+ /** Whether the box reaches past the viewport, which a shot of it would cut. */
209
+ const offscreen = (rect: DOMRect): boolean =>
210
+ rect.left < -0.5 ||
211
+ rect.top < -0.5 ||
212
+ rect.right > window.innerWidth + 0.5 ||
213
+ rect.bottom > window.innerHeight + 0.5;
214
+
215
+ const effects = (style: CSSStyleDeclaration): boolean =>
216
+ style.filter !== "none" ||
217
+ (style.backdropFilter !== "none" && style.backdropFilter !== "") ||
218
+ style.mixBlendMode !== "normal" ||
219
+ style.clipPath !== "none" ||
220
+ (style.maskImage !== "none" && style.maskImage !== "");
221
+
222
+ /** The radius of a corner, horizontal and vertical, in pixels of the box. */
223
+ const corner = (value: string, width: number, height: number): [number, number] => {
224
+ const [h = "0", v = h] = value.split(" ");
225
+ const length = (part: string, of: number) =>
226
+ part.endsWith("%") ? (px(part) / 100) * of : px(part);
227
+ return [length(h, width), length(v, height)];
228
+ };
229
+
230
+ /** Whether an ancestor's clipping, square or round, cuts into the box. */
231
+ const clipped = (el: Element, rect: DOMRect): boolean => {
232
+ for (let node = el.parentElement; node; node = node.parentElement) {
233
+ const style = getComputedStyle(node);
234
+ if (style.overflowX !== "visible" || style.overflowY !== "visible") {
235
+ const outer = node.getBoundingClientRect();
236
+ const clip = {
237
+ left: outer.left + px(style.borderLeftWidth),
238
+ top: outer.top + px(style.borderTopWidth),
239
+ right: outer.right - px(style.borderRightWidth),
240
+ bottom: outer.bottom - px(style.borderBottomWidth),
241
+ };
242
+ if (
243
+ rect.left < clip.left - 0.5 ||
244
+ rect.top < clip.top - 0.5 ||
245
+ rect.right > clip.right + 0.5 ||
246
+ rect.bottom > clip.bottom + 0.5
247
+ ) {
248
+ return true;
249
+ }
250
+ const [radius] = corner(style.borderTopLeftRadius, outer.width, outer.height);
251
+ const round = Math.max(
252
+ radius,
253
+ corner(style.borderTopRightRadius, outer.width, outer.height)[0],
254
+ corner(style.borderBottomRightRadius, outer.width, outer.height)[0],
255
+ corner(style.borderBottomLeftRadius, outer.width, outer.height)[0],
256
+ );
257
+ const nearX = rect.left < clip.left + round || rect.right > clip.right - round;
258
+ const nearY = rect.top < clip.top + round || rect.bottom > clip.bottom - round;
259
+ if (round > 0 && nearX && nearY) {
260
+ return true;
261
+ }
262
+ }
263
+ if (node === slide) {
264
+ break;
265
+ }
266
+ }
267
+ return false;
268
+ };
269
+
270
+ const SIDES = ["Top", "Right", "Bottom", "Left"] as const;
271
+
272
+ /** What the element draws of its own box: a shape, paint that stays drawn, or nothing. */
273
+ const boxPaint = (
274
+ el: Element,
275
+ style: CSSStyleDeclaration,
276
+ rect: DOMRect,
277
+ scale: number | undefined,
278
+ opacity: number,
279
+ effect: boolean,
280
+ ): PptxNodePaint | undefined => {
281
+ const background = rgba(style.backgroundColor);
282
+ const image = style.backgroundImage !== "none";
283
+ const sides = SIDES.map((side) => ({
284
+ width: px(style.getPropertyValue(`border-${side.toLowerCase()}-width`)),
285
+ style: style.getPropertyValue(`border-${side.toLowerCase()}-style`),
286
+ color: rgba(style.getPropertyValue(`border-${side.toLowerCase()}-color`)),
287
+ })).map((side) => ({
288
+ ...side,
289
+ drawn:
290
+ side.width > 0 && side.style !== "none" && side.style !== "hidden" && side.color.alpha > 0,
291
+ }));
292
+ const shadow = reachOf(style.boxShadow);
293
+ const outline =
294
+ style.outlineStyle !== "none" && px(style.outlineWidth) > 0
295
+ ? px(style.outlineWidth) + Math.max(0, px(style.outlineOffset))
296
+ : 0;
297
+ // A border image paints instead of the borders, and a column rule between the columns.
298
+ const unmeasured =
299
+ style.borderImageSource !== "none" ||
300
+ (style.columnRuleStyle !== "none" && px(style.columnRuleWidth) > 0);
301
+ const paints =
302
+ background.alpha > 0 ||
303
+ image ||
304
+ unmeasured ||
305
+ sides.some((side) => side.drawn) ||
306
+ shadow > 0 ||
307
+ outline > 0;
308
+ if (!paints || style.visibility !== "visible" || opacity === 0) {
309
+ return undefined;
310
+ }
311
+ const reach = Math.max(shadow, outline);
312
+ const drawn: PptxNodePaint = {
313
+ kind: "drawn",
314
+ extent: local({
315
+ left: rect.left - reach,
316
+ top: rect.top - reach,
317
+ right: rect.right + reach,
318
+ bottom: rect.bottom + reach,
319
+ }),
320
+ };
321
+ const collapsed =
322
+ /^table/.test(style.display) &&
323
+ sides.some((side) => side.drawn) &&
324
+ getComputedStyle(el.closest("table") ?? el).borderCollapse === "collapse";
325
+ if (
326
+ image ||
327
+ unmeasured ||
328
+ reach > 0 ||
329
+ effect ||
330
+ scale === undefined ||
331
+ collapsed ||
332
+ el.getClientRects().length !== 1 ||
333
+ sides.some((side) => side.drawn && side.style !== "solid") ||
334
+ !/^(border|padding)-box$/.test(style.backgroundClip) ||
335
+ clipped(el, rect) ||
336
+ offscreen(rect)
337
+ ) {
338
+ return drawn;
339
+ }
340
+ const corners = [
341
+ style.borderTopLeftRadius,
342
+ style.borderTopRightRadius,
343
+ style.borderBottomRightRadius,
344
+ style.borderBottomLeftRadius,
345
+ ].map((value) => corner(value, rect.width / scale, rect.height / scale));
346
+ const [[radius = 0, vertical = 0] = []] = corners;
347
+ if (corners.some(([h, v]) => Math.abs(h - radius) > 0.01 || Math.abs(v - vertical) > 0.01)) {
348
+ return drawn;
349
+ }
350
+ // CSS shrinks corners that do not fit by one factor for both radii, so an elliptical corner,
351
+ // such as 50% of a box that is not square, stays elliptical; PowerPoint's are circular.
352
+ if (Math.abs(radius - vertical) > 0.01) {
353
+ return drawn;
354
+ }
355
+ const painted = sides.filter((side) => side.drawn);
356
+ // A corner rounder than its border is wide has a path on the midline to round; one less round
357
+ // would be a square path, its outer corner square where CSS rounds it.
358
+ if (radius > 0 && painted.some((side) => radius < side.width / 2)) {
359
+ return drawn;
360
+ }
361
+ const same =
362
+ painted.length === 4 &&
363
+ painted.every(
364
+ (side) =>
365
+ Math.abs(side.width - (painted[0]?.width ?? 0)) < 0.01 &&
366
+ side.color.color.join() === painted[0]?.color.color.join() &&
367
+ side.color.alpha === painted[0]?.color.alpha,
368
+ );
369
+ if (painted.length > 0 && !same && radius > 0) {
370
+ return drawn;
371
+ }
372
+ const paintOf = (color: { color: [number, number, number]; alpha: number }) => ({
373
+ color: color.color,
374
+ alpha: Math.round(color.alpha * opacity * 1000) / 1000,
375
+ });
376
+ const inset = SIDES.map(
377
+ (side, index) =>
378
+ ((sides[index]?.drawn ? (sides[index]?.width ?? 0) : 0) +
379
+ px(style.getPropertyValue(`padding-${side.toLowerCase()}`))) *
380
+ scale,
381
+ );
382
+ const [top = 0, right = 0, bottom = 0, left = 0] = inset;
383
+ return {
384
+ kind: "shape",
385
+ content: local({
386
+ left: rect.left + left,
387
+ top: rect.top + top,
388
+ right: rect.right - right,
389
+ bottom: rect.bottom - bottom,
390
+ }),
391
+ ...(background.alpha > 0 ? { fill: paintOf(background) } : {}),
392
+ ...(same && painted[0]
393
+ ? { outline: { width: painted[0].width * scale, paint: paintOf(painted[0].color) } }
394
+ : {}),
395
+ ...(!same && painted.length > 0
396
+ ? {
397
+ sides: Object.fromEntries(
398
+ SIDES.flatMap((side, index) => {
399
+ const drawnSide = sides[index];
400
+ return drawnSide?.drawn
401
+ ? [
402
+ [
403
+ side.toLowerCase(),
404
+ { width: drawnSide.width * scale, paint: paintOf(drawnSide.color) },
405
+ ],
406
+ ]
407
+ : [];
408
+ }),
409
+ ),
410
+ }
411
+ : {}),
412
+ radius: Math.min(radius * scale, Math.max(rect.width, rect.height)),
413
+ };
414
+ };
415
+
416
+ const PICTURES = new Set(["img", "canvas", "svg"]);
417
+ const REPLACED = new Set([
418
+ "video",
419
+ "iframe",
420
+ "object",
421
+ "embed",
422
+ "input",
423
+ "textarea",
424
+ "select",
425
+ "meter",
426
+ "progress",
427
+ ]);
428
+
429
+ const describe = (el: Element): string => {
430
+ const tag = el.tagName.toLowerCase();
431
+ const title = tag === "svg" ? el.querySelector(":scope > title")?.textContent : undefined;
432
+ return (el.getAttribute("alt") ?? el.getAttribute("aria-label") ?? title ?? "").trim();
433
+ };
434
+
435
+ const BULLETS: Record<string, string> = { disc: "•", circle: "◦", square: "▪" };
436
+
437
+ /** The bullet PowerPoint draws for an item's marker, if it has one. */
438
+ const bulletOf = (el: Element, style: CSSStyleDeclaration): PptxPseudoPaint["bullet"] => {
439
+ const marker = getComputedStyle(el, "::marker");
440
+ if (style.listStyleImage !== "none" || (marker.content !== "normal" && marker.content !== "")) {
441
+ return undefined;
442
+ }
443
+ const char = BULLETS[style.listStyleType];
444
+ if (char) {
445
+ return { char };
446
+ }
447
+ if (style.listStyleType !== "decimal") {
448
+ return undefined;
449
+ }
450
+ const list = el.parentElement;
451
+ if (!list || list.hasAttribute("reversed")) {
452
+ return undefined;
453
+ }
454
+ const items = [...list.children].filter((item) =>
455
+ getComputedStyle(item).display.includes("list-item"),
456
+ );
457
+ if (items.some((item) => item.hasAttribute("value"))) {
458
+ return undefined;
459
+ }
460
+ const start = list.tagName.toLowerCase() === "ol" ? Number(list.getAttribute("start") ?? 1) : 1;
461
+ // PowerPoint counts from 1 to 32767; a list numbered outside that keeps its marker drawn.
462
+ return Number.isInteger(start) && start >= 1 && start + items.length - 1 <= 32767
463
+ ? { ordinal: start + items.indexOf(el) }
464
+ : undefined;
465
+ };
466
+
467
+ /** The pseudo-elements of `el` that draw, and its marker, with where each paints. */
468
+ const pseudoPaints = (el: Element, host: number, key: PaintKey, index: number) => {
469
+ let drawn = false;
470
+ for (const pseudo of ["before", "after"] as const) {
471
+ const style = getComputedStyle(el, `::${pseudo}`);
472
+ if (style.content === "none" || style.content === "normal" || style.display === "none") {
473
+ continue;
474
+ }
475
+ const box =
476
+ rgba(style.backgroundColor).alpha > 0 ||
477
+ style.backgroundImage !== "none" ||
478
+ reachOf(style.boxShadow) > 0 ||
479
+ SIDES.some((side) => px(style.getPropertyValue(`border-${side.toLowerCase()}-width`)) > 0);
480
+ if ((style.content === '""' && !box) || style.visibility !== "visible") {
481
+ continue;
482
+ }
483
+ const at = pseudo === "before" ? index + 0.25 : -1;
484
+ pseudos.push({
485
+ host,
486
+ pseudo,
487
+ key: [...key, layerOf(style, el), at],
488
+ reach: Math.max(reachOf(style.boxShadow), reachOf(style.textShadow)),
489
+ });
490
+ drawn = true;
491
+ }
492
+ const style = getComputedStyle(el);
493
+ if (
494
+ style.display.includes("list-item") &&
495
+ (style.listStyleType !== "none" || style.listStyleImage !== "none") &&
496
+ rgba(getComputedStyle(el, "::marker").color).alpha > 0
497
+ ) {
498
+ const bullet = bulletOf(el, style);
499
+ pseudos.push({
500
+ host,
501
+ pseudo: "marker",
502
+ key: [...key, 0, index + 0.1],
503
+ reach: 0,
504
+ ...(bullet ? { bullet } : {}),
505
+ });
506
+ drawn = true;
507
+ }
508
+ if (drawn) {
509
+ el.setAttribute("data-dek-pptx-pseudo", String(host));
510
+ }
511
+ };
512
+
513
+ /**
514
+ * Each element `el` holds, in the document's order: `scale` is what scales it, none when
515
+ * something rotates or skews it, `opacity` all it is in, and `effect` whether a filter, blend,
516
+ * clip path, or mask of something it is in changes how it looks.
517
+ */
518
+ const visit = (
519
+ el: Element,
520
+ parent: number,
521
+ key: PaintKey,
522
+ scale: number | undefined,
523
+ opacity: number,
524
+ effect: boolean,
525
+ ) => {
526
+ for (const child of el.children) {
527
+ const tag = child.tagName.toLowerCase();
528
+ if (tag === "script" || tag === "style" || tag === "template") {
529
+ continue;
530
+ }
531
+ const style = getComputedStyle(child);
532
+ const id = nodes.length;
533
+ child.setAttribute("data-dek-pptx-node", String(id));
534
+ const own = [...key, layerOf(style, el), id];
535
+ const rect = child.getBoundingClientRect();
536
+ const childScale = scaleOf(style, scale);
537
+ const childOpacity = opacity * Number(style.opacity);
538
+ const childEffect = effect || effects(style);
539
+ const flow = flowOf(style, el, PICTURES.has(tag) || REPLACED.has(tag));
540
+ const node: PptxPaintNode = {
541
+ parent,
542
+ key: own,
543
+ rect: local(rect),
544
+ ...(flow ? { flow } : {}),
545
+ };
546
+ nodes.push(node);
547
+ ends.push(id + 1);
548
+ const laidOut = child.getClientRects().length > 0 && rect.width > 0 && rect.height > 0;
549
+ const visible = style.visibility === "visible" && childOpacity > 0;
550
+ if (child.shadowRoot !== null || tag.includes("-")) {
551
+ // What a shadow root draws is out of reach, and a custom element may have a closed one.
552
+ if (laidOut && visible) {
553
+ node.paint = { kind: "drawn", extent: node.rect };
554
+ }
555
+ continue;
556
+ }
557
+ if (PICTURES.has(tag)) {
558
+ if (laidOut && visible) {
559
+ const blended = childEffect && style.mixBlendMode !== "normal";
560
+ // A shot of the picture alone is its drawing whole, its shadow and outline with it, so
561
+ // nothing may cut it.
562
+ const reach =
563
+ reachOf(style.boxShadow) +
564
+ (style.outlineStyle !== "none" && px(style.outlineWidth) > 0
565
+ ? px(style.outlineWidth) + Math.max(0, px(style.outlineOffset))
566
+ : 0);
567
+ const whole = new DOMRect(
568
+ rect.left - reach,
569
+ rect.top - reach,
570
+ rect.width + reach * 2,
571
+ rect.height + reach * 2,
572
+ );
573
+ const extent = local(whole);
574
+ node.paint =
575
+ childScale === undefined || blended || clipped(child, whole) || offscreen(whole)
576
+ ? { kind: "drawn", extent }
577
+ : {
578
+ kind: "picture",
579
+ description: describe(child),
580
+ ...(reach > 0 ? { extent } : {}),
581
+ };
582
+ }
583
+ // An SVG is drawn as a whole: nothing inside it is an element of its own here.
584
+ continue;
585
+ }
586
+ if (REPLACED.has(tag)) {
587
+ if (laidOut && visible) {
588
+ node.paint = { kind: "drawn", extent: node.rect };
589
+ }
590
+ continue;
591
+ }
592
+ if (laidOut) {
593
+ const own = boxPaint(child, style, rect, childScale, childOpacity, childEffect);
594
+ if (own) {
595
+ node.paint = own;
596
+ }
597
+ }
598
+ pseudoPaints(child, id, node.key, id);
599
+ visit(child, id, node.key, childScale, childOpacity, childEffect);
600
+ ends[id] = nodes.length;
601
+ }
602
+ };
603
+
604
+ pseudoPaints(slide, -1, [], -1);
605
+ visit(slide, -1, [], 1, 1, false);
606
+ ends.push(nodes.length);
607
+ // An `::after` paints after everything its element holds.
608
+ for (const pseudo of pseudos) {
609
+ if (pseudo.pseudo === "after") {
610
+ pseudo.key[pseudo.key.length - 1] =
611
+ (pseudo.host === -1 ? nodes.length : (ends[pseudo.host] ?? 0)) - 0.25;
612
+ }
613
+ }
614
+
615
+ // The slide's own box: painted only by its background, over the whole viewport, it is what
616
+ // PowerPoint paints as a slide's background.
617
+ const own = getComputedStyle(slide);
618
+ const plain =
619
+ SIDES.every((side) => px(own.getPropertyValue(`border-${side.toLowerCase()}-width`)) === 0) &&
620
+ [
621
+ own.borderTopLeftRadius,
622
+ own.borderTopRightRadius,
623
+ own.borderBottomRightRadius,
624
+ own.borderBottomLeftRadius,
625
+ ].every((radius) => px(radius) === 0) &&
626
+ reachOf(own.boxShadow) === 0 &&
627
+ (own.outlineStyle === "none" || px(own.outlineWidth) === 0) &&
628
+ !effects(own) &&
629
+ Number(own.opacity) === 1 &&
630
+ own.transform === "none" &&
631
+ !set(own.rotate) &&
632
+ !set(own.scale) &&
633
+ !set(own.translate) &&
634
+ own.visibility === "visible" &&
635
+ /^auto( auto)?$/.test(own.backgroundSize) &&
636
+ /^0% 0%$|^0px 0px$/.test(own.backgroundPosition) &&
637
+ own.backgroundBlendMode === "normal" &&
638
+ !offscreen(origin) &&
639
+ origin.left <= 0.5 &&
640
+ origin.top <= 0.5 &&
641
+ origin.right >= window.innerWidth - 0.5 &&
642
+ origin.bottom >= window.innerHeight - 0.5;
643
+ const stacking = zOf(own, slide.parentElement) !== undefined || isolates(own);
644
+ return {
645
+ origin: { left: origin.left, top: origin.top },
646
+ nodes,
647
+ pseudos,
648
+ slide: {
649
+ paint: rgba(own.backgroundColor),
650
+ image: own.backgroundImage,
651
+ plain,
652
+ stacking,
653
+ width: origin.width,
654
+ height: origin.height,
655
+ },
656
+ };
657
+ }
658
+
659
+ /**
660
+ * What the slide says of the deck's theme: the color each of its color tokens is, and the
661
+ * `font-family` each of its font tokens is, each as an element set in it computes it, so a token
662
+ * set from another reads as what it comes to. A token the slide does not set is not there.
663
+ */
664
+ export function measurePptxThemeInPage(): {
665
+ colors: Partial<Record<"fg" | "bg" | "muted" | "accent", [number, number, number]>>;
666
+ families: Partial<Record<"title" | "body", string>>;
667
+ } {
668
+ const slide = document.querySelector("#deck > .slide");
669
+ if (!(slide instanceof HTMLElement)) {
670
+ return { colors: {}, families: {} };
671
+ }
672
+ const tokens = getComputedStyle(slide);
673
+ const given = (token: string) => tokens.getPropertyValue(token).trim() !== "";
674
+ const probe = document.createElement("span");
675
+ probe.style.cssText = "position: absolute; visibility: hidden;";
676
+ slide.append(probe);
677
+ const canvas = document.createElement("canvas");
678
+ canvas.width = 1;
679
+ canvas.height = 1;
680
+ const paint = canvas.getContext("2d", { willReadFrequently: true });
681
+ const colors: Partial<Record<"fg" | "bg" | "muted" | "accent", [number, number, number]>> = {};
682
+ for (const token of ["fg", "bg", "muted", "accent"] as const) {
683
+ if (!given(`--${token}`) || !paint) {
684
+ continue;
685
+ }
686
+ probe.style.color = `var(--${token})`;
687
+ paint.clearRect(0, 0, 1, 1);
688
+ paint.fillStyle = "rgba(0, 0, 0, 0)";
689
+ paint.fillStyle = getComputedStyle(probe).color;
690
+ paint.fillRect(0, 0, 1, 1);
691
+ const [r = 0, g = 0, b = 0, a = 0] = paint.getImageData(0, 0, 1, 1).data;
692
+ // A color the page shows through is no color a theme can name.
693
+ if (a === 255) {
694
+ colors[token] = [r, g, b];
695
+ }
696
+ }
697
+ const families: Partial<Record<"title" | "body", string>> = {};
698
+ for (const token of ["title", "body"] as const) {
699
+ if (given(`--font-${token}`)) {
700
+ probe.style.fontFamily = `var(--font-${token})`;
701
+ families[token] = getComputedStyle(probe).fontFamily;
702
+ }
703
+ }
704
+ probe.remove();
705
+ return { colors, families };
706
+ }
707
+
708
+ /**
709
+ * Puts, or takes away, a line of text in each font token, Latin and East Asian, marked
710
+ * `data-dek-pptx-probe="<token>-<script>"` for Chromium to say which face drew it. Puts them in
711
+ * only after the last shot of the slide.
712
+ */
713
+ export async function pptxFontProbesInPage(put: boolean): Promise<void> {
714
+ for (const probe of document.querySelectorAll("[data-dek-pptx-probe]")) {
715
+ probe.remove();
716
+ }
717
+ const slide = document.querySelector("#deck > .slide");
718
+ if (!put || !(slide instanceof HTMLElement)) {
719
+ return;
720
+ }
721
+ for (const token of ["title", "body"] as const) {
722
+ for (const [script, text] of [
723
+ ["latin", "Aa"],
724
+ ["ea", "あ漢字"],
725
+ ] as const) {
726
+ const probe = document.createElement("span");
727
+ probe.setAttribute("data-dek-pptx-probe", `${token}-${script}`);
728
+ probe.style.cssText = `position: absolute; left: 0; top: 0; font-family: var(--font-${token});`;
729
+ probe.textContent = text;
730
+ slide.append(probe);
731
+ }
732
+ }
733
+ // Laid out and drawn once, so Chromium has shaped them with the faces it found.
734
+ await new Promise<void>((done) =>
735
+ requestAnimationFrame(() => requestAnimationFrame(() => done())),
736
+ );
737
+ }
738
+
739
+ /** Whether a PNG, as a data URL, is transparent all over: nothing is left in the picture. */
740
+ export async function pptxPictureEmptyInPage(src: string): Promise<boolean> {
741
+ const image = new Image();
742
+ image.src = src;
743
+ await image.decode();
744
+ const canvas = document.createElement("canvas");
745
+ canvas.width = image.width;
746
+ canvas.height = image.height;
747
+ const paint = canvas.getContext("2d", { willReadFrequently: true });
748
+ if (!paint) {
749
+ return false;
750
+ }
751
+ paint.drawImage(image, 0, 0);
752
+ const { data } = paint.getImageData(0, 0, image.width, image.height);
753
+ for (let at = 3; at < data.length; at += 4) {
754
+ if (data[at] !== 0) {
755
+ return false;
756
+ }
757
+ }
758
+ return true;
759
+ }
760
+
761
+ /**
762
+ * What the slide's pictures say they show, by the element that says it as
763
+ * `measurePptxPaintInPage` numbered it, -1 when it did not: each `alt`, `aria-label`, and SVG
764
+ * `<title>` outside `aria-hidden`, in the document's order.
765
+ */
766
+ export function describePptxPicturesInPage(): PptxPagePaint["described"] {
767
+ const slide = document.querySelector("#deck > .slide");
768
+ if (!(slide instanceof HTMLElement)) {
769
+ return [];
770
+ }
771
+ return [...slide.querySelectorAll("img[alt], [aria-label], svg > title")].flatMap((el) => {
772
+ if (el.closest('[aria-hidden="true"]')) {
773
+ return [];
774
+ }
775
+ const title = el.tagName.toLowerCase() === "title";
776
+ const said =
777
+ (title ? el.textContent : (el.getAttribute("alt") ?? el.getAttribute("aria-label"))) ?? "";
778
+ const marked = (title ? el.closest("svg") : el)?.getAttribute("data-dek-pptx-node");
779
+ return said.trim() === ""
780
+ ? []
781
+ : [{ node: marked == null ? -1 : Number(marked), text: said.trim() }];
782
+ });
783
+ }
784
+
785
+ /** The paint an inline SVG's elements take from CSS, written back as attributes. */
786
+ export const SVG_PAINT = [
787
+ "fill",
788
+ "fill-opacity",
789
+ "fill-rule",
790
+ "stroke",
791
+ "stroke-width",
792
+ "stroke-opacity",
793
+ "stroke-linecap",
794
+ "stroke-linejoin",
795
+ "stroke-miterlimit",
796
+ "stroke-dasharray",
797
+ "stroke-dashoffset",
798
+ "opacity",
799
+ "visibility",
800
+ "display",
801
+ "font-family",
802
+ "font-size",
803
+ "font-weight",
804
+ "font-style",
805
+ "text-anchor",
806
+ "dominant-baseline",
807
+ "letter-spacing",
808
+ "stop-color",
809
+ "stop-opacity",
810
+ "clip-rule",
811
+ "paint-order",
812
+ "vector-effect",
813
+ "marker-start",
814
+ "marker-mid",
815
+ "marker-end",
816
+ "filter",
817
+ "clip-path",
818
+ "mask",
819
+ "flood-color",
820
+ "flood-opacity",
821
+ "lighting-color",
822
+ ] as const;
823
+
824
+ /**
825
+ * What else CSS can set on an SVG's elements, which their attributes alone must come to as
826
+ * well for the file to be the SVG the page drew: where each shape lies and how its text is set.
827
+ */
828
+ export const SVG_CHECKED = [
829
+ "transform",
830
+ "d",
831
+ "cx",
832
+ "cy",
833
+ "r",
834
+ "rx",
835
+ "ry",
836
+ "x",
837
+ "y",
838
+ "width",
839
+ "height",
840
+ "text-decoration",
841
+ "writing-mode",
842
+ "font-variant",
843
+ "font-stretch",
844
+ "word-spacing",
845
+ "baseline-shift",
846
+ ] as const;
847
+
848
+ /**
849
+ * An inline SVG as a file of its own: each element's computed paint written onto it as
850
+ * attributes, so a theme's `currentColor`, tokens, and classes survive without the page, at the
851
+ * size it was drawn, and nothing in it that runs. None when the file would draw other than the
852
+ * page did: when it refers to anything outside itself, which a file cannot reach; when the
853
+ * `<svg>` paints a box of its own, or something it is in filters, blends, clips, or masks it,
854
+ * which the PNG keeps; or when CSS set more than its attributes can say, which is found by
855
+ * setting the file's elements where no stylesheet of the page reaches and comparing.
856
+ */
857
+ export function pptxSvgInPage(args: {
858
+ node: number;
859
+ paint: readonly string[];
860
+ checked: readonly string[];
861
+ }): string | undefined {
862
+ const svg = document.querySelector(`[data-dek-pptx-node="${args.node}"]`);
863
+ if (!(svg instanceof SVGSVGElement) || svg.querySelector("foreignObject")) {
864
+ return undefined;
865
+ }
866
+ const px = (value: string) => Number.parseFloat(value) || 0;
867
+ const none = (value: string) => value === "none" || value === "";
868
+ const own = getComputedStyle(svg);
869
+ // A background color is the file's too, a rectangle under the drawing; any other box paint,
870
+ // or a background with round corners, is the PNG's alone.
871
+ const background = own.backgroundColor === "rgba(0, 0, 0, 0)" ? undefined : own.backgroundColor;
872
+ const round = [
873
+ own.borderTopLeftRadius,
874
+ own.borderTopRightRadius,
875
+ own.borderBottomRightRadius,
876
+ own.borderBottomLeftRadius,
877
+ ].some((radius) => px(radius) > 0);
878
+ if (
879
+ (background !== undefined && round) ||
880
+ !none(own.backgroundImage) ||
881
+ !none(own.boxShadow) ||
882
+ (own.outlineStyle !== "none" && px(own.outlineWidth) > 0) ||
883
+ ["top", "right", "bottom", "left"].some(
884
+ (side) =>
885
+ px(own.getPropertyValue(`border-${side}-width`)) > 0 ||
886
+ px(own.getPropertyValue(`padding-${side}`)) > 0,
887
+ ) ||
888
+ !none(own.transform) ||
889
+ !none(own.scale)
890
+ ) {
891
+ return undefined;
892
+ }
893
+ for (let node: Element | null = svg; node && node.id !== "deck"; node = node.parentElement) {
894
+ const style = getComputedStyle(node);
895
+ if (
896
+ !none(style.filter) ||
897
+ style.mixBlendMode !== "normal" ||
898
+ !none(style.clipPath) ||
899
+ !none(style.maskImage)
900
+ ) {
901
+ return undefined;
902
+ }
903
+ }
904
+
905
+ const inside = (id: string) =>
906
+ id === "" || svg.querySelector(`[id="${CSS.escape(id)}"]`) !== null;
907
+ /** A value with every `url()` in it pointing inside the SVG, written `url(#id)`; none if not. */
908
+ const localized = (value: string): string | undefined => {
909
+ let local = true;
910
+ const written = value.replace(
911
+ /url\(\s*(["']?)(.*?)\1\s*\)/g,
912
+ (whole, _quote, target: string) => {
913
+ if (target.startsWith("data:")) {
914
+ return whole;
915
+ }
916
+ if (!target.startsWith("#") || !inside(target.slice(1))) {
917
+ local = false;
918
+ }
919
+ return `url(${target})`;
920
+ },
921
+ );
922
+ return local ? written : undefined;
923
+ };
924
+
925
+ const all = [svg, ...svg.querySelectorAll("*")];
926
+ for (const el of all) {
927
+ const href = el.getAttribute("href") ?? el.getAttribute("xlink:href");
928
+ if (
929
+ href !== null &&
930
+ (href.startsWith("#") ? !inside(href.slice(1)) : !href.startsWith("data:"))
931
+ ) {
932
+ return undefined;
933
+ }
934
+ }
935
+ const copy = svg.cloneNode(true) as SVGSVGElement;
936
+ const copies = [copy, ...copy.querySelectorAll("*")];
937
+ // What a `<style>` in the SVG said is in each element's computed paint; a script does not go.
938
+ const pairs = all.flatMap((el, index) => {
939
+ const target = copies[index];
940
+ return target && !el.closest("script, style") ? [[el, target] as const] : [];
941
+ });
942
+ for (const el of copy.querySelectorAll("script, style")) {
943
+ el.remove();
944
+ }
945
+ for (const [el, target] of pairs) {
946
+ for (const name of target.getAttributeNames()) {
947
+ // What the page's CSS said goes in the attributes; inline style would outrank them.
948
+ if (
949
+ /^on/i.test(name) ||
950
+ name.startsWith("data-dek-pptx") ||
951
+ name === "class" ||
952
+ name === "style"
953
+ ) {
954
+ target.removeAttribute(name);
955
+ }
956
+ }
957
+ const style = getComputedStyle(el);
958
+ for (const property of args.paint) {
959
+ const value = style.getPropertyValue(property);
960
+ if (value === "") {
961
+ continue;
962
+ }
963
+ if (/^(filter|clip-path|mask)$/.test(property) && value !== "none" && !/^url\(/.test(value)) {
964
+ return undefined;
965
+ }
966
+ target.setAttribute(property, value);
967
+ }
968
+ for (const name of target.getAttributeNames()) {
969
+ const value = target.getAttribute(name) ?? "";
970
+ if (/url\(/i.test(value)) {
971
+ const local = localized(value);
972
+ if (local === undefined) {
973
+ return undefined;
974
+ }
975
+ target.setAttribute(name, local);
976
+ }
977
+ }
978
+ }
979
+ let opacity = 1;
980
+ for (let node: Element | null = svg; node; node = node.parentElement) {
981
+ opacity *= Number(getComputedStyle(node).opacity);
982
+ }
983
+ const rect = svg.getBoundingClientRect();
984
+ const [width, height] = [px(own.width), px(own.height)];
985
+ if (
986
+ !svg.hasAttribute("viewBox") &&
987
+ (Math.abs(width - rect.width) > 0.5 || Math.abs(height - rect.height) > 0.5)
988
+ ) {
989
+ // Scaled by what it is in: drawn at the size it was laid out, then scaled as the page did.
990
+ if (!(width > 0 && height > 0)) {
991
+ return undefined;
992
+ }
993
+ copy.setAttribute("viewBox", `0 0 ${width} ${height}`);
994
+ }
995
+ copy.setAttribute("xmlns", "http://www.w3.org/2000/svg");
996
+ copy.setAttribute("width", String(rect.width));
997
+ copy.setAttribute("height", String(rect.height));
998
+ copy.setAttribute("opacity", String(opacity));
999
+
1000
+ const host = document.createElement("div");
1001
+ host.setAttribute(
1002
+ "style",
1003
+ "all: initial; position: fixed; left: 0; top: 0; opacity: 0; pointer-events: none;",
1004
+ );
1005
+ const probe = copy.cloneNode(true) as SVGSVGElement;
1006
+ host.attachShadow({ mode: "closed" }).append(probe);
1007
+ document.body.append(host);
1008
+ try {
1009
+ const probes = [probe, ...probe.querySelectorAll("*")];
1010
+ if (probes.length !== pairs.length) {
1011
+ return undefined;
1012
+ }
1013
+ for (const [index, [el]] of pairs.entries()) {
1014
+ const page = getComputedStyle(el);
1015
+ const file = getComputedStyle(probes[index] as Element);
1016
+ for (const property of [...args.paint, ...args.checked]) {
1017
+ // The `<svg>` itself is placed and faded by the picture's frame, not by its attributes.
1018
+ if (index === 0 && /^(x|y|width|height|transform|opacity)$/.test(property)) {
1019
+ continue;
1020
+ }
1021
+ if (page.getPropertyValue(property) !== file.getPropertyValue(property)) {
1022
+ return undefined;
1023
+ }
1024
+ }
1025
+ }
1026
+ } finally {
1027
+ host.remove();
1028
+ }
1029
+
1030
+ // Each color as `rgb()` and an opacity, which every SVG renderer reads, whatever space the
1031
+ // page wrote it in: drawn once to be read back in sRGB.
1032
+ const canvas = document.createElement("canvas");
1033
+ canvas.width = 1;
1034
+ canvas.height = 1;
1035
+ const paint = canvas.getContext("2d", { willReadFrequently: true });
1036
+ const srgb = (value: string): { rgb: string; alpha: number } | undefined => {
1037
+ const written = /^rgba?\(\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\s*\)$/.exec(
1038
+ value,
1039
+ );
1040
+ if (written) {
1041
+ const [, r, g, b, a = "1"] = written;
1042
+ return { rgb: `rgb(${r}, ${g}, ${b})`, alpha: Number(a) };
1043
+ }
1044
+ if (
1045
+ !paint ||
1046
+ value === "none" ||
1047
+ value.includes("url(") ||
1048
+ /^(currentcolor|context-)/i.test(value)
1049
+ ) {
1050
+ return undefined;
1051
+ }
1052
+ paint.clearRect(0, 0, 1, 1);
1053
+ paint.fillStyle = "rgba(0, 0, 0, 0)";
1054
+ paint.fillStyle = value;
1055
+ paint.fillRect(0, 0, 1, 1);
1056
+ const [r = 0, g = 0, b = 0, a = 0] = paint.getImageData(0, 0, 1, 1).data;
1057
+ return { rgb: `rgb(${r}, ${g}, ${b})`, alpha: a / 255 };
1058
+ };
1059
+ const COLORS: Array<[string, string | undefined]> = [
1060
+ ["fill", "fill-opacity"],
1061
+ ["stroke", "stroke-opacity"],
1062
+ ["stop-color", "stop-opacity"],
1063
+ ["flood-color", "flood-opacity"],
1064
+ ["lighting-color", undefined],
1065
+ ];
1066
+ for (const el of [copy, ...copy.querySelectorAll("*")]) {
1067
+ for (const [property, opacityOf] of COLORS) {
1068
+ const color = srgb(el.getAttribute(property) ?? "none");
1069
+ if (!color) {
1070
+ continue;
1071
+ }
1072
+ el.setAttribute(property, color.rgb);
1073
+ if (opacityOf && color.alpha < 1) {
1074
+ const of = Number(el.getAttribute(opacityOf) ?? "1");
1075
+ el.setAttribute(
1076
+ opacityOf,
1077
+ String(Math.round((Number.isFinite(of) ? of : 1) * color.alpha * 1000) / 1000),
1078
+ );
1079
+ }
1080
+ }
1081
+ }
1082
+
1083
+ if (background === undefined) {
1084
+ return new XMLSerializer().serializeToString(copy);
1085
+ }
1086
+ // The drawing in an SVG of the same size, over its background: the drawing keeps its own
1087
+ // viewBox, and the rectangle fills the box the page painted.
1088
+ const NS = "http://www.w3.org/2000/svg";
1089
+ const outer = document.createElementNS(NS, "svg");
1090
+ outer.setAttribute("xmlns", NS);
1091
+ outer.setAttribute("width", String(rect.width));
1092
+ outer.setAttribute("height", String(rect.height));
1093
+ outer.setAttribute("opacity", String(opacity));
1094
+ const ground = document.createElementNS(NS, "rect");
1095
+ ground.setAttribute("width", "100%");
1096
+ ground.setAttribute("height", "100%");
1097
+ const groundColor = srgb(background);
1098
+ ground.setAttribute("fill", groundColor?.rgb ?? background);
1099
+ if (groundColor && groundColor.alpha < 1) {
1100
+ ground.setAttribute("fill-opacity", String(Math.round(groundColor.alpha * 1000) / 1000));
1101
+ }
1102
+ copy.removeAttribute("opacity");
1103
+ copy.removeAttribute("xmlns");
1104
+ copy.setAttribute("x", "0");
1105
+ copy.setAttribute("y", "0");
1106
+ outer.append(ground, copy);
1107
+ return new XMLSerializer().serializeToString(outer);
1108
+ }
1109
+
1110
+ /** The style that shows one element of the slide alone, on nothing; or shows the slide again. */
1111
+ export function showOnlyPptxNodeInPage(node: number | null): void {
1112
+ document.getElementById("dek-pptx-alone")?.remove();
1113
+ if (node === null) {
1114
+ return;
1115
+ }
1116
+ const style = document.createElement("style");
1117
+ style.id = "dek-pptx-alone";
1118
+ const alone = `[data-dek-pptx-node="${node}"]`;
1119
+ // Ids no element has, which `:not()` counts, so the rule outranks a theme's own `!important`.
1120
+ const over = ":not(#dek-pptx-0):not(#dek-pptx-1):not(#dek-pptx-2)";
1121
+ // A transition would draw the change halfway, as it began, in the shot taken next.
1122
+ style.textContent = `*, *::before, *::after, *::marker { transition: none !important; }
1123
+ html${over}, body${over}, #deck${over} { background: transparent !important; }
1124
+ #deck > .slide${over}, #deck > .slide *:not(${alone}, ${alone} *)${over} { visibility: hidden !important; }
1125
+ ${alone}${over} { visibility: visible !important; }`;
1126
+ document.head.append(style);
1127
+ }
1128
+
1129
+ /**
1130
+ * Takes what was laid over the picture out of it, with no change to the layout: a shape's
1131
+ * background and borders made transparent, a picture hidden, a bullet's marker unpainted, and
1132
+ * the slide's background, when PowerPoint paints it.
1133
+ */
1134
+ export function hidePptxLiftedInPage(lifted: {
1135
+ shapes: number[];
1136
+ pictures: number[];
1137
+ markers: number[];
1138
+ /** Whether the slide's background is PowerPoint's: the slide is shot on nothing. */
1139
+ background: boolean;
1140
+ }): void {
1141
+ const over = ":not(#dek-pptx-0):not(#dek-pptx-1):not(#dek-pptx-2)";
1142
+ const select = (ids: number[], suffix = "") =>
1143
+ ids.map((id) => `[data-dek-pptx-node="${id}"]${over}${suffix}`).join(", ");
1144
+ const rules = [
1145
+ lifted.shapes.length > 0
1146
+ ? `${select(lifted.shapes)} { background-color: transparent !important; border-color: transparent !important; }`
1147
+ : "",
1148
+ lifted.pictures.length > 0
1149
+ ? `${select(lifted.pictures)} { visibility: hidden !important; }`
1150
+ : "",
1151
+ lifted.markers.length > 0
1152
+ ? `${select(lifted.markers, "::marker")} { color: transparent !important; }`
1153
+ : "",
1154
+ lifted.background
1155
+ ? `html${over}, body${over}, #deck${over}, #deck > .slide${over} { background: transparent !important; }`
1156
+ : "",
1157
+ ].filter((rule) => rule !== "");
1158
+ if (rules.length === 0) {
1159
+ return;
1160
+ }
1161
+ const style = document.createElement("style");
1162
+ // A transition would leave what was lifted halfway gone in the shot taken next.
1163
+ style.textContent = [
1164
+ "*, *::before, *::after, *::marker { transition: none !important; }",
1165
+ ...rules,
1166
+ ].join("\n");
1167
+ document.head.append(style);
1168
+ }