@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.
- package/CHANGELOG.md +63 -0
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/package.json +2 -1
- package/skills/dek/SKILL.md +10 -3
- package/skills/dek-design/SKILL.md +42 -0
- package/src/cli/address.ts +84 -14
- package/src/cli/build.ts +2 -2
- package/src/cli/check.ts +2 -1
- package/src/cli/commands.ts +11 -6
- package/src/cli/contract.ts +9 -1
- package/src/cli/files.ts +1 -5
- package/src/cli/flags.ts +1 -1
- package/src/cli/goto.ts +3 -1
- package/src/cli/init.ts +10 -5
- package/src/cli/lint.ts +4 -3
- package/src/cli/main.ts +7 -5
- package/src/cli/new.ts +14 -7
- package/src/cli/pptx.ts +9 -4
- package/src/cli/ref.ts +91 -9
- package/src/cli/result.ts +5 -2
- package/src/cli/scope.ts +17 -28
- package/src/cli/shot.ts +2 -1
- package/src/cli/text.ts +27 -2
- package/src/cli/theme.ts +16 -2
- package/src/cli/voice.ts +4 -3
- package/src/core/alignment.ts +320 -0
- package/src/core/annotation-rows.ts +16 -4
- package/src/core/annotations.ts +19 -1
- package/src/core/config.ts +61 -12
- package/src/core/diagnostic.ts +5 -1
- package/src/core/escape.ts +43 -0
- package/src/core/line-break.ts +275 -0
- package/src/core/lint/asset-refs.ts +6 -2
- package/src/core/lint/files.ts +30 -5
- package/src/core/lint/keys.ts +7 -3
- package/src/core/lint/slide-html.ts +3 -1
- package/src/core/lint/slide-script.ts +7 -5
- package/src/core/mv.ts +30 -10
- package/src/core/pack.ts +2 -2
- package/src/core/playwright-module.d.ts +1 -0
- package/src/core/playwright-visual.ts +180 -20
- package/src/core/playwright.ts +202 -25
- package/src/core/pptx-package.ts +461 -33
- package/src/core/pptx-paint.ts +1168 -0
- package/src/core/pptx-plan.ts +879 -0
- package/src/core/pptx-text.ts +204 -47
- package/src/core/pptx-theme.ts +222 -0
- package/src/core/pptx.ts +55 -8
- package/src/core/prose.ts +23 -0
- package/src/core/pseudo-text.ts +32 -10
- package/src/core/ref-name.ts +47 -8
- package/src/core/ref.ts +172 -16
- package/src/core/resolve.ts +20 -9
- package/src/core/shot/morph.ts +1 -1
- package/src/core/shot/motion.ts +2 -3
- package/src/core/shot/still.ts +2 -2
- package/src/core/shot-cache.ts +25 -17
- package/src/core/slide-measure.ts +51 -1
- package/src/core/slide-script-eval.ts +10 -8
- package/src/core/slide-script.ts +114 -20
- package/src/core/sync.ts +2 -2
- package/src/core/visual-cache.ts +2 -0
- package/src/core/visual.ts +154 -15
- package/src/core/voice.ts +3 -5
- package/src/core/zod.ts +9 -1
- package/src/server/dev.ts +15 -4
- package/src/server/lock.ts +48 -5
- package/src/server/routes.ts +7 -2
- package/src/theme/default.css +4 -0
- 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 {
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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:
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
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: `[${
|
|
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
|
|
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 &&
|
|
191
|
-
fonts.set(
|
|
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 = {
|
|
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),
|
package/src/core/playwright.ts
CHANGED
|
@@ -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
|
|
88
|
-
* then the page shot without
|
|
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
|
-
/**
|
|
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<{
|
|
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: ({
|
|
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
|
-
|
|
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,
|
|
457
|
-
|
|
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
|
|
575
|
+
function isPptxLayer(value: unknown): value is PptxLayer<PptxPictureFile> {
|
|
461
576
|
if (!value || typeof value !== "object") {
|
|
462
577
|
return false;
|
|
463
578
|
}
|
|
464
|
-
const
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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 {
|
|
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;
|