@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
@@ -1,40 +1,65 @@
1
1
  /// <reference lib="dom" />
2
2
  // What a PPTX takes from a slide as the browser drew it: each line of the text in its HTML flow,
3
- // where it was laid out and how it was set, so PowerPoint can set it there again; and the slide
4
- // drawn with that text taken out, as the picture under it.
3
+ // where it was laid out and how it was set, so PowerPoint can set it there again; each block's
4
+ // text as it is written, for a shape to set as paragraphs; and the slide drawn with that text
5
+ // taken out, as the picture under it.
5
6
 
6
- import type { PptxRun, PptxTextBox } from "./pptx-package.ts";
7
-
8
- /** A run as the page measures it: the element that owns its text, for its font to be looked up. */
9
- type MeasuredRun = Omit<PptxRun, "font"> & { owner: number };
10
-
11
- type MeasuredBox = Omit<PptxTextBox, "runs"> & { runs: MeasuredRun[] };
7
+ import type { PptxBlockText, PptxMeasuredBox, PptxMeasuredRun } from "./pptx-plan.ts";
8
+ import type { Box } from "./slide-measure.ts";
12
9
 
13
10
  export type PptxPageTexts = {
14
- boxes: MeasuredBox[];
15
- /** How many elements own text, marked `data-dek-pptx-font="<n>"` for their fonts. */
16
- owners: number;
17
- /** The alternative texts the slide's pictures carry, for the slide's picture as a whole. */
18
- description: string;
11
+ boxes: PptxMeasuredBox[];
12
+ /** The text of each block a box is in, when it is set in plain lines left to right. */
13
+ blocks: PptxBlockText[];
14
+ /** Text the picture keeps, and the shadows of text it does not, by the element they are of. */
15
+ drawn: Array<{ node: number; extent: Box }>;
19
16
  };
20
17
 
21
- /** The attribute that marks each element owning measured text, by its number. */
18
+ /** The attribute that marks each element owning measured text, by its node. */
22
19
  export const PPTX_OWNER_ATTR = "data-dek-pptx-font";
23
20
 
24
21
  /**
25
22
  * Measures every text in the slide's HTML flow, line by line, then hides it from the drawing with
26
23
  * a highlight, which changes no layout, so a shot taken next is the picture under the text. What
27
24
  * is not measured stays drawn: SVG, decoration under `aria-hidden`, what a pseudo-element draws,
28
- * text a transform rotates or skews, text with no fill of its own, and every text's shadow.
25
+ * text a transform rotates or skews, text with no fill of its own, and every text's shadow; the
26
+ * text and the shadows among these are reported as drawn. Each element is known by the number
27
+ * `measurePptxPaintInPage` marked it with, which runs first. Each run says the `font-family` it
28
+ * was set in, for the theme's fonts to be found.
29
29
  *
30
30
  * Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
31
31
  */
