@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
@@ -2,13 +2,17 @@ import { existsSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import type { Browser } from "playwright";
5
+ import type { AlignedEdge, Misalignment } from "./alignment.ts";
5
6
  import { DekError } from "./error.ts";
6
7
  import type { Fill } from "./fill.ts";
8
+ import type { LineBreakKind } from "./line-break.ts";
7
9
  import { type Collision, EDGES, type Overflow } from "./overflow.ts";
8
10
  import { moduleFilePath } from "./path.ts";
9
- import type { PptxTextBox } from "./pptx-package.ts";
11
+ import type { PptxFill, PptxLayer, PptxTextBox } from "./pptx-package.ts";
12
+ import type { PptxPageTheme } from "./pptx-theme.ts";
13
+ import { chromiumFailure, launchFailure, runDrawingWorker, type SandboxMode } from "./sandbox.ts";
10
14
  import type { MotionBeat, SheetSpec } from "./sheet.ts";
11
- import { exitWorker, readWorkerRequest, runJsonWorker, workerCommand } from "./spawn.ts";
15
+ import { exitWorker, readWorkerRequest, workerCommand } from "./spawn.ts";
12
16
  import type { Position } from "./step.ts";
13
17
 
14
18
  /** One slide page to measure; a page that names a `screenshotPath` is shot there too. */
@@ -41,7 +45,7 @@ export type MotionSpec = {
41
45
  };
42
46
 
43
47
  /** What to measure on each page; a screenshot is asked for by the page's `screenshotPath`. */
44
- export type PageAction = "overflow" | "contrast" | "fill";
48
+ export type PageAction = "overflow" | "contrast" | "fill" | "breaks" | "alignment";
45
49
 
46
50
  type Viewport = { width: number; height: number };
47
51
 
@@ -84,8 +88,9 @@ export type MotionRequest = {
84
88
  };
85
89
 
86
90
  /**
87
- * Each slide's still page drawn for a PPTX, at twice its size: its text measured line by line,
88
- * then the page shot without it, to `screenshotPath`.
91
+ * Each slide's still page drawn for a PPTX, at twice its size: its text, boxes, and pictures
92
+ * measured, each picture drawn alone beside `screenshotPath`, then the page shot without what
93
+ * is laid over it, to `screenshotPath`.
89
94
  */
90
95
  export type PptxRequest = {
91
96
  kind: "pptx";
@@ -93,9 +98,22 @@ export type PptxRequest = {
93
98
  pages: Array<{ html: string; slug: string; screenshotPath: string }>;
94
99
  };
95
100
 
96
- /** Each page's text, in the order of the request's pages, with the font each run was drawn in. */
101
+ /** A picture on its way to the package: the PNG the worker wrote, and the SVG it read. */
102
+ export type PptxPictureFile = { png: string; svg?: string };
103
+
104
+ /** Each page's layers, in the order of the request's pages, with the font each run was drawn in. */
97
105
  export type PptxResponse = {
98
- slides: Array<{ slug: string; description: string; boxes: PptxTextBox[] }>;
106
+ slides: Array<{
107
+ slug: string;
108
+ description: string;
109
+ layers: PptxLayer<PptxPictureFile>[];
110
+ /** Whether anything is left in the slide's picture; false for nothing. */
111
+ picture?: boolean;
112
+ /** The slide's background, when PowerPoint paints it. */
113
+ background?: PptxFill;
114
+ /** What the slide says of the deck's theme. */
115
+ theme?: PptxPageTheme;
116
+ }>;
99
117
  };
100
118
 
101
119
  /** What the Playwright worker is asked; the kind says which payload comes with it. */
@@ -119,9 +137,15 @@ export type ContrastFinding = {
119
137
  /**
120
138
  * For text below its threshold, which stylesheet draws it so: "theme" when it stays below
121
139
  * with the slide's own CSS taken away, "slide" when that CSS brings it there. Absent when the
122
- * text passes, or shows no glyph without the slide's CSS.
140
+ * text passes, or shows no glyph without the slide's CSS. Absent too when it shows none with the
141
+ * slide script's draw undone and clears without the slide's CSS: either may have drawn it so.
123
142
  */
124
143
  origin?: ContrastOrigin;
144
+ /**
145
+ * For text below its threshold that shows with the slide's own CSS and no glyph without it: the
146
+ * theme alone was never measured, so either stylesheet may hold its colors.
147
+ */
148
+ onlyWithSlideCss?: true;
125
149
  };
126
150
 
127
151
  export type ContrastOrigin = "theme" | "slide" | "script";
@@ -144,6 +168,30 @@ type CollisionFinding = Collision & { slug: string; step: string };
144
168
  /** How much of the frame one page fills, and where. */
145
169
  export type FillFinding = Fill & { slug: string; step: string };
146
170
 
171
+ /**
172
+ * A line break that reads wrong on one page, with what decides where the fix goes: the slide's own
173
+ * `word-break` and the text's `text-wrap`, as the page computes them.
174
+ */
175
+ type LineBreakFinding = {
176
+ slug: string;
177
+ step: string;
178
+ box: string;
179
+ text?: string;
180
+ lines: string[];
181
+ wordBreak: string;
182
+ textWrap: string;
183
+ } & LineBreakKind;
184
+
185
+ /** Two items on one page that nearly line up and do not, each named as the page shows it. */
186
+ type MisalignmentFinding = Omit<Misalignment, "element" | "other"> & {
187
+ slug: string;
188
+ step: string;
189
+ box: string;
190
+ text?: string;
191
+ other: string;
192
+ otherText?: string;
193
+ };
194
+
147
195
  export type PagesResponse = {
148
196
  overflows: OverflowFinding[];
149
197
  contrasts: ContrastFinding[];
@@ -153,6 +201,10 @@ export type PagesResponse = {
153
201
  collisions?: CollisionFinding[];
154
202
  /** One for each page, when `fill` was asked for; absent from a runner that has none. */
155
203
  fills?: FillFinding[];
204
+ /** Absent from a runner that has none to report. */
205
+ lineBreaks?: LineBreakFinding[];
206
+ /** Absent from a runner that has none to report. */
207
+ misalignments?: MisalignmentFinding[];
156
208
  };
157
209
 
158
210
  export type MotionResponse = {
@@ -186,6 +238,11 @@ type SpawnTimeoutOptions = {
186
238
  timeoutMs?: number;
187
239
  };
188
240
 
241
+ type RunnerOptions = SpawnTimeoutOptions & {
242
+ /** The pages hold a ref's deck: Chromium's sandbox only, unless the user accepted otherwise. */
243
+ ref?: boolean;
244
+ };
245
+
189
246
  const requireFromDek = createRequire(import.meta.url);
190
247
 
191
248
  export function resolvePlaywrightModule(): string | undefined {
@@ -248,49 +305,85 @@ export async function importPlaywright(): Promise<typeof import("playwright")> {
248
305
  return import(pathToFileURL(spec).href) as Promise<typeof import("playwright")>;
249
306
  }
250
307
 
308
+ /** Playwright's Chromium, or what stands in for it in tests. */
309
+ type Chromium = typeof import("playwright").chromium;
310
+
251
311
  /**
252
312
  * A Playwright worker's whole life: load Playwright, read the request, launch Chromium, answer
253
313
  * with what `handle` returns, and close the browser. Any failure exits with its reason on stderr.
314
+ * Where Chromium's sandbox did not start, the answer says why in `unsandboxed`, and is only that
315
+ * when the request's `sandbox` required it, or with why Chromium did not start without it either
316
+ * in `failed`. `chromium` stands in for Playwright's in tests.
254
317
  */
255
318
  export async function runPlaywrightWorker<Request, Response>(
256
319
  handle: (browser: Browser, request: Request) => Promise<Response>,
320
+ chromium?: Chromium,
257
321
  ): Promise<void> {
258
- let playwright: Awaited<ReturnType<typeof importPlaywright>>;
322
+ let launcher: Chromium;
259
323
  try {
260
- playwright = await importPlaywright();
324
+ launcher = chromium ?? (await importPlaywright()).chromium;
261
325
  } catch {
262
326
  exitWorker(`playwright not found; ${PLAYWRIGHT_INSTALL}`);
263
327
  }
264
- const request = await readWorkerRequest<Request>();
328
+ const request = await readWorkerRequest<Request & { sandbox?: unknown }>();
265
329
  try {
266
- const browser = await launchChromium(playwright.chromium);
330
+ // Anything but "preferred", or nothing, requires the sandbox.
331
+ const sandbox = request.sandbox === "preferred" ? "preferred" : "required";
332
+ const launched = await launchChromium(launcher, sandbox);
333
+ if (!launched.browser) {
334
+ const { browser: _none, ...said } = launched;
335
+ process.stdout.write(`${JSON.stringify(said)}\n`);
336
+ return;
337
+ }
338
+ const { browser, unsandboxed } = launched;
267
339
  try {
268
340
  const response = await handle(browser, request);
269
- process.stdout.write(`${JSON.stringify(response)}\n`);
341
+ const answer = unsandboxed === undefined ? response : { ...response, unsandboxed };
342
+ process.stdout.write(`${JSON.stringify(answer)}\n`);
270
343
  } finally {
271
344
  await browser.close();
272
345
  }
273
346
  } catch (error) {
274
- exitWorker(error);
347
+ // The parent's hint keeps the start of stderr, and Playwright's opens with a command line.
348
+ exitWorker(chromiumFailure(error) ?? error);
275
349
  }
276
350
  }
277
351
 
352
+ /**
353
+ * Chromium with its sandbox, or without it and why; or none: where the sandbox was required, or
354
+ * where Chromium did not start without it either.
355
+ */
356
+ type Launched =
357
+ | { browser: Browser; unsandboxed?: string }
358
+ | { browser?: never; unsandboxed: string; failed?: string };
359
+
278
360
  /**
279
361
  * Headless Chromium with its sandbox, which Playwright leaves off by default: slide scripts run in
280
- * it, a ref's included. Where the sandbox cannot start, as in a container running as root, it
281
- * runs without one rather than not at all.
362
+ * it, a ref's included. When that launch fails, as in a container running as root, it launches
363
+ * again without one only when `sandbox` is "preferred", and says why in Chromium's words, whether
364
+ * or not they name the sandbox; when that fails too, it says both.
282
365
  */
283
- async function launchChromium(chromium: typeof import("playwright").chromium): Promise<Browser> {
366
+ export async function launchChromium(chromium: Chromium, sandbox: SandboxMode): Promise<Launched> {
367
+ let first: unknown;
284
368
  try {
285
- return await chromium.launch({ headless: true, chromiumSandbox: true });
286
- } catch {
287
- return await chromium.launch({ headless: true });
369
+ return { browser: await chromium.launch({ headless: true, chromiumSandbox: true }) };
370
+ } catch (error) {
371
+ first = error;
372
+ }
373
+ const unsandboxed = launchFailure(first);
374
+ if (sandbox !== "preferred") {
375
+ return { unsandboxed };
376
+ }
377
+ try {
378
+ return { browser: await chromium.launch({ headless: true }), unsandboxed };
379
+ } catch (second) {
380
+ return { unsandboxed, failed: launchFailure(second) };
288
381
  }
289
382
  }
290
383
 
291
384
  export async function defaultPlaywrightRunner(
292
385
  request: VisualRequest,
293
- options: SpawnTimeoutOptions = {},
386
+ options: RunnerOptions = {},
294
387
  ): Promise<VisualResponse | null> {
295
388
  if (!playwrightResolved()) {
296
389
  return null;
@@ -298,6 +391,14 @@ export async function defaultPlaywrightRunner(
298
391
  return spawnRunner(workerCommand(process.env.DEK_PLAYWRIGHT || workerPath()), request, options);
299
392
  }
300
393
 
394
+ /**
395
+ * The default runner for a ref's pages: someone else's slide scripts, which Chromium runs only in
396
+ * its sandbox unless the user set DEK_ALLOW_UNSANDBOXED=1 (ADR 29).
397
+ */
398
+ export function refPlaywrightRunner(request: VisualRequest): Promise<VisualResponse | null> {
399
+ return defaultPlaywrightRunner(request, { ref: true });
400
+ }
401
+
301
402
  function workerPath(): string {
302
403
  return moduleFilePath(new URL("./playwright-worker.ts", import.meta.url));
303
404
  }
@@ -305,12 +406,13 @@ function workerPath(): string {
305
406
  function spawnRunner(
306
407
  cmd: string[],
307
408
  request: VisualRequest,
308
- options: SpawnTimeoutOptions,
409
+ options: RunnerOptions,
309
410
  ): Promise<VisualResponse> {
310
- return runJsonWorker(cmd, request, (text) => parseVisualResponse(text, request.kind), {
411
+ return runDrawingWorker(cmd, request, (text) => parseVisualResponse(text, request.kind), {
311
412
  label: "Playwright worker failed",
312
413
  hint: `${PLAYWRIGHT_INSTALL} or check DEK_PLAYWRIGHT`,
313
414
  ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
415
+ ...(options.ref ? { ref: true } : {}),
314
416
  });
315
417
  }
316
418
 
@@ -318,13 +420,23 @@ function spawnRunner(
318
420
  const RESPONSE_PARSERS: {
319
421
  [K in keyof VisualResponses]: (fields: Record<string, unknown>) => VisualResponses[K] | null;
320
422
  } = {
321
- pages: ({ overflows, contrasts, drawErrors = [], collisions = [], fills = [] }) =>
423
+ pages: ({
424
+ overflows,
425
+ contrasts,
426
+ drawErrors = [],
427
+ collisions = [],
428
+ fills = [],
429
+ lineBreaks = [],
430
+ misalignments = [],
431
+ }) =>
322
432
  isArrayOf(overflows, isOverflowFinding) &&
323
433
  isArrayOf(contrasts, isContrastFinding) &&
324
434
  isArrayOf(drawErrors, isDrawErrorFinding) &&
325
435
  isArrayOf(collisions, isCollisionFinding) &&
326
- isArrayOf(fills, isFillFinding)
327
- ? { overflows, contrasts, drawErrors, collisions, fills }
436
+ isArrayOf(fills, isFillFinding) &&
437
+ isArrayOf(lineBreaks, isLineBreakFinding) &&
438
+ isArrayOf(misalignments, isMisalignmentFinding)
439
+ ? { overflows, contrasts, drawErrors, collisions, fills, lineBreaks, misalignments }
328
440
  : null,
329
441
  pdf: () => ({}),
330
442
  morph: () => ({}),
@@ -401,6 +513,37 @@ function isFillFinding(value: unknown): value is FillFinding {
401
513
  );
402
514
  }
403
515
 
516
+ function isLineBreakFinding(value: unknown): value is LineBreakFinding {
517
+ if (!isFinding(value)) {
518
+ return false;
519
+ }
520
+ const { kind, lines, phrased, tail, wordBreak, textWrap } = value;
521
+ return (
522
+ isArrayOf(lines, isString) &&
523
+ isString(wordBreak) &&
524
+ isString(textWrap) &&
525
+ ((kind === "split" && isArrayOf(phrased, isString)) || (kind === "short" && isString(tail)))
526
+ );
527
+ }
528
+
529
+ const ALIGNED_EDGES: ReadonlySet<unknown> = new Set<AlignedEdge>([
530
+ "top",
531
+ "middle",
532
+ "bottom",
533
+ "left",
534
+ "center",
535
+ "right",
536
+ ]);
537
+
538
+ function isMisalignmentFinding(value: unknown): value is MisalignmentFinding {
539
+ // Both name two elements by their boxes and their texts, as a collision does.
540
+ if (!isCollisionFinding(value)) {
541
+ return false;
542
+ }
543
+ const { edge, of, by } = value as Record<string, unknown>;
544
+ return ALIGNED_EDGES.has(edge) && (of === "box" || of === "text") && isNumber(by);
545
+ }
546
+
404
547
  function isDrawErrorFinding(value: unknown): value is DrawErrorFinding {
405
548
  if (!value || typeof value !== "object") {
406
549
  return false;
@@ -438,14 +581,15 @@ function isContrastFinding(value: unknown): value is ContrastFinding {
438
581
  if (!isFinding(value)) {
439
582
  return false;
440
583
  }
441
- const { ratio, fg, bg, fontSize, fontWeight, origin } = value;
584
+ const { ratio, fg, bg, fontSize, fontWeight, origin, onlyWithSlideCss } = value;
442
585
  return (
443
586
  isNumber(ratio) &&
444
587
  isString(fg) &&
445
588
  isString(bg) &&
446
589
  isNumber(fontSize) &&
447
590
  isNumber(fontWeight) &&
448
- (origin === undefined || origin === "theme" || origin === "slide" || origin === "script")
591
+ (origin === undefined || origin === "theme" || origin === "slide" || origin === "script") &&
592
+ (onlyWithSlideCss === undefined || onlyWithSlideCss === true)
449
593
  );
450
594
  }
451
595
 
@@ -453,34 +597,125 @@ function isPptxSlide(value: unknown): value is PptxResponse["slides"][number] {
453
597
  if (!value || typeof value !== "object") {
454
598
  return false;
455
599
  }
456
- const { slug, description, boxes } = value as Record<string, unknown>;
457
- return isString(slug) && isString(description) && isArrayOf(boxes, isPptxBox);
600
+ const { slug, description, layers, picture, background, theme } = value as Record<
601
+ string,
602
+ unknown
603
+ >;
604
+ return (
605
+ isString(slug) &&
606
+ isString(description) &&
607
+ isArrayOf(layers, isPptxLayer) &&
608
+ (picture === undefined || typeof picture === "boolean") &&
609
+ (background === undefined || isFill(background)) &&
610
+ (theme === undefined || (Boolean(theme) && typeof theme === "object"))
611
+ );
458
612
  }
459
613
 
460
- function isPptxBox(value: unknown): value is PptxTextBox {
614
+ function isFill(value: unknown): value is PptxFill {
461
615
  if (!value || typeof value !== "object") {
462
616
  return false;
463
617
  }
464
- const box = value as Record<string, unknown>;
618
+ const fill = value as Record<string, unknown>;
619
+ return fill.kind === "solid"
620
+ ? isPaint(fill.paint)
621
+ : fill.kind === "linear" &&
622
+ isNumber(fill.angle) &&
623
+ isArrayOf(
624
+ fill.stops,
625
+ (stop: unknown): stop is object =>
626
+ Boolean(stop) &&
627
+ typeof stop === "object" &&
628
+ isNumber((stop as Record<string, unknown>).at) &&
629
+ isPaint((stop as Record<string, unknown>).paint),
630
+ );
631
+ }
632
+
633
+ function isPptxLayer(value: unknown): value is PptxLayer<PptxPictureFile> {
634
+ if (!value || typeof value !== "object") {
635
+ return false;
636
+ }
637
+ const layer = value as Record<string, unknown>;
638
+ const at = ["x", "y", "width", "height"].every((key) => isNumber(layer[key]));
639
+ switch (layer.kind) {
640
+ case "text":
641
+ return isPptxBox(layer);
642
+ case "rule":
643
+ return (
644
+ ["x1", "y1", "x2", "y2", "width"].every((key) => isNumber(layer[key])) &&
645
+ isPaint(layer.paint)
646
+ );
647
+ case "picture":
648
+ return (
649
+ at &&
650
+ isString(layer.png) &&
651
+ isString(layer.description) &&
652
+ (layer.svg === undefined || isString(layer.svg))
653
+ );
654
+ case "shape":
655
+ return (
656
+ at &&
657
+ isNumber(layer.radius) &&
658
+ (layer.fill === undefined || isPaint(layer.fill)) &&
659
+ isArrayOf(layer.layers, isPptxLayer) &&
660
+ (layer.text === undefined || isShapeText(layer.text))
661
+ );
662
+ default:
663
+ return false;
664
+ }
665
+ }
666
+
667
+ function isPaint(value: unknown): boolean {
668
+ if (!value || typeof value !== "object") {
669
+ return false;
670
+ }
671
+ const { color, alpha } = value as Record<string, unknown>;
672
+ return isArrayOf(color, isNumber) && isNumber(alpha);
673
+ }
674
+
675
+ function isShapeText(value: unknown): boolean {
676
+ if (!value || typeof value !== "object") {
677
+ return false;
678
+ }
679
+ const { inset, paragraphs } = value as Record<string, unknown>;
465
680
  return (
466
- ["x", "y", "width", "height", "lineHeight"].every((key) => isNumber(box[key])) &&
467
- isArrayOf(box.runs, (run: unknown): run is PptxTextBox["runs"][number] => {
468
- if (!run || typeof run !== "object") {
681
+ Boolean(inset) &&
682
+ typeof inset === "object" &&
683
+ isArrayOf(paragraphs, (paragraph: unknown): paragraph is object => {
684
+ if (!paragraph || typeof paragraph !== "object") {
469
685
  return false;
470
686
  }
471
- const { text, size, alpha, letterSpacing, color, font } = run as Record<string, unknown>;
472
- return (
473
- isString(text) &&
474
- isNumber(size) &&
475
- isNumber(alpha) &&
476
- isNumber(letterSpacing) &&
477
- isArrayOf(color, isNumber) &&
478
- (font === undefined || isString(font))
479
- );
687
+ const { runs, lineHeight } = paragraph as Record<string, unknown>;
688
+ return isNumber(lineHeight) && isArrayOf(runs, isPptxRun);
480
689
  })
481
690
  );
482
691
  }
483
692
 
693
+ function isPptxBox(value: unknown): value is PptxTextBox {
694
+ if (!value || typeof value !== "object") {
695
+ return false;
696
+ }
697
+ const box = value as Record<string, unknown>;
698
+ return (
699
+ ["x", "y", "width", "height", "lineHeight"].every((key) => isNumber(box[key])) &&
700
+ isArrayOf(box.runs, isPptxRun)
701
+ );
702
+ }
703
+
704
+ function isPptxRun(run: unknown): run is PptxTextBox["runs"][number] {
705
+ if (!run || typeof run !== "object") {
706
+ return false;
707
+ }
708
+ const { text, size, alpha, letterSpacing, color, font } = run as Record<string, unknown>;
709
+ return (
710
+ isString(text) &&
711
+ isNumber(size) &&
712
+ isNumber(alpha) &&
713
+ isNumber(letterSpacing) &&
714
+ isArrayOf(color, isNumber) &&
715
+ (font === undefined || isString(font))
716
+ );
717
+ }
718
+
484
719
  function isMotionBeat(value: unknown): value is MotionBeat {
485
720
  if (!value || typeof value !== "object") {
486
721
  return false;