@hajimism/dek 0.3.3 → 0.5.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 (105) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.ja.md +2 -2
  3. package/README.md +2 -2
  4. package/package.json +5 -3
  5. package/skills/dek/SKILL.md +10 -3
  6. package/skills/dek-design/SKILL.md +42 -0
  7. package/src/cli/address.ts +95 -18
  8. package/src/cli/build.ts +2 -2
  9. package/src/cli/check.ts +2 -1
  10. package/src/cli/commands.ts +57 -19
  11. package/src/cli/contract.ts +58 -4
  12. package/src/cli/files.ts +2 -5
  13. package/src/cli/flags.ts +1 -1
  14. package/src/cli/format.ts +5 -4
  15. package/src/cli/goto.ts +44 -11
  16. package/src/cli/init.ts +10 -5
  17. package/src/cli/lint.ts +36 -16
  18. package/src/cli/ls.ts +5 -1
  19. package/src/cli/main.ts +51 -14
  20. package/src/cli/new.ts +28 -8
  21. package/src/cli/pptx.ts +9 -4
  22. package/src/cli/ref.ts +92 -9
  23. package/src/cli/result.ts +47 -6
  24. package/src/cli/scope.ts +57 -29
  25. package/src/cli/shot.ts +78 -6
  26. package/src/cli/show.ts +15 -2
  27. package/src/cli/text.ts +88 -11
  28. package/src/cli/theme.ts +58 -10
  29. package/src/cli/tty.ts +16 -2
  30. package/src/cli/voice.ts +15 -9
  31. package/src/core/alignment.ts +320 -0
  32. package/src/core/annotation-rows.ts +16 -4
  33. package/src/core/annotations.ts +19 -1
  34. package/src/core/assets.ts +18 -10
  35. package/src/core/attribute-value.ts +188 -0
  36. package/src/core/build.ts +3 -3
  37. package/src/core/config.ts +61 -12
  38. package/src/core/css-scan.ts +173 -39
  39. package/src/core/css-transform.ts +57 -11
  40. package/src/core/css.ts +19 -13
  41. package/src/core/diagnostic.ts +5 -1
  42. package/src/core/escape.ts +43 -0
  43. package/src/core/foreign-text.ts +138 -0
  44. package/src/core/github.ts +1 -0
  45. package/src/core/html-entities.ts +318 -0
  46. package/src/core/html-scan.ts +49 -27
  47. package/src/core/html.ts +21 -15
  48. package/src/core/line-break.ts +275 -0
  49. package/src/core/lint/asset-refs.ts +50 -8
  50. package/src/core/lint/files.ts +30 -5
  51. package/src/core/lint/keys.ts +7 -3
  52. package/src/core/lint/slide-html.ts +24 -19
  53. package/src/core/lint/slide-script.ts +7 -5
  54. package/src/core/mv.ts +57 -27
  55. package/src/core/order.ts +14 -0
  56. package/src/core/pack.ts +2 -2
  57. package/src/core/path.ts +8 -2
  58. package/src/core/playwright-module.d.ts +13 -1
  59. package/src/core/playwright-motion.ts +1 -0
  60. package/src/core/playwright-sheet.ts +1 -0
  61. package/src/core/playwright-visual.ts +207 -30
  62. package/src/core/playwright.ts +279 -44
  63. package/src/core/pptx-package.ts +461 -33
  64. package/src/core/pptx-paint.ts +1168 -0
  65. package/src/core/pptx-plan.ts +879 -0
  66. package/src/core/pptx-text.ts +204 -47
  67. package/src/core/pptx-theme.ts +222 -0
  68. package/src/core/pptx.ts +56 -8
  69. package/src/core/prose.ts +23 -0
  70. package/src/core/pseudo-text.ts +32 -10
  71. package/src/core/ref-name.ts +52 -8
  72. package/src/core/ref.ts +308 -54
  73. package/src/core/resolve.ts +23 -11
  74. package/src/core/rumdl.ts +51 -25
  75. package/src/core/safe-fs.ts +19 -2
  76. package/src/core/sandbox.ts +217 -0
  77. package/src/core/shot/morph.ts +1 -1
  78. package/src/core/shot/motion.ts +2 -3
  79. package/src/core/shot/still.ts +2 -2
  80. package/src/core/shot-cache.ts +26 -17
  81. package/src/core/slide-eval-worker.ts +1 -0
  82. package/src/core/slide-measure.ts +51 -1
  83. package/src/core/slide-script-eval.ts +12 -8
  84. package/src/core/slide-script.ts +114 -20
  85. package/src/core/spawn.ts +131 -32
  86. package/src/core/sync.ts +5 -14
  87. package/src/core/tar.ts +315 -0
  88. package/src/core/text-contrast.ts +2 -0
  89. package/src/core/toml-keys.ts +116 -51
  90. package/src/core/url-attributes.ts +27 -9
  91. package/src/core/visual-cache.ts +2 -0
  92. package/src/core/visual.ts +182 -24
  93. package/src/core/voice.ts +5 -6
  94. package/src/core/zod.ts +9 -1
  95. package/src/server/auth.ts +21 -0
  96. package/src/server/dev.ts +26 -7
  97. package/src/server/lock.ts +50 -7
  98. package/src/server/routes.ts +7 -2
  99. package/src/server/serial.ts +8 -2
  100. package/src/theme/default.css +4 -0
  101. package/src/video/bake.ts +6 -2
  102. package/src/video/mux.ts +27 -3
  103. package/src/video/recorder.ts +3 -2
  104. package/src/video/worker.ts +1 -0
  105. package/src/voice/engine.ts +81 -18