32
32
  export function measurePptxTextsInPage(): PptxPageTexts {
33
33
  const slide = document.querySelector("#deck > .slide");
34
34
  if (!(slide instanceof HTMLElement)) {
35
- return { boxes: [], owners: 0, description: "" };
35
+ return { boxes: [], blocks: [], drawn: [] };
36
36
  }
37
+ /**
38
+ * The element's number, as `measurePptxPaintInPage` marked it; -1 for the slide; and for one it
39
+ * did not mark, which it did not reach or did not run for, a number below -1 of its own, so
40
+ * each owner of text still has its own font.
41
+ */
42
+ const unmarked = new Map<Element, number>();
43
+ const nodeOf = (el: Element): number => {
44
+ const marked = el.getAttribute("data-dek-pptx-node");
45
+ if (marked !== null) {
46
+ return Number(marked);
47
+ }
48
+ if (el === slide) {
49
+ return -1;
50
+ }
51
+ const known = unmarked.get(el) ?? -2 - unmarked.size;
52
+ unmarked.set(el, known);
53
+ return known;
54
+ };
37
55
  const origin = slide.getBoundingClientRect();
56
+ const local = (rect: Box): Box => ({
57
+ left: rect.left - origin.left,
58
+ top: rect.top - origin.top,
59
+ right: rect.right - origin.left,
60
+ bottom: rect.bottom - origin.top,
61
+ });
62
+ const drawn: PptxPageTexts["drawn"] = [];
38
63
 
39
64
  const canvas = document.createElement("canvas");
40
65
  canvas.width = 1;
@@ -50,6 +75,8 @@ export function measurePptxTextsInPage(): PptxPageTexts {
50
75
  let found = { rgb: [0, 0, 0] as [number, number, number], alpha: 0 };
51
76
  if (paint) {
52
77
  paint.clearRect(0, 0, 1, 1);
78
+ // A value the canvas cannot read leaves its fill as it was: nothing, not the last color.
79
+ paint.fillStyle = "rgba(0, 0, 0, 0)";
53
80
  paint.fillStyle = value;
54
81
  paint.fillRect(0, 0, 1, 1);
55
82
  const [r = 0, g = 0, b = 0, a = 0] = paint.getImageData(0, 0, 1, 1).data;
@@ -60,17 +87,30 @@ export function measurePptxTextsInPage(): PptxPageTexts {
60
87
  };
61
88
 
62
89
  /**
63
- * How much the element and what it is in, up to the slide, scale it; none when one of them
64
- * rotates or skews it, or scales it unevenly, which a text box cannot follow.
90
+ * How much the element and what it is in, up to the slide, scale it, by `transform` and
91
+ * `scale`; none when one of them rotates or skews it, scales it unevenly, or `zoom` sets it at
92
+ * another size than its computed lengths say, which a text box cannot follow.
65
93
  */
66
94
  const scaleOf = (el: Element): number | undefined => {
95
+ const set = (value: string) => value !== "none" && value !== "";
67
96
  let scale = 1;
68
97
  for (
69
98
  let node: Element | null = el;
70
99
  node && node !== slide.parentElement;
71
100
  node = node.parentElement
72
101
  ) {
73
- const transform = getComputedStyle(node).transform;
102
+ const style = getComputedStyle(node);
103
+ if (set(style.rotate) || (style.zoom !== "" && Number(style.zoom) !== 1)) {
104
+ return undefined;
105
+ }
106
+ if (set(style.scale)) {
107
+ const [x = Number.NaN, y = x, z = 1] = style.scale.split(/\s+/).map(Number);
108
+ if (!(x > 0) || Math.abs(x - y) > 1e-6 || z !== 1) {
109
+ return undefined;
110
+ }
111
+ scale *= x;
112
+ }
113
+ const transform = style.transform;
74
114
  if (transform === "none" || transform === "") {
75
115
  continue;
76
116
  }
@@ -98,6 +138,15 @@ export function measurePptxTextsInPage(): PptxPageTexts {
98
138
  return opacity;
99
139
  };
100
140
 
141
+ /** How far a shadow list reaches past the text: every length in it, added up, at most. */
142
+ const reachOf = (value: string): number =>
143
+ value === "none"
144
+ ? 0
145
+ : (value.match(/-?\d*\.?\d+px/g) ?? []).reduce(
146
+ (sum, length) => sum + Math.abs(Number.parseFloat(length) || 0),
147
+ 0,
148
+ );
149
+
101
150
  /** The nearest element around `el` laid out as a block of its own, `el` included. */
102
151
  const blockOf = (el: Element): Element => {
103
152
  for (let node: Element | null = el; node; node = node.parentElement) {
@@ -150,11 +199,21 @@ export function measurePptxTextsInPage(): PptxPageTexts {
150
199
  right: number;
151
200
  bottom: number;
152
201
  block: Element;
153
- run: MeasuredRun;
202
+ run: PptxMeasuredRun;
154
203
  };
155
204
  const fragments: Fragment[] = [];
156
205
  const ranges: Range[] = [];
157
- const owners = new Map<Element, number>();
206
+ const owners = new Set<Element>();
207
+ /** Each measured text's run, with its white space as written, for its block's paragraph. */
208
+ const written = new Map<
209
+ Text,
210
+ {
211
+ run: PptxMeasuredRun;
212
+ spaces: "collapsed" | "lines" | "kept";
213
+ transform: string;
214
+ lang: string;
215
+ }
216
+ >();
158
217
  const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
159
218
 
160
219
  const walker = document.createTreeWalker(slide, NodeFilter.SHOW_TEXT);
@@ -164,7 +223,7 @@ export function measurePptxTextsInPage(): PptxPageTexts {
164
223
  if (!parent || !/[^ \t\n\r\f]/.test(text.data)) {
165
224
  continue;
166
225
  }
167
- if (parent.closest('svg, script, style, template, [aria-hidden="true"]')) {
226
+ if (parent.closest("svg, script, style, template")) {
168
227
  continue;
169
228
  }
170
229
  const style = getComputedStyle(parent);
@@ -174,16 +233,40 @@ export function measurePptxTextsInPage(): PptxPageTexts {
174
233
  const fill = rgba(style.webkitTextFillColor || style.color);
175
234
  const opacity = opacityOf(parent);
176
235
  const scale = scaleOf(parent);
177
- if (fill.alpha === 0 || opacity === 0 || scale === undefined) {
236
+ if (opacity === 0) {
237
+ continue;
238
+ }
239
+ // What is not set again stays drawn, and so does every shadow of what is.
240
+ const shadow = reachOf(style.textShadow);
241
+ if (
242
+ shadow > 0 ||
243
+ fill.alpha === 0 ||
244
+ scale === undefined ||
245
+ parent.closest('[aria-hidden="true"]')
246
+ ) {
247
+ const whole = document.createRange();
248
+ whole.selectNodeContents(text);
249
+ const bounds = whole.getBoundingClientRect();
250
+ drawn.push({
251
+ node: nodeOf(parent),
252
+ extent: local({
253
+ left: bounds.left - shadow,
254
+ top: bounds.top - shadow,
255
+ right: bounds.right + shadow,
256
+ bottom: bounds.bottom + shadow,
257
+ }),
258
+ });
259
+ }
260
+ if (fill.alpha === 0 || scale === undefined || parent.closest('[aria-hidden="true"]')) {
178
261
  continue;
179
262
  }
180
263
  const block = blockOf(parent);
181
- const owner = owners.get(parent) ?? owners.size;
182
- owners.set(parent, owner);
264
+ const owner = nodeOf(parent);
265
+ owners.add(parent);
183
266
  const weight = Number(style.fontWeight);
184
267
  const letterSpacing =
185
268
  style.letterSpacing === "normal" ? 0 : Number.parseFloat(style.letterSpacing);
186
- const run: MeasuredRun = {
269
+ const run: PptxMeasuredRun = {
187
270
  text: "",
188
271
  size: Number.parseFloat(style.fontSize) * scale,
189
272
  bold: weight >= 600,
@@ -193,9 +276,13 @@ export function measurePptxTextsInPage(): PptxPageTexts {
193
276
  alpha: Math.round(fill.alpha * opacity * 1000) / 1000,
194
277
  letterSpacing: (Number.isFinite(letterSpacing) ? letterSpacing : 0) * scale,
195
278
  owner,
279
+ family: style.fontFamily,
196
280
  };
197
281
  const collapse = !/^(pre|pre-wrap|break-spaces)$/.test(style.whiteSpace);
198
282
  const lang = parent.closest("[lang]")?.getAttribute("lang") ?? "";
283
+ // `pre-line` collapses spaces as `normal` does, and keeps each newline as a break.
284
+ const spaces = !collapse ? "kept" : style.whiteSpace === "pre-line" ? "lines" : "collapsed";
285
+ written.set(text, { run, spaces, transform: style.textTransform, lang });
199
286
 
200
287
  // The lines the text is drawn on, each the graphemes on it and the box they cover.
201
288
  const range = document.createRange();
@@ -262,8 +349,8 @@ export function measurePptxTextsInPage(): PptxPageTexts {
262
349
 
263
350
  // Pieces of one line in one block that sit together are one box, a run each; a gap wider than
264
351
  // a space, such as the padding of a chip, starts another.
265
- const boxes: MeasuredBox[] = [];
266
- let open: { box: MeasuredBox; block: Element; right: number } | undefined;
352
+ const boxes: PptxMeasuredBox[] = [];
353
+ let open: { box: PptxMeasuredBox; block: Element; right: number } | undefined;
267
354
  for (const fragment of fragments) {
268
355
  const height = fragment.bottom - fragment.top;
269
356
  const gap = open ? fragment.left - open.right : Number.POSITIVE_INFINITY;
@@ -297,7 +384,8 @@ export function measurePptxTextsInPage(): PptxPageTexts {
297
384
  }
298
385
  // A box starts at its first glyph: a space it would start with is not set.
299
386
  const start = Number.isFinite(fragment.ink) ? fragment.ink : fragment.left;
300
- const box: MeasuredBox = {
387
+ const box: PptxMeasuredBox = {
388
+ block: nodeOf(fragment.block),
301
389
  x: start - origin.left,
302
390
  y: fragment.top - origin.top,
303
391
  width: Math.max(0, fragment.right - start),
@@ -320,8 +408,8 @@ export function measurePptxTextsInPage(): PptxPageTexts {
320
408
  box.runs = box.runs.filter((run) => run.text !== "");
321
409
  }
322
410
 
323
- for (const [el, owner] of owners) {
324
- el.setAttribute("data-dek-pptx-font", String(owner));
411
+ for (const el of owners) {
412
+ el.setAttribute("data-dek-pptx-font", String(nodeOf(el)));
325
413
  }
326
414
  const style = document.createElement("style");
327
415
  style.textContent =
@@ -329,25 +417,94 @@ export function measurePptxTextsInPage(): PptxPageTexts {
329
417
  document.head.append(style);
330
418
  CSS.highlights.set("dek-pptx", new Highlight(...ranges));
331
419
 
332
- const described = [...slide.querySelectorAll("img[alt], [aria-label], svg > title")].flatMap(
333
- (el) => {
334
- if (el.closest('[aria-hidden="true"]')) {
335
- return [];
336
- }
337
- const said =
338
- (el.tagName.toLowerCase() === "title"
339
- ? el.textContent
340
- : (el.getAttribute("alt") ?? el.getAttribute("aria-label"))) ?? "";
341
- return said.trim() === "" ? [] : [said.trim()];
420
+ const blocks = [...new Set(fragments.map((fragment) => fragment.block))].flatMap(
421
+ (block): PptxBlockText[] => {
422
+ const text = paragraphOf(block);
423
+ return text ? [text] : [];
342
424
  },
343
425
  );
344
- return {
345
- boxes: boxes.filter((box) => box.runs.length > 0),
346
- owners: owners.size,
347
- description: described.join(" / "),
348
- };
426
+ return { boxes: boxes.filter((box) => box.runs.length > 0), blocks, drawn };
427
+
428
+ /**
429
+ * A block's text as it is written, in runs, a `<br>` as a line break; none when it is not set
430
+ * in plain lines left to right, which a paragraph could not set again.
431
+ */
432
+ function paragraphOf(block: Element): PptxBlockText | undefined {
433
+ const style = getComputedStyle(block);
434
+ if (style.writingMode !== "horizontal-tb" || style.direction !== "ltr") {
435
+ return undefined;
436
+ }
437
+ const runs: PptxMeasuredRun[] = [];
438
+ const walk = document.createTreeWalker(block, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
439
+ for (let node = walk.nextNode(); node; node = walk.nextNode()) {
440
+ if (node instanceof HTMLBRElement && blockOf(node) === block) {
441
+ const last = runs.at(-1);
442
+ if (last) {
443
+ last.text = `${last.text.replace(/ +$/, "")}\n`;
444
+ }
445
+ continue;
446
+ }
447
+ const facts = node instanceof Text ? written.get(node) : undefined;
448
+ if (
449
+ !facts ||
450
+ !(node instanceof Text) ||
451
+ node.parentElement === null ||
452
+ blockOf(node.parentElement) !== block
453
+ ) {
454
+ continue;
455
+ }
456
+ let piece =
457
+ facts.spaces === "collapsed"
458
+ ? node.data.replace(/[ \t\n\r\f]+/g, " ")
459
+ : facts.spaces === "lines"
460
+ ? node.data.replace(/[ \t\r\f]*\n[ \t\r\f]*/g, "\n").replace(/[ \t\r\f]+/g, " ")
461
+ : node.data;
462
+ const before = runs.at(-1)?.text ?? "";
463
+ if (facts.spaces !== "kept" && (before === "" || / $|\n$/.test(before))) {
464
+ piece = piece.replace(/^ /, "");
465
+ }
466
+ piece = transformText(piece, facts.transform, facts.lang);
467
+ const last = runs.at(-1);
468
+ if (last && sameStyle(last, facts.run)) {
469
+ last.text += piece;
470
+ } else if (piece !== "") {
471
+ runs.push({ ...facts.run, text: piece });
472
+ }
473
+ }
474
+ const last = runs.at(-1);
475
+ if (last) {
476
+ last.text = last.text.replace(/[ \t\n\r\f]+$/, "");
477
+ }
478
+ const kept = runs.filter((run) => run.text !== "");
479
+ if (kept.length === 0) {
480
+ return undefined;
481
+ }
482
+ const rect = block.getBoundingClientRect();
483
+ const scale = scaleOf(block) ?? 1;
484
+ const px = (value: string) => (Number.parseFloat(value) || 0) * scale;
485
+ const align = style.textAlign;
486
+ return {
487
+ node: nodeOf(block),
488
+ runs: kept,
489
+ align: /center/.test(align)
490
+ ? "center"
491
+ : align === "right" || align === "end"
492
+ ? "right"
493
+ : align === "justify"
494
+ ? "justify"
495
+ : "left",
496
+ lineHeight: Number.parseFloat(style.lineHeight) * scale,
497
+ content: local({
498
+ left: rect.left + px(style.borderLeftWidth) + px(style.paddingLeft),
499
+ top: rect.top + px(style.borderTopWidth) + px(style.paddingTop),
500
+ right: rect.right - px(style.borderRightWidth) - px(style.paddingRight),
501
+ bottom: rect.bottom - px(style.borderBottomWidth) - px(style.paddingBottom),
502
+ }),
503
+ textIndent: px(style.textIndent),
504
+ };
505
+ }
349
506
 
350
- function sameStyle(a: MeasuredRun, b: MeasuredRun): boolean {
507
+ function sameStyle(a: PptxMeasuredRun, b: PptxMeasuredRun): boolean {
351
508
  return (
352
509
  a.owner === b.owner &&
353
510
  a.size === b.size &&
@@ -0,0 +1,222 @@
1
+ import type { PptxFill, PptxPaint, PptxTheme, PptxThemeFont } from "./pptx-package.ts";
2
+
3
+ // What of a slide's look becomes PowerPoint's own rather than its picture's, from the computed
4
+ // values a page reports: the deck's theme, from the tokens most slides have, and a slide's
5
+ // background, when PowerPoint paints it as the browser did. Nothing here touches a page.
6
+
7
+ /** What one slide says of the deck's theme: its color tokens, and the fonts its font tokens are. */
8
+ export type PptxPageTheme = {
9
+ colors: PptxTheme["colors"];
10
+ fonts: Partial<Record<"title" | "body", PptxThemeFont>>;
11
+ };
12
+
13
+ /**
14
+ * The deck's theme: each token as most slides have it, the first slide's on a tie, so a slide
15
+ * that sets a token of its own keeps it to itself. Fonts only when both font tokens are known.
16
+ */
17
+ export function deckTheme(pages: PptxPageTheme[]): PptxTheme {
18
+ const colors: PptxTheme["colors"] = {};
19
+ for (const token of ["fg", "bg", "muted", "accent"] as const) {
20
+ const color = mostOf(
21
+ pages.map((page) => page.colors[token]),
22
+ (color) => color.join(),
23
+ );
24
+ if (color) {
25
+ colors[token] = color;
26
+ }
27
+ }
28
+ const title = mostOf(
29
+ pages.map((page) => page.fonts.title),
30
+ (font) => font.family,
31
+ );
32
+ const body = mostOf(
33
+ pages.map((page) => page.fonts.body),
34
+ (font) => font.family,
35
+ );
36
+ return title && body ? { colors, fonts: { title, body } } : { colors };
37
+ }
38
+
39
+ /** The value most of `values` are, by `key`; the first of them on a tie. */
40
+ function mostOf<T>(values: Array<T | undefined>, key: (value: T) => string): T | undefined {
41
+ const counts = new Map<string, { value: T; count: number }>();
42
+ for (const value of values) {
43
+ if (value !== undefined) {
44
+ const seen = counts.get(key(value)) ?? { value, count: 0 };
45
+ seen.count += 1;
46
+ counts.set(key(value), seen);
47
+ }
48
+ }
49
+ let most: { value: T; count: number } | undefined;
50
+ for (const seen of counts.values()) {
51
+ if (!most || seen.count > most.count) {
52
+ most = seen;
53
+ }
54
+ }
55
+ return most?.value;
56
+ }
57
+
58
+ /**
59
+ * A slide's background as PowerPoint paints it, from its computed `background-color` and
60
+ * `background-image`: one opaque color, or one linear gradient along an axis whose stops are
61
+ * opaque sRGB colors at lengths CSS and DrawingML place alike. None for anything else, which
62
+ * stays in the slide's picture: an image, a radial or repeating gradient, a diagonal, a hint,
63
+ * a color space other than sRGB, a stop the page shows through, a second layer.
64
+ */
65
+ export function slideBackground(
66
+ background: { paint: PptxPaint; image: string },
67
+ size: { width: number; height: number },
68
+ ): PptxFill | undefined {
69
+ const image = background.image.trim();
70
+ if (image === "none") {
71
+ return background.paint.alpha === 1 ? { kind: "solid", paint: background.paint } : undefined;
72
+ }
73
+ const layers = splitTopLevel(image);
74
+ const gradient = layers.length === 1 ? /^linear-gradient\((.*)\)$/s.exec(image)?.[1] : undefined;
75
+ if (gradient === undefined) {
76
+ return undefined;
77
+ }
78
+ const args = splitTopLevel(gradient);
79
+ const first = args[0] ?? "";
80
+ const direction = cssAngle(first);
81
+ if (/\bin\s/.test(first) || (direction === undefined && !colorOf(first))) {
82
+ return undefined;
83
+ }
84
+ // CSS's 0deg points up and turns clockwise; DrawingML's 0 points right, and turns so too.
85
+ const css = direction ?? 180;
86
+ const turned = ((css % 360) + 360) % 360;
87
+ if (Math.abs(turned / 90 - Math.round(turned / 90)) > 1e-6) {
88
+ return undefined;
89
+ }
90
+ const along = Math.round(turned / 90) % 2 === 0 ? size.height : size.width;
91
+ const stops = placeStops(direction === undefined ? args : args.slice(1), along);
92
+ if (!stops || stops.length < 2 || stops.some((stop) => stop.at < 0 || stop.at > 1)) {
93
+ return undefined;
94
+ }
95
+ return { kind: "linear", angle: (Math.round(turned) + 270) % 360, stops };
96
+ }
97
+
98
+ /** A gradient's direction in CSS degrees, or none when the argument is not one. */
99
+ function cssAngle(arg: string): number | undefined {
100
+ const sides: Record<string, number> = { top: 0, right: 90, bottom: 180, left: 270 };
101
+ const side = /^to\s+(top|right|bottom|left)$/.exec(arg)?.[1];
102
+ if (side !== undefined) {
103
+ return sides[side];
104
+ }
105
+ const angle = /^(-?[\d.]+)(deg|turn|rad|grad)$/.exec(arg);
106
+ if (!angle) {
107
+ return undefined;
108
+ }
109
+ const per: Record<string, number> = { deg: 1, turn: 360, rad: 180 / Math.PI, grad: 0.9 };
110
+ return Number(angle[1]) * (per[angle[2] ?? "deg"] ?? 1);
111
+ }
112
+
113
+ /**
114
+ * The stops at 0..1 along the gradient, as CSS places them: the first at the start and the
115
+ * last at the end unless placed, one behind another moved up to it, and the unplaced spread
116
+ * evenly between their neighbors. None when a stop is not an opaque color, or a hint or a
117
+ * length is there that DrawingML has no place for.
118
+ */
119
+ function placeStops(
120
+ args: string[],
121
+ along: number,
122
+ ): Array<{ at: number; paint: PptxPaint }> | undefined {
123
+ const stops: Array<{ at: number | undefined; paint: PptxPaint }> = [];
124
+ for (const arg of args) {
125
+ const color = colorOf(arg);
126
+ if (!color) {
127
+ return undefined;
128
+ }
129
+ const lengths = arg.slice(color.written.length).trim().split(/\s+/).filter(Boolean);
130
+ if (lengths.length > 2 || color.paint.alpha !== 1) {
131
+ return undefined;
132
+ }
133
+ const at = lengths.map((length) => lengthAt(length, along));
134
+ if (at.some((each) => each === undefined)) {
135
+ return undefined;
136
+ }
137
+ if (at.length === 0) {
138
+ stops.push({ at: undefined, paint: color.paint });
139
+ }
140
+ for (const each of at) {
141
+ stops.push({ at: each, paint: color.paint });
142
+ }
143
+ }
144
+ const [head, tail] = [stops[0], stops.at(-1)];
145
+ if (head && head.at === undefined) {
146
+ head.at = 0;
147
+ }
148
+ if (tail && tail.at === undefined) {
149
+ tail.at = 1;
150
+ }
151
+ let furthest = Number.NEGATIVE_INFINITY;
152
+ for (const stop of stops) {
153
+ if (stop.at !== undefined) {
154
+ stop.at = Math.max(stop.at, furthest);
155
+ furthest = stop.at;
156
+ }
157
+ }
158
+ for (let index = 0; index < stops.length; index++) {
159
+ if (stops[index]?.at !== undefined) {
160
+ continue;
161
+ }
162
+ const from = index - 1;
163
+ let to = index;
164
+ while (stops[to]?.at === undefined) {
165
+ to++;
166
+ }
167
+ const start = stops[from]?.at ?? 0;
168
+ const end = stops[to]?.at ?? 1;
169
+ for (let gap = index; gap < to; gap++) {
170
+ const stop = stops[gap];
171
+ if (stop) {
172
+ stop.at = start + ((end - start) * (gap - from)) / (to - from);
173
+ }
174
+ }
175
+ }
176
+ return stops.map((stop) => ({ at: stop.at ?? 0, paint: stop.paint }));
177
+ }
178
+
179
+ function lengthAt(length: string, along: number): number | undefined {
180
+ const value = /^(-?[\d.]+)(%|px)$/.exec(length);
181
+ if (!value) {
182
+ return undefined;
183
+ }
184
+ const number = Number(value[1]);
185
+ return value[2] === "%" ? number / 100 : along > 0 ? number / along : undefined;
186
+ }
187
+
188
+ /** A stop's color as Chromium writes an sRGB one, `rgb()` or `rgba()`, and what it took. */
189
+ function colorOf(arg: string): { paint: PptxPaint; written: string } | undefined {
190
+ const match =
191
+ /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)/.exec(arg);
192
+ if (!match) {
193
+ return undefined;
194
+ }
195
+ const [written, r = "0", g = "0", b = "0", a = "1"] = match;
196
+ const alpha = a.endsWith("%") ? Number(a.slice(0, -1)) / 100 : Number(a);
197
+ const channel = (value: string) => Math.max(0, Math.min(255, Math.round(Number(value))));
198
+ return {
199
+ paint: { color: [channel(r), channel(g), channel(b)], alpha },
200
+ written,
201
+ };
202
+ }
203
+
204
+ /** The arguments of a function, split at the commas outside any parentheses. */
205
+ function splitTopLevel(text: string): string[] {
206
+ const args: string[] = [];
207
+ let depth = 0;
208
+ let start = 0;
209
+ for (let at = 0; at < text.length; at++) {
210
+ const char = text[at];
211
+ if (char === "(") {
212
+ depth++;
213
+ } else if (char === ")") {
214
+ depth--;
215
+ } else if (char === "," && depth === 0) {
216
+ args.push(text.slice(start, at).trim());
217
+ start = at + 1;
218
+ }
219
+ }
220
+ args.push(text.slice(start).trim());
221
+ return args;
222
+ }