@hajimism/dek 0.3.2 → 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 +90 -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 +54 -13
- package/src/cli/contract.ts +18 -1
- package/src/cli/files.ts +9 -6
- 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 +41 -10
- package/src/cli/pack.ts +21 -0
- package/src/cli/pptx.ts +9 -4
- package/src/cli/ref.ts +243 -9
- package/src/cli/result.ts +5 -2
- package/src/cli/scope.ts +25 -27
- package/src/cli/shot.ts +2 -1
- package/src/cli/text.ts +45 -4
- 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-keys.ts +6 -2
- package/src/core/config.ts +88 -12
- package/src/core/diagnostic.ts +5 -1
- package/src/core/escape.ts +43 -0
- package/src/core/licenses/CC-BY-4.0.txt +396 -0
- package/src/core/licenses/CC0-1.0.txt +121 -0
- package/src/core/licenses.ts +20 -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 +137 -0
- 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 +100 -17
- package/src/core/ref.ts +318 -34
- 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 +3 -3
- 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/runtime/annotate-view.ts +4 -2
- 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
package/src/core/pack.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { loadConfig } from "./config.ts";
|
|
4
|
+
import { DekError } from "./error.ts";
|
|
5
|
+
import { licenseText } from "./licenses.ts";
|
|
6
|
+
import { type DistOptions, distFile } from "./path.ts";
|
|
7
|
+
import { keepsInDeck, LICENSE_NAMES, MAX_SNAPSHOT_BYTES } from "./ref.ts";
|
|
8
|
+
import { isRefDeckName, REF_DECK_NAME_RULE } from "./ref-name.ts";
|
|
9
|
+
import { followInside, requireInside, writeInside } from "./safe-fs.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A deck handed over as a file, to be read as a ref by someone who cannot reach it on GitHub:
|
|
13
|
+
* a gzipped tar of a project that holds only that deck, under a folder named for it. The deck
|
|
14
|
+
* keeps what `dekc ref` keeps of a repository; the project's dek.toml goes empty, so the
|
|
15
|
+
* sender's refs and voice settings stay home. Its LICENSE is the one `packLicense` picks.
|
|
16
|
+
*/
|
|
17
|
+
export async function packDeck(
|
|
18
|
+
project: { root: string },
|
|
19
|
+
deck: { name: string; dir: string },
|
|
20
|
+
options: { maxBytes?: number } = {},
|
|
21
|
+
): Promise<Uint8Array> {
|
|
22
|
+
if (!isRefDeckName(deck.name)) {
|
|
23
|
+
throw new DekError(`deck "${deck.name}" cannot be read as a ref by that name`, {
|
|
24
|
+
path: deck.dir,
|
|
25
|
+
hint: `${REF_DECK_NAME_RULE}; rename the deck's folder, e.g. to 2026-10-talk, and pack it again`,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
// Each file by its path in the pack and where it really is, measured before any is read.
|
|
29
|
+
const sources: Array<readonly [string, string]> = deckFiles(deck.dir, project.root).map(
|
|
30
|
+
(path) =>
|
|
31
|
+
[`decks/${deck.name}/${path}`, requireInside(join(deck.dir, path), project.root)] as const,
|
|
32
|
+
);
|
|
33
|
+
const license = packLicense(project.root);
|
|
34
|
+
if (license?.path) {
|
|
35
|
+
sources.push([license.name, license.path]);
|
|
36
|
+
}
|
|
37
|
+
const maxBytes = options.maxBytes ?? MAX_SNAPSHOT_BYTES;
|
|
38
|
+
const size = sources.reduce((sum, [, real]) => sum + statSync(real).size, 0);
|
|
39
|
+
if (size > maxBytes) {
|
|
40
|
+
throw new DekError(
|
|
41
|
+
`deck "${deck.name}" is larger than a ref keeps, ${maxBytes / 1024 / 1024} MB`,
|
|
42
|
+
{
|
|
43
|
+
path: deck.dir,
|
|
44
|
+
hint: "a deck to read as a model should be far smaller; check its assets",
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const files = new Map<string, Uint8Array>([["dek.toml", new Uint8Array()]]);
|
|
49
|
+
for (const [path, real] of sources) {
|
|
50
|
+
files.set(path, readFileSync(real));
|
|
51
|
+
}
|
|
52
|
+
if (license?.text !== undefined) {
|
|
53
|
+
files.set(license.name, new TextEncoder().encode(license.text));
|
|
54
|
+
}
|
|
55
|
+
const entries: Record<string, Uint8Array> = {};
|
|
56
|
+
for (const [path, bytes] of files) {
|
|
57
|
+
entries[`${deck.name}/${path}`] = bytes;
|
|
58
|
+
}
|
|
59
|
+
return new Bun.Archive(entries, { compress: "gzip" }).bytes();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The LICENSE a pack carries: the legal code `[pack] license` names, which says what the reader
|
|
64
|
+
* may do with a deck handed over on purpose; else the project's own LICENSE file; else none.
|
|
65
|
+
*/
|
|
66
|
+
function packLicense(
|
|
67
|
+
root: string,
|
|
68
|
+
):
|
|
69
|
+
| { name: string; text: string; path?: undefined }
|
|
70
|
+
| { name: string; path: string; text?: undefined }
|
|
71
|
+
| undefined {
|
|
72
|
+
const id = loadConfig(join(root, "dek.toml")).pack?.license;
|
|
73
|
+
if (id) {
|
|
74
|
+
return { name: "LICENSE", text: licenseText(id) };
|
|
75
|
+
}
|
|
76
|
+
const name = LICENSE_NAMES.find((file) => isFile(join(root, file), root));
|
|
77
|
+
return name ? { name, path: requireInside(join(root, name), root) } : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Said to the sender, who can fix it, when a pack would go out with no license. */
|
|
81
|
+
const NO_PACK_LICENSE =
|
|
82
|
+
'the pack carries no license, so its reader is told to ask you before reusing it; add `[pack] license = "CC0-1.0"` to dek.toml to let them reuse it freely, or put a LICENSE at the project root';
|
|
83
|
+
|
|
84
|
+
/** `dekc pack`: write the deck's pack as dist/<deck>.tgz. */
|
|
85
|
+
export async function writePack(
|
|
86
|
+
project: { root: string },
|
|
87
|
+
deck: { name: string; dir: string },
|
|
88
|
+
options: DistOptions = {},
|
|
89
|
+
): Promise<{ outPath: string; warnings: string[] }> {
|
|
90
|
+
const bytes = await packDeck(project, deck);
|
|
91
|
+
const outPath = distFile(project, deck, "tgz", options);
|
|
92
|
+
writeInside(outPath, bytes, project.root);
|
|
93
|
+
return { outPath, warnings: packLicense(project.root) ? [] : [NO_PACK_LICENSE] };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What `deckFiles` looks at in the deck's own folder; dist/, .cache/, and voice/ are not read. */
|
|
97
|
+
const DECK_TOP = new Set(["script.md", "theme.css", "slides", "assets"]);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The deck's files a ref keeps, relative to the deck, in a stable order. A file or folder whose
|
|
101
|
+
* name starts with a dot stays behind; a link dek would not follow anywhere else is an error.
|
|
102
|
+
*/
|
|
103
|
+
function deckFiles(deckDir: string, root: string): string[] {
|
|
104
|
+
const found: string[] = [];
|
|
105
|
+
const walk = (rel: string) => {
|
|
106
|
+
const dir = requireInside(join(deckDir, rel), root);
|
|
107
|
+
for (const name of readdirSync(dir).sort()) {
|
|
108
|
+
if (name.startsWith(".") || (rel === "" && !DECK_TOP.has(name))) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const path = rel === "" ? name : `${rel}/${name}`;
|
|
112
|
+
const real = requireInside(join(deckDir, path), root);
|
|
113
|
+
if (!existsSync(real)) {
|
|
114
|
+
throw new DekError(`${relative(root, join(deckDir, path))} is missing`, {
|
|
115
|
+
path: join(deckDir, path),
|
|
116
|
+
hint: "it is a symlink to nothing; remove it, or copy the file it meant in its place",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (statSync(real).isDirectory()) {
|
|
120
|
+
walk(path);
|
|
121
|
+
} else if (keepsInDeck(path)) {
|
|
122
|
+
found.push(path);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
walk("");
|
|
127
|
+
return found;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isFile(path: string, root: string): boolean {
|
|
131
|
+
const real = followInside(path, root);
|
|
132
|
+
try {
|
|
133
|
+
return real !== undefined && statSync(real).isFile();
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -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: {
|
|
@@ -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),
|