package/src/core/mv.ts CHANGED
@@ -1,10 +1,18 @@
1
- import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
1
+ // biome-ignore lint/style/noRestrictedImports: renameSync and unlinkSync, for mv's steps, each undone on failure; safe-fs does not rename.
2
+ import { existsSync, renameSync, unlinkSync } from "node:fs";
2
3
  import { basename } from "node:path";
3
4
  import { deckPaths } from "./deck-paths.ts";
4
5
  import { DekError } from "./error.ts";
5
- import { escapeRegExp } from "./escape.ts";
6
+ import { escapeRegExp, isPlainWord, shellName } from "./escape.ts";
6
7
  import { joinLines, sectionChunks, splitLines } from "./lines.ts";
7
- import { asResolvedDeck, type ResolvedDeck, requireSection, SLIDE_SIDECARS } from "./resolve.ts";
8
+ import {
9
+ asResolvedDeck,
10
+ type ResolvedDeck,
11
+ readDeckFile,
12
+ requireSection,
13
+ SLIDE_SIDECARS,
14
+ } from "./resolve.ts";
15
+ import { writeInside } from "./safe-fs.ts";
8
16
  import { Id } from "./schema.ts";
9
17
  import { deckLayouts, skeletonHtml } from "./skeleton.ts";
10
18
  import { skeletonRecord } from "./skeleton-record.ts";
