@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,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),
@@ -2,11 +2,14 @@ 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";
10
13
  import type { MotionBeat, SheetSpec } from "./sheet.ts";
11
14
  import { exitWorker, readWorkerRequest, runJsonWorker, workerCommand } from "./spawn.ts";
12
15
  import type { Position } from "./step.ts";
@@ -41,7 +44,7 @@ export type MotionSpec = {
41
44
  };
42
45
 
43
46
  /** What to measure on each page; a screenshot is asked for by the page's `screenshotPath`. */
44
- export type PageAction = "overflow" | "contrast" | "fill";
47
+ export type PageAction = "overflow" | "contrast" | "fill" | "breaks" | "alignment";
45
48
 
46
49
  type Viewport = { width: number; height: number };
47
50
 
@@ -84,8 +87,9 @@ export type MotionRequest = {
84
87
  };
85
88
 
86
89
  /**
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`.
90
+ * Each slide's still page drawn for a PPTX, at twice its size: its text, boxes, and pictures
91
+ * measured, each picture drawn alone beside `screenshotPath`, then the page shot without what
92
+ * is laid over it, to `screenshotPath`.
89
93
  */
90
94
  export type PptxRequest = {
91
95
  kind: "pptx";
@@ -93,9 +97,22 @@ export type PptxRequest = {
93
97
  pages: Array<{ html: string; slug: string; screenshotPath: string }>;
94
98
  };
95
99
 
96
- /** Each page's text, in the order of the request's pages, with the font each run was drawn in. */
100
+ /** A picture on its way to the package: the PNG the worker wrote, and the SVG it read. */
101
+ export type PptxPictureFile = { png: string; svg?: string };
102
+
103
+ /** Each page's layers, in the order of the request's pages, with the font each run was drawn in. */
97
104
  export type PptxResponse = {
98
- slides: Array<{ slug: string; description: string; boxes: PptxTextBox[] }>;
105
+ slides: Array<{
106
+ slug: string;
107
+ description: string;
108
+ layers: PptxLayer<PptxPictureFile>[];
109
+ /** Whether anything is left in the slide's picture; false for nothing. */
110
+ picture?: boolean;
111
+ /** The slide's background, when PowerPoint paints it. */
112
+ background?: PptxFill;
113
+ /** What the slide says of the deck's theme. */
114
+ theme?: PptxPageTheme;
115
+ }>;
99
116
  };
100
117
 
101
118
  /** What the Playwright worker is asked; the kind says which payload comes with it. */
@@ -144,6 +161,30 @@ type CollisionFinding = Collision & { slug: string; step: string };
144
161
  /** How much of the frame one page fills, and where. */
145
162
  export type FillFinding = Fill & { slug: string; step: string };
146
163
 
164
+ /**
165
+ * A line break that reads wrong on one page, with what decides where the fix goes: the slide's own
166
+ * `word-break` and the text's `text-wrap`, as the page computes them.
167
+ */
168
+ type LineBreakFinding = {
169
+ slug: string;
170
+ step: string;
171
+ box: string;
172
+ text?: string;
173
+ lines: string[];
174
+ wordBreak: string;
175
+ textWrap: string;
176
+ } & LineBreakKind;
177
+
178
+ /** Two items on one page that nearly line up and do not, each named as the page shows it. */
179
+ type MisalignmentFinding = Omit<Misalignment, "element" | "other"> & {
180
+ slug: string;
181
+ step: string;
182
+ box: string;
183
+ text?: string;
184
+ other: string;
185
+ otherText?: string;
186
+ };
187
+
147
188
  export type PagesResponse = {
148
189
  overflows: OverflowFinding[];
149
190
  contrasts: ContrastFinding[];
@@ -153,6 +194,10 @@ export type PagesResponse = {
153
194
  collisions?: CollisionFinding[];
154
195
  /** One for each page, when `fill` was asked for; absent from a runner that has none. */
155
196
  fills?: FillFinding[];
197
+ /** Absent from a runner that has none to report. */
198
+ lineBreaks?: LineBreakFinding[];
199
+ /** Absent from a runner that has none to report. */
200
+ misalignments?: MisalignmentFinding[];
156
201
  };
157
202
 
158
203
  export type MotionResponse = {
@@ -318,13 +363,23 @@ function spawnRunner(
318
363
  const RESPONSE_PARSERS: {
319
364
  [K in keyof VisualResponses]: (fields: Record<string, unknown>) => VisualResponses[K] | null;
320
365
  } = {
321
- pages: ({ overflows, contrasts, drawErrors = [], collisions = [], fills = [] }) =>
366
+ pages: ({
367
+ overflows,
368
+ contrasts,
369
+ drawErrors = [],
370
+ collisions = [],
371
+ fills = [],
372
+ lineBreaks = [],
373
+ misalignments = [],
374
+ }) =>
322
375
  isArrayOf(overflows, isOverflowFinding) &&
323
376
  isArrayOf(contrasts, isContrastFinding) &&
324
377
  isArrayOf(drawErrors, isDrawErrorFinding) &&
325
378
  isArrayOf(collisions, isCollisionFinding) &&
326
- isArrayOf(fills, isFillFinding)
327
- ? { overflows, contrasts, drawErrors, collisions, fills }
379
+ isArrayOf(fills, isFillFinding) &&
380
+ isArrayOf(lineBreaks, isLineBreakFinding) &&
381
+ isArrayOf(misalignments, isMisalignmentFinding)
382
+ ? { overflows, contrasts, drawErrors, collisions, fills, lineBreaks, misalignments }
328
383
  : null,
329
384
  pdf: () => ({}),
330
385
  morph: () => ({}),
@@ -401,6 +456,37 @@ function isFillFinding(value: unknown): value is FillFinding {
401
456
  );
402
457
  }
403
458
 
459
+ function isLineBreakFinding(value: unknown): value is LineBreakFinding {
460
+ if (!isFinding(value)) {
461
+ return false;
462
+ }
463
+ const { kind, lines, phrased, tail, wordBreak, textWrap } = value;
464
+ return (
465
+ isArrayOf(lines, isString) &&
466
+ isString(wordBreak) &&
467
+ isString(textWrap) &&
468
+ ((kind === "split" && isArrayOf(phrased, isString)) || (kind === "short" && isString(tail)))
469
+ );
470
+ }
471
+
472
+ const ALIGNED_EDGES: ReadonlySet<unknown> = new Set<AlignedEdge>([
473
+ "top",
474
+ "middle",
475
+ "bottom",
476
+ "left",
477
+ "center",
478
+ "right",
479
+ ]);
480
+
481
+ function isMisalignmentFinding(value: unknown): value is MisalignmentFinding {
482
+ // Both name two elements by their boxes and their texts, as a collision does.
483
+ if (!isCollisionFinding(value)) {
484
+ return false;
485
+ }
486
+ const { edge, of, by } = value as Record<string, unknown>;
487
+ return ALIGNED_EDGES.has(edge) && (of === "box" || of === "text") && isNumber(by);
488
+ }
489
+
404
490
  function isDrawErrorFinding(value: unknown): value is DrawErrorFinding {
405
491
  if (!value || typeof value !== "object") {
406
492
  return false;
@@ -453,34 +539,125 @@ function isPptxSlide(value: unknown): value is PptxResponse["slides"][number] {
453
539
  if (!value || typeof value !== "object") {
454
540
  return false;
455
541
  }
456
- const { slug, description, boxes } = value as Record<string, unknown>;
457
- return isString(slug) && isString(description) && isArrayOf(boxes, isPptxBox);
542
+ const { slug, description, layers, picture, background, theme } = value as Record<
543
+ string,
544
+ unknown
545
+ >;
546
+ return (
547
+ isString(slug) &&
548
+ isString(description) &&
549
+ isArrayOf(layers, isPptxLayer) &&
550
+ (picture === undefined || typeof picture === "boolean") &&
551
+ (background === undefined || isFill(background)) &&
552
+ (theme === undefined || (Boolean(theme) && typeof theme === "object"))
553
+ );
554
+ }
555
+
556
+ function isFill(value: unknown): value is PptxFill {
557
+ if (!value || typeof value !== "object") {
558
+ return false;
559
+ }
560
+ const fill = value as Record<string, unknown>;
561
+ return fill.kind === "solid"
562
+ ? isPaint(fill.paint)
563
+ : fill.kind === "linear" &&
564
+ isNumber(fill.angle) &&
565
+ isArrayOf(
566
+ fill.stops,
567
+ (stop: unknown): stop is object =>
568
+ Boolean(stop) &&
569
+ typeof stop === "object" &&
570
+ isNumber((stop as Record<string, unknown>).at) &&
571
+ isPaint((stop as Record<string, unknown>).paint),
572
+ );
458
573
  }
459
574
 
460
- function isPptxBox(value: unknown): value is PptxTextBox {
575
+ function isPptxLayer(value: unknown): value is PptxLayer<PptxPictureFile> {
461
576
  if (!value || typeof value !== "object") {
462
577
  return false;
463
578
  }
464
- const box = value as Record<string, unknown>;
579
+ const layer = value as Record<string, unknown>;
580
+ const at = ["x", "y", "width", "height"].every((key) => isNumber(layer[key]));
581
+ switch (layer.kind) {
582
+ case "text":
583
+ return isPptxBox(layer);
584
+ case "rule":
585
+ return (
586
+ ["x1", "y1", "x2", "y2", "width"].every((key) => isNumber(layer[key])) &&
587
+ isPaint(layer.paint)
588
+ );
589
+ case "picture":
590
+ return (
591
+ at &&
592
+ isString(layer.png) &&
593
+ isString(layer.description) &&
594
+ (layer.svg === undefined || isString(layer.svg))
595
+ );
596
+ case "shape":
597
+ return (
598
+ at &&
599
+ isNumber(layer.radius) &&
600
+ (layer.fill === undefined || isPaint(layer.fill)) &&
601
+ isArrayOf(layer.layers, isPptxLayer) &&
602
+ (layer.text === undefined || isShapeText(layer.text))
603
+ );
604
+ default:
605
+ return false;
606
+ }
607
+ }
608
+
609
+ function isPaint(value: unknown): boolean {
610
+ if (!value || typeof value !== "object") {
611
+ return false;
612
+ }
613
+ const { color, alpha } = value as Record<string, unknown>;
614
+ return isArrayOf(color, isNumber) && isNumber(alpha);
615
+ }
616
+
617
+ function isShapeText(value: unknown): boolean {
618
+ if (!value || typeof value !== "object") {
619
+ return false;
620
+ }
621
+ const { inset, paragraphs } = value as Record<string, unknown>;
465
622
  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") {
623
+ Boolean(inset) &&
624
+ typeof inset === "object" &&
625
+ isArrayOf(paragraphs, (paragraph: unknown): paragraph is object => {
626
+ if (!paragraph || typeof paragraph !== "object") {
469
627
  return false;
470
628
  }
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
- );
629
+ const { runs, lineHeight } = paragraph as Record<string, unknown>;
630
+ return isNumber(lineHeight) && isArrayOf(runs, isPptxRun);
480
631
  })
481
632
  );
482
633
  }
483
634
 
635
+ function isPptxBox(value: unknown): value is PptxTextBox {
636
+ if (!value || typeof value !== "object") {
637
+ return false;
638
+ }
639
+ const box = value as Record<string, unknown>;
640
+ return (
641
+ ["x", "y", "width", "height", "lineHeight"].every((key) => isNumber(box[key])) &&
642
+ isArrayOf(box.runs, isPptxRun)
643
+ );
644
+ }
645
+
646
+ function isPptxRun(run: unknown): run is PptxTextBox["runs"][number] {
647
+ if (!run || typeof run !== "object") {
648
+ return false;
649
+ }
650
+ const { text, size, alpha, letterSpacing, color, font } = run as Record<string, unknown>;
651
+ return (
652
+ isString(text) &&
653
+ isNumber(size) &&
654
+ isNumber(alpha) &&
655
+ isNumber(letterSpacing) &&
656
+ isArrayOf(color, isNumber) &&
657
+ (font === undefined || isString(font))
658
+ );
659
+ }
660
+
484
661
  function isMotionBeat(value: unknown): value is MotionBeat {
485
662
  if (!value || typeof value !== "object") {
486
663
  return false;