@@ -16,11 +24,11 @@ export function renameSection(input: string | ResolvedDeck, from: string, to: st
16
24
  if (!Id.safeParse(to).success) {
17
25
  throw new DekError(`invalid id "${to}"`, { hint: "use [a-z0-9-] with at least one letter" });
18
26
  }
19
- const { deck } = asResolvedDeck(input);
27
+ const { project, deck } = asResolvedDeck(input);
20
28
  const has = (slug: string): boolean => deck.deck.sections.some((entry) => entry.slug === slug);
21
29
  if (!has(from) && has(to)) {
22
30
  // script.md was edited first; only the files still carry the old id.
23
- const steps = slideFileSteps(deck, from, to);
31
+ const steps = slideFileSteps(project.root, deck, from, to);
24
32
  if (steps.length === 0 && !existsSync(deckPaths(deck.dir).slide(to, ".html"))) {
25
33
  throw new DekError(`slide "${from}" not found`, {
26
34
  path: deckPaths(deck.dir).slide(from, ".html"),
@@ -38,7 +46,7 @@ export function renameSection(input: string | ResolvedDeck, from: string, to: st
38
46
  });
39
47
  }
40
48
 
41
- const source = readFileSync(deck.scriptPath, "utf8");
49
+ const source = readDeckFile(deck.dir, deck.scriptPath) ?? "";
42
50
  const lines = splitLines(source);
43
51
  const headingIndex = section.line - 1;
44
52
  const heading = lines[headingIndex];
@@ -50,8 +58,8 @@ export function renameSection(input: string | ResolvedDeck, from: string, to: st
50
58
  }
51
59
  lines[headingIndex] = rewriteHeadingId(heading, from, to);
52
60
  applySteps([
53
- writeStep(deck.scriptPath, source, joinLines(source, lines)),
54
- ...slideFileSteps(deck, from, to),
61
+ writeStep(project.root, deck.scriptPath, source, joinLines(source, lines)),
62
+ ...slideFileSteps(project.root, deck, from, to),
55
63
  ]);
56
64
  }
57
65
 
@@ -63,16 +71,22 @@ export function renameSection(input: string | ResolvedDeck, from: string, to: st
63
71
  * the author wrote, one under each id, stop the rename; everything that could stop it is worked
64
72
  * out before any file changes.
65
73
  */
66
- function slideFileSteps(deck: ResolvedDeck["deck"], from: string, to: string): FileStep[] {
74
+ function slideFileSteps(
75
+ root: string,
76
+ deck: ResolvedDeck["deck"],
77
+ from: string,
78
+ to: string,
79
+ ): FileStep[] {
67
80
  const paths = deckPaths(deck.dir);
68
81
  const layouts = deckLayouts(deck.dir);
69
82
  const record = skeletonRecord(deck.dir);
70
83
  const read = (slug: string): SlideFile | undefined => {
71
84
  const path = paths.slide(slug, ".html");
72
- if (!existsSync(path)) {
85
+ // What is read here may be written under the other id: a link out of the project is refused.
86
+ const html = readDeckFile(deck.dir, path);
87
+ if (html === undefined) {
73
88
  return undefined;
74
89
  }
75
- const html = readFileSync(path, "utf8");
76
90
  return { path, html, authored: !record.owns(html, skeletonHtml(deck.deck, slug, layouts)) };
77
91
  };
78
92
  const source = read(from);
@@ -93,16 +107,16 @@ function slideFileSteps(deck: ResolvedDeck["deck"], from: string, to: string): F
93
107
  const kept = source && (source.authored || !target) ? source : target;
94
108
  if (source && kept === source) {
95
109
  steps.push(
96
- target ? writeStep(toPath, target.html, source.html) : renameStep(source.path, toPath),
110
+ target ? writeStep(root, toPath, target.html, source.html) : renameStep(source.path, toPath),
97
111
  );
98
112
  }
99
113
  if (source && target) {
100
- steps.push(removeStep(source.path, source.html));
114
+ steps.push(removeStep(root, source.path, source.html));
101
115
  }
102
116
  if (kept) {
103
117
  const next = relabel(kept.html, from, to);
104
118
  if (next !== kept.html) {
105
- steps.push(writeStep(toPath, kept.html, next));
119
+ steps.push(writeStep(root, toPath, kept.html, next));
106
120
  }
107
121
  }
108
122
  for (const ext of SLIDE_SIDECARS) {
@@ -112,7 +126,7 @@ function slideFileSteps(deck: ResolvedDeck["deck"], from: string, to: string): F
112
126
  }
113
127
  }
114
128
  if (voice) {
115
- steps.push(writeStep(voice.path, voice.source, voice.next));
129
+ steps.push(writeStep(root, voice.path, voice.source, voice.next));
116
130
  }
117
131
  return steps;
118
132
  }
@@ -131,23 +145,39 @@ function bothHold(fromPath: string, toPath: string, from: string, to: string): D
131
145
  const what = fromPath.endsWith(".html") ? "hold a slide you wrote" : "exist";
132
146
  return new DekError(`${fromName} and ${toName} both ${what}`, {
133
147
  path: fromPath,
134
- hint: `merge them into ${toName} and remove ${fromName}, then run \`dekc mv ${from} ${to}\` again`,
148
+ hint: `merge them into ${toName} and remove ${isPlainWord(from) ? fromName : "the other"}, then run ${again(from, to)}`,
135
149
  });
136
150
  }
137
151
 
138
- /** One file change `dekc mv` makes, with how to take it back. */
152
+ /**
153
+ * The rename to run again: as a command when each name has a word every shell reads alike, after
154
+ * `--` when one starts with a dash; else as the one that was run.
155
+ */
156
+ function again(from: string, to: string): string {
157
+ const [source, target] = [shellName(from), shellName(to)];
158
+ if (source === undefined || target === undefined) {
159
+ return "that `dekc mv` again";
160
+ }
161
+ const end = from.startsWith("-") || to.startsWith("-") ? "-- " : "";
162
+ return `\`dekc mv ${end}${source} ${target}\` again`;
163
+ }
164
+
165
+ /**
166
+ * One file change `dekc mv` makes, with how to take it back. A write goes to a new file renamed
167
+ * into place, so a crash part way leaves the old file or the new one, never half of one.
168
+ */
139
169
  type FileStep = { path: string; apply: () => void; undo: () => void };
140
170
 
141
- function writeStep(path: string, before: string, after: string): FileStep {
171
+ function writeStep(root: string, path: string, before: string, after: string): FileStep {
142
172
  return {
143
173
  path,
144
- apply: () => writeFileSync(path, after),
145
- undo: () => writeFileSync(path, before),
174
+ apply: () => writeInside(path, after, root),
175
+ undo: () => writeInside(path, before, root),
146
176
  };
147
177
  }
148
178
 
149
- function removeStep(path: string, before: string): FileStep {
150
- return { path, apply: () => unlinkSync(path), undo: () => writeFileSync(path, before) };
179
+ function removeStep(root: string, path: string, before: string): FileStep {
180
+ return { path, apply: () => unlinkSync(path), undo: () => writeInside(path, before, root) };
151
181
  }
152
182
 
153
183
  function renameStep(from: string, to: string): FileStep {
@@ -192,10 +222,10 @@ function planVoiceBeatKeys(
192
222
  to: string,
193
223
  ): { path: string; source: string; next: string } | undefined {
194
224
  const path = deckPaths(deckDir).voiceToml;
195
- if (!existsSync(path)) {
225
+ const source = readDeckFile(deckDir, path);
226
+ if (source === undefined) {
196
227
  return undefined;
197
228
  }
198
- const source = readFileSync(path, "utf8");
199
229
  let parsed: Record<string, unknown>;
200
230
  try {
201
231
  parsed = Bun.TOML.parse(source) as Record<string, unknown>;
@@ -248,7 +278,7 @@ export function reorderSection(
248
278
  place: SectionPlace,
249
279
  ): void {
250
280
  const target = "before" in place ? place.before : place.after;
251
- const { deck } = asResolvedDeck(input);
281
+ const { project, deck } = asResolvedDeck(input);
252
282
  const sections = deck.deck.sections;
253
283
  const fromIndex = sections.findIndex((entry) => entry.slug === slug);
254
284
  if (fromIndex < 0) {
@@ -259,7 +289,7 @@ export function reorderSection(
259
289
  requireSection(deck, target);
260
290
  }
261
291
 
262
- const source = readFileSync(deck.scriptPath, "utf8");
292
+ const source = readDeckFile(deck.dir, deck.scriptPath) ?? "";
263
293
  const lines = splitLines(source);
264
294
  const firstLine = sections[0]?.line;
265
295
  if (firstLine === undefined) {
@@ -281,7 +311,7 @@ export function reorderSection(
281
311
  });
282
312
  const insertAt = "before" in place ? remainingTarget : remainingTarget + 1;
283
313
  chunks.splice(insertAt, 0, moved);
284
- writeFileSync(deck.scriptPath, joinLines(source, [...head, ...chunks.flat()]));
314
+ writeInside(deck.scriptPath, joinLines(source, [...head, ...chunks.flat()]), project.root);
285
315
  }
286
316
 
287
317
  function rewriteHeadingId(heading: string, from: string, to: string): string {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Orders two strings by their UTF-16 code units, as `Array.prototype.sort` does with no
3
+ * comparator, so every list dek sorts comes out in one order, the same on every machine.
4
+ *
5
+ * `localeCompare` with no locale follows the machine's, so a teammate set to Swedish or Turkish
6
+ * would write dict.toml in another order. A fixed locale is no cure: its collation comes from
7
+ * the ICU the runtime has, which Bun takes from the system on macOS and bundles on Linux, and it
8
+ * counts a precomposed `ä` and an `a` with a combining mark as equal, leaving their order to
9
+ * whichever came first. Code units cost a dictionary's order: `B` comes before `a`, `ä` after
10
+ * `z`, and Japanese reads hiragana, then katakana, then kanji by code point.
11
+ */
12
+ export function compareCodeUnits(a: string, b: string): number {
13
+ return a < b ? -1 : a > b ? 1 : 0;
14
+ }
package/src/core/pack.ts CHANGED
@@ -5,7 +5,7 @@ import { DekError } from "./error.ts";
5
5
  import { licenseText } from "./licenses.ts";
6
6
  import { type DistOptions, distFile } from "./path.ts";
7
7
  import { keepsInDeck, LICENSE_NAMES, MAX_SNAPSHOT_BYTES } from "./ref.ts";
8
- import { isRefDeckName } from "./ref-name.ts";
8
+ import { isRefDeckName, REF_DECK_NAME_RULE } from "./ref-name.ts";
9
9
  import { followInside, requireInside, writeInside } from "./safe-fs.ts";
10
10
 
11
11
  /**
@@ -22,7 +22,7 @@ export async function packDeck(
22
22
  if (!isRefDeckName(deck.name)) {
23
23
  throw new DekError(`deck "${deck.name}" cannot be read as a ref by that name`, {
24
24
  path: deck.dir,
25
- hint: "a ref names its deck with no space or @; rename the deck's folder, e.g. to 2026-10-talk, and pack it again",
25
+ hint: `${REF_DECK_NAME_RULE}; rename the deck's folder, e.g. to 2026-10-talk, and pack it again`,
26
26
  });
27
27
  }
28
28
  // Each file by its path in the pack and where it really is, measured before any is read.
package/src/core/path.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
+ import { holdsHidden } from "./foreign-text.ts";
3
4
 
4
5
  export function moduleFilePath(url: string | URL): string {
5
6
  return fileURLToPath(url);
@@ -16,8 +17,12 @@ export function deckProjectRoot(deckDir: string): string {
16
17
  }
17
18
 
18
19
  export const DECK_NAME_HINT =
19
- 'use a plain name like 2026-10-talk: no "/" or "\\", no control characters, no leading "-"';
20
+ 'use a plain name like 2026-10-talk: no "/" or "\\", no control character or one a person cannot see, no leading "-"';
20
21
 
22
+ /**
23
+ * A name `dekc new` and `dekc init` may give a deck. A pack's ref takes its deck's name, so, as
24
+ * `dekc pack` does, these refuse a control character and one a person cannot see.
25
+ */
21
26
  export function isDeckName(name: string): boolean {
22
27
  return (
23
28
  name.length > 0 &&
@@ -28,7 +33,8 @@ export function isDeckName(name: string): boolean {
28
33
  // A leading "-" reads as a flag to every tool the name is passed to.
29
34
  !name.startsWith("-") &&
30
35
  // biome-ignore lint/suspicious/noControlCharactersInRegex: a control in a name is the problem.
31
- !/[\x00-\x1f\x7f]/.test(name)
36
+ !/[\x00-\x1f\x7f-\x9f]/.test(name) &&
37
+ !holdsHidden(name)
32
38
  );
33
39
  }
34
40
 
@@ -15,6 +15,7 @@ declare module "playwright" {
15
15
  clip?: { x: number; y: number; width: number; height: number };
16
16
  type?: "png" | "jpeg";
17
17
  quality?: number;
18
+ omitBackground?: boolean;
18
19
  }): Promise<Buffer>;
19
20
  addScriptTag(options: { content: string }): Promise<unknown>;
20
21
  pdf(options: {
@@ -26,6 +27,11 @@ declare module "playwright" {
26
27
  }): Promise<Buffer>;
27
28
  close(): Promise<void>;
28
29
  context(): BrowserContext;
30
+ mainFrame(): Frame;
31
+ frames(): Frame[];
32
+ };
33
+ export type Frame = {
34
+ url(): string;
29
35
  };
30
36
  /** A DevTools protocol session: Chromium's own answers, for what the page does not expose. */
31
37
  export type CDPSession = {
@@ -43,10 +49,16 @@ declare module "playwright" {
43
49
  viewport?: Viewport;
44
50
  deviceScaleFactor?: number;
45
51
  offline?: boolean;
52
+ httpCredentials?: { username: string; password: string };
46
53
  }): Promise<BrowserContext>;
54
+ newBrowserCDPSession(): Promise<CDPSession>;
47
55
  close(): Promise<void>;
48
56
  };
49
57
  export const chromium: {
50
- launch(options?: { headless?: boolean; chromiumSandbox?: boolean }): Promise<Browser>;
58
+ launch(options?: {
59
+ headless?: boolean;
60
+ chromiumSandbox?: boolean;
61
+ executablePath?: string;
62
+ }): Promise<Browser>;
51
63
  };
52
64
  }
@@ -1,3 +1,4 @@
1
+ // biome-ignore lint/style/noRestrictedImports: mkdirSync, in a worker, for the folder its parent chose in dek's cache, which Chromium writes into.
1
2
  import { mkdirSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import type { Page } from "playwright";
@@ -1,3 +1,4 @@
1
+ // biome-ignore lint/style/noRestrictedImports: mkdirSync, in a worker, for the folder its parent chose in dek's cache, which Chromium writes into.
1
2
  import { mkdirSync, readFileSync } from "node:fs";
2
3
  import { dirname } from "node:path";
3
4
  import type { Page } from "playwright";
@@ -1,7 +1,9 @@
1
1
  import type { Browser, BrowserContext, Page } from "playwright";
2
+ import { findMisalignments } from "./alignment.ts";
2
3
  import { freezeTransition, loadVideoDoc } from "./capture-go.ts";
3
4
  import { measureFill } from "./fill.ts";
4
5
  import { finishBeat } from "./finish-beat.ts";
6
+ import { findLineBreaks, measureTextFlowsInPage } from "./line-break.ts";
5
7
  import {
6
8
  crossesEdge,
7
9
  findClippedText,
@@ -31,6 +33,7 @@ import type {
31
33
  PagesRequest,
32
34
  PagesResponse,
33
35
  PdfRequest,
36
+ PptxPictureFile,
34
37
  PptxRequest,
35
38
  PptxResponse,
36
39
  ResponseTo,
@@ -39,8 +42,24 @@ import type {
39
42
  } from "./playwright.ts";
40
43
  import { captureMotion } from "./playwright-motion.ts";
41
44
  import { renderSheets } from "./playwright-sheet.ts";
45
+ import { mapPictures } from "./pptx-package.ts";
46
+ import {
47
+ describePptxPicturesInPage,
48
+ hidePptxLiftedInPage,
49
+ measurePptxPaintInPage,
50
+ measurePptxThemeInPage,
51
+ PPTX_PSEUDO_ATTR,
52
+ pptxFontProbesInPage,
53
+ pptxPictureEmptyInPage,
54
+ pptxSvgInPage,
55
+ SVG_CHECKED,
56
+ SVG_PAINT,
57
+ showOnlyPptxNodeInPage,
58
+ } from "./pptx-paint.ts";
59
+ import { planPptxLayers, pptxPageFacts } from "./pptx-plan.ts";
42
60
  import { measurePptxTextsInPage, PPTX_OWNER_ATTR } from "./pptx-text.ts";
43
- import { pseudoTextBoxes } from "./pseudo-text.ts";
61
+ import type { PptxPageTheme } from "./pptx-theme.ts";
62
+ import { pseudoElementBoxes, pseudoTextBoxes } from "./pseudo-text.ts";
44
63
  import { motionSheets } from "./sheet.ts";
45
64
  import {
46
65
  type Box,
@@ -133,7 +152,10 @@ async function printPdf(context: BrowserContext, request: PdfRequest): Promise<D
133
152
  return {};
134
153
  }
135
154
 
136
- /** Each slide's text measured and taken out, and the slide shot without it, page by page. */
155
+ /**
156
+ * Each slide's text and paint measured; what is laid over the picture planned, each picture
157
+ * drawn alone, and the slide shot without what was laid over it, page by page.
158
+ */
137
159
  async function shootPptx(context: BrowserContext, request: PptxRequest): Promise<PptxResponse> {
138
160
  const slides = await visitInPages(
139
161
  request.pages,
@@ -141,19 +163,86 @@ async function shootPptx(context: BrowserContext, request: PptxRequest): Promise
141
163
  async (page, pageReq) => {
142
164
  await page.setContent(pageReq.html, { waitUntil: "load" });
143
165
  await page.evaluate(() => document.fonts.ready.then(() => undefined));
144
- const measured = await page.evaluate(measurePptxTextsInPage);
166
+ const paint = {
167
+ ...(await page.evaluate(measurePptxPaintInPage)),
168
+ described: await page.evaluate(describePptxPicturesInPage),
169
+ };
170
+ const texts = await page.evaluate(measurePptxTextsInPage);
145
171
  const fonts = await platformFonts(page);
146
- await page.screenshot({ path: pageReq.screenshotPath, fullPage: false });
172
+ const placed = await pseudoElementBoxes(page, PPTX_PSEUDO_ATTR, "border", [
173
+ "before",
174
+ "after",
175
+ "marker",
176
+ ]);
177
+ for (const run of [
178
+ ...texts.boxes.flatMap((box) => box.runs),
179
+ ...texts.blocks.flatMap((block) => block.runs),
180
+ ]) {
181
+ const font = fonts.get(run.owner);
182
+ if (font !== undefined) {
183
+ run.font = font;
184
+ }
185
+ }
186
+ const plan = planPptxLayers(pptxPageFacts(paint, texts, placed));
187
+
188
+ const files = new Map<number, PptxPictureFile>();
189
+ for (const [index, node] of plan.lifted.pictures.entries()) {
190
+ const measured = paint.nodes[node];
191
+ const rect =
192
+ (measured?.paint?.kind === "picture" ? measured.paint.extent : undefined) ??
193
+ measured?.rect;
194
+ if (!rect) {
195
+ continue;
196
+ }
197
+ const svg = await page.evaluate(pptxSvgInPage, {
198
+ node,
199
+ paint: SVG_PAINT,
200
+ checked: SVG_CHECKED,
201
+ });
202
+ const png = pageReq.screenshotPath.replace(/\.png$/, `-${index + 1}.png`);
203
+ await page.evaluate(showOnlyPptxNodeInPage, node);
204
+ await page.screenshot({
205
+ path: png,
206
+ omitBackground: true,
207
+ clip: {
208
+ x: paint.origin.left + rect.left,
209
+ y: paint.origin.top + rect.top,
210
+ width: rect.right - rect.left,
211
+ height: rect.bottom - rect.top,
212
+ },
213
+ });
214
+ await page.evaluate(showOnlyPptxNodeInPage, null);
215
+ files.set(node, svg === undefined ? { png } : { png, svg });
216
+ }
217
+ const layers = mapPictures(plan.layers, ({ node, ...picture }) => ({
218
+ ...picture,
219
+ ...(files.get(node) ?? { png: "" }),
220
+ }));
221
+ // With the background PowerPoint's, the slide is shot on nothing, and a picture with
222
+ // nothing left in it is no picture.
223
+ const { background } = plan;
224
+ await page.evaluate(hidePptxLiftedInPage, {
225
+ ...plan.lifted,
226
+ background: background !== undefined,
227
+ });
228
+ const shot = await page.screenshot({
229
+ path: pageReq.screenshotPath,
230
+ fullPage: false,
231
+ ...(background ? { omitBackground: true } : {}),
232
+ });
233
+ const empty =
234
+ background !== undefined &&
235
+ (await page.evaluate(
236
+ pptxPictureEmptyInPage,
237
+ `data:image/png;base64,${shot.toString("base64")}`,
238
+ ));
147
239
  return {
148
240
  slug: pageReq.slug,
149
- description: measured.description,
150
- boxes: measured.boxes.map((box) => ({
151
- ...box,
152
- runs: box.runs.map(({ owner, ...run }) => {
153
- const font = fonts.get(owner);
154
- return font === undefined ? run : { ...run, font };
155
- }),
156
- })),
241
+ description: plan.description,
242
+ layers,
243
+ picture: !empty,
244
+ ...(background ? { background } : {}),
245
+ theme: await pageTheme(page),
157
246
  };
158
247
  },
159
248
  );
@@ -161,12 +250,44 @@ async function shootPptx(context: BrowserContext, request: PptxRequest): Promise
161
250
  }
162
251
 
163
252
  /**
164
- * The font Chromium drew each marked element's text with, by the element's number: the one that
165
- * drew most of its glyphs, as the DevTools protocol reports it. A theme names a stack, or only
166
- * `sans-serif`; this is the face that was found.
253
+ * What the slide says of the deck's theme: its color tokens, and each font token as the
254
+ * `font-family` it is and the faces Chromium drew Latin and East Asian text in with it. Measured
255
+ * after the slide's last shot, since it puts text in the page to measure.
167
256
  */
257
+ async function pageTheme(page: Page): Promise<PptxPageTheme> {
258
+ const { colors, families } = await page.evaluate(measurePptxThemeInPage);
259
+ await page.evaluate(pptxFontProbesInPage, true);
260
+ const faces = await platformFaces(page, "data-dek-pptx-probe");
261
+ await page.evaluate(pptxFontProbesInPage, false);
262
+ const fonts: PptxPageTheme["fonts"] = {};
263
+ for (const token of ["title", "body"] as const) {
264
+ const family = families[token];
265
+ const latin = faces.get(`${token}-latin`);
266
+ if (family !== undefined && latin !== undefined) {
267
+ fonts[token] = { family, latin, ea: faces.get(`${token}-ea`) ?? latin };
268
+ }
269
+ }
270
+ return { colors, fonts };
271
+ }
272
+
273
+ /** The font Chromium drew each marked element's text with, by the element's number. */
168
274
  async function platformFonts(page: Page): Promise<Map<number, string>> {
169
275
  const fonts = new Map<number, string>();
276
+ for (const [owner, face] of await platformFaces(page, PPTX_OWNER_ATTR)) {
277
+ if (Number.isInteger(Number(owner))) {
278
+ fonts.set(Number(owner), face);
279
+ }
280
+ }
281
+ return fonts;
282
+ }
283
+
284
+ /**
285
+ * The font Chromium drew each element marked with `attr` in, by the attribute's value: the one
286
+ * that drew most of its glyphs, as the DevTools protocol reports it. A theme names a stack, or
287
+ * only `sans-serif`; this is the face that was found.
288
+ */
289
+ async function platformFaces(page: Page, attr: string): Promise<Map<string, string>> {
290
+ const fonts = new Map<string, string>();
170
291
  const cdp = await page.context().newCDPSession(page);
171
292
  try {
172
293
  await cdp.send("DOM.enable");
@@ -176,19 +297,19 @@ async function platformFonts(page: Page): Promise<Map<number, string>> {
176
297
  };
177
298
  const { nodeIds } = (await cdp.send("DOM.querySelectorAll", {
178
299
  nodeId: root.nodeId,
179
- selector: `[${PPTX_OWNER_ATTR}]`,
300
+ selector: `[${attr}]`,
180
301
  })) as { nodeIds: number[] };
181
302
  for (const nodeId of nodeIds) {
182
303
  const { attributes } = (await cdp.send("DOM.getAttributes", { nodeId })) as {
183
304
  attributes: string[];
184
305
  };
185
- const owner = Number(attributes[attributes.indexOf(PPTX_OWNER_ATTR) + 1]);
306
+ const value = attributes[attributes.indexOf(attr) + 1];
186
307
  const { fonts: used } = (await cdp.send("CSS.getPlatformFontsForNode", { nodeId })) as {
187
308
  fonts: Array<{ familyName: string; glyphCount: number }>;
188
309
  };
189
310
  const main = [...used].sort((a, b) => b.glyphCount - a.glyphCount)[0];
190
- if (main && Number.isInteger(owner)) {
191
- fonts.set(owner, main.familyName);
311
+ if (main && value !== undefined) {
312
+ fonts.set(value, main.familyName);
192
313
  }
193
314
  }
194
315
  } finally {
@@ -240,6 +361,8 @@ async function visitPages(context: BrowserContext, request: PagesRequest): Promi
240
361
  drawErrors: found.flatMap((page) => page.drawErrors ?? []),
241
362
  collisions: found.flatMap((page) => page.collisions ?? []),
242
363
  fills: found.flatMap((page) => page.fills ?? []),
364
+ lineBreaks: found.flatMap((page) => page.lineBreaks ?? []),
365
+ misalignments: found.flatMap((page) => page.misalignments ?? []),
243
366
  };
244
367
  }
245
368
 
@@ -343,7 +466,14 @@ async function visitPage(
343
466
  pageReq: VisualPage,
344
467
  { actions, viewport }: Pick<PagesRequest, "actions" | "viewport">,
345
468
  ): Promise<PagesResponse> {
346
- const response: PagesResponse = { overflows: [], contrasts: [], collisions: [], fills: [] };
469
+ const response: PagesResponse = {
470
+ overflows: [],
471
+ contrasts: [],
472
+ collisions: [],
473
+ fills: [],
474
+ lineBreaks: [],
475
+ misalignments: [],
476
+ };
347
477
  const failing: Failing[] = [];
348
478
  const crossing: Crossing[] = [];
349
479
  await page.setContent(pageReq.html, { waitUntil: "load" });
@@ -362,6 +492,36 @@ async function visitPage(
362
492
  if (actions.includes("fill") && frame) {
363
493
  response.fills?.push({ slug, step, ...measureFill(frame, measured.elements) });
364
494
  }
495
+ if (actions.includes("alignment")) {
496
+ for (const { element, other, ...miss } of findMisalignments(measured.elements)) {
497
+ const [one, two] = [measured.elements[element], measured.elements[other]];
498
+ response.misalignments?.push({
499
+ slug,
500
+ step,
501
+ box: one?.box ?? "",
502
+ ...(one?.text ? { text: one.text } : {}),
503
+ other: two?.box ?? "",
504
+ ...(two?.text ? { otherText: two.text } : {}),
505
+ ...miss,
506
+ });
507
+ }
508
+ }
509
+ if (actions.includes("breaks")) {
510
+ const { wordBreak, flows } = await page.evaluate(measureTextFlowsInPage);
511
+ const textWraps = new Map(flows.map((flow) => [flow.element, flow.textWrap]));
512
+ for (const { element, ...found } of findLineBreaks(flows)) {
513
+ const el = measured.elements[element];
514
+ response.lineBreaks?.push({
515
+ slug,
516
+ step,
517
+ box: el?.box ?? "",
518
+ ...(el?.text ? { text: el.text } : {}),
519
+ ...found,
520
+ wordBreak,
521
+ textWrap: textWraps.get(element) ?? "wrap",
522
+ });
523
+ }
524
+ }
365
525
  if (actions.includes("overflow") && frame) {
366
526
  for (const { element, ...overflow } of [
367
527
  ...findOverflows(frame, measured.elements),
@@ -421,13 +581,18 @@ async function visitPage(
421
581
  * measuring again: first what the slide's script drew, then the slide's own CSS. A text that
422
582
  * clears its threshold, or an element that no longer crosses an edge, once a layer is gone is
423
583
  * that layer's to fix. With both gone, a text that still fails is the theme's, and an element that
424
- * still overflows is its content's: too much for the theme's sizes. The page is not put back.
584
+ * still overflows is its content's: too much for the theme's sizes. A text that shows no glyph
585
+ * once a layer is gone is no longer measured as the audience sees it: one the slide's CSS alone
586
+ * shows is marked so, and one the draw alone shows is no layer's unless it still fails with both
587
+ * gone. The page is not put back.
425
588
  */
426
589
  async function attributeFindings(
427
590
  page: Page,
428
591
  pending: { failing: Failing[]; crossing: Crossing[] },
429
592
  ): Promise<void> {
430
593
  let { failing, crossing } = pending;
594
+ // Texts that showed no glyph the last time they were measured.
595
+ const unseen = new Set<Failing>();
431
596
  for (const layer of LAYERS) {
432
597
  if (failing.length === 0 && crossing.length === 0) {
433
598
  return;
@@ -455,20 +620,32 @@ async function attributeFindings(
455
620
  const texts = await pageTexts(page, measured);
456
621
  const contrasts = await textContrasts(page, texts);
457
622
  const now = new Map(texts.map((text, at) => [text.key, { text, contrast: contrasts[at] }]));
458
- failing = failing.filter(({ finding, key }) => {
459
- const measuredText = now.get(key);
460
- if (
461
- measuredText?.contrast &&
462
- measuredText.contrast.ratio >= contrastThreshold(measuredText.text)
463
- ) {
464
- finding.origin = layer.origin;
623
+ failing = failing.filter((entry) => {
624
+ const measuredText = now.get(entry.key);
625
+ const seen = !unseen.has(entry);
626
+ if (!measuredText?.contrast) {
627
+ if (layer.origin === "slide" && seen) {
628
+ entry.finding.onlyWithSlideCss = true;
629
+ return false;
630
+ }
631
+ unseen.add(entry);
632
+ return true;
633
+ }
634
+ if (measuredText.contrast.ratio >= contrastThreshold(measuredText.text)) {
635
+ // Hidden with the layer before gone, it may owe its low contrast to that layer as well.
636
+ if (seen) {
637
+ entry.finding.origin = layer.origin;
638
+ }
465
639
  return false;
466
640
  }
641
+ unseen.delete(entry);
467
642
  return true;
468
643
  });
469
644
  }
470
- for (const { finding } of failing) {
471
- finding.origin = "theme";
645
+ for (const entry of failing) {
646
+ if (!unseen.has(entry)) {
647
+ entry.finding.origin = "theme";
648
+ }
472
649
  }
473
650
  for (const { finding } of crossing) {
474
651
  finding.origin = "content";