@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.
- package/CHANGELOG.md +136 -0
- package/README.ja.md +2 -2
- package/README.md +2 -2
- package/package.json +5 -3
- package/skills/dek/SKILL.md +10 -3
- package/skills/dek-design/SKILL.md +42 -0
- package/src/cli/address.ts +95 -18
- package/src/cli/build.ts +2 -2
- package/src/cli/check.ts +2 -1
- package/src/cli/commands.ts +57 -19
- package/src/cli/contract.ts +58 -4
- package/src/cli/files.ts +2 -5
- package/src/cli/flags.ts +1 -1
- package/src/cli/format.ts +5 -4
- package/src/cli/goto.ts +44 -11
- package/src/cli/init.ts +10 -5
- package/src/cli/lint.ts +36 -16
- package/src/cli/ls.ts +5 -1
- package/src/cli/main.ts +51 -14
- package/src/cli/new.ts +28 -8
- package/src/cli/pptx.ts +9 -4
- package/src/cli/ref.ts +92 -9
- package/src/cli/result.ts +47 -6
- package/src/cli/scope.ts +57 -29
- package/src/cli/shot.ts +78 -6
- package/src/cli/show.ts +15 -2
- package/src/cli/text.ts +88 -11
- package/src/cli/theme.ts +58 -10
- package/src/cli/tty.ts +16 -2
- package/src/cli/voice.ts +15 -9
- 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/assets.ts +18 -10
- package/src/core/attribute-value.ts +188 -0
- package/src/core/build.ts +3 -3
- package/src/core/config.ts +61 -12
- package/src/core/css-scan.ts +173 -39
- package/src/core/css-transform.ts +57 -11
- package/src/core/css.ts +19 -13
- package/src/core/diagnostic.ts +5 -1
- package/src/core/escape.ts +43 -0
- package/src/core/foreign-text.ts +138 -0
- package/src/core/github.ts +1 -0
- package/src/core/html-entities.ts +318 -0
- package/src/core/html-scan.ts +49 -27
- package/src/core/html.ts +21 -15
- package/src/core/line-break.ts +275 -0
- package/src/core/lint/asset-refs.ts +50 -8
- package/src/core/lint/files.ts +30 -5
- package/src/core/lint/keys.ts +7 -3
- package/src/core/lint/slide-html.ts +24 -19
- package/src/core/lint/slide-script.ts +7 -5
- package/src/core/mv.ts +57 -27
- package/src/core/order.ts +14 -0
- package/src/core/pack.ts +2 -2
- package/src/core/path.ts +8 -2
- package/src/core/playwright-module.d.ts +13 -1
- package/src/core/playwright-motion.ts +1 -0
- package/src/core/playwright-sheet.ts +1 -0
- package/src/core/playwright-visual.ts +207 -30
- package/src/core/playwright.ts +279 -44
- 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 +56 -8
- package/src/core/prose.ts +23 -0
- package/src/core/pseudo-text.ts +32 -10
- package/src/core/ref-name.ts +52 -8
- package/src/core/ref.ts +308 -54
- package/src/core/resolve.ts +23 -11
- package/src/core/rumdl.ts +51 -25
- package/src/core/safe-fs.ts +19 -2
- package/src/core/sandbox.ts +217 -0
- 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 +26 -17
- package/src/core/slide-eval-worker.ts +1 -0
- package/src/core/slide-measure.ts +51 -1
- package/src/core/slide-script-eval.ts +12 -8
- package/src/core/slide-script.ts +114 -20
- package/src/core/spawn.ts +131 -32
- package/src/core/sync.ts +5 -14
- package/src/core/tar.ts +315 -0
- package/src/core/text-contrast.ts +2 -0
- package/src/core/toml-keys.ts +116 -51
- package/src/core/url-attributes.ts +27 -9
- package/src/core/visual-cache.ts +2 -0
- package/src/core/visual.ts +182 -24
- package/src/core/voice.ts +5 -6
- package/src/core/zod.ts +9 -1
- package/src/server/auth.ts +21 -0
- package/src/server/dev.ts +26 -7
- package/src/server/lock.ts +50 -7
- package/src/server/routes.ts +7 -2
- package/src/server/serial.ts +8 -2
- package/src/theme/default.css +4 -0
- package/src/video/bake.ts +6 -2
- package/src/video/mux.ts +27 -3
- package/src/video/recorder.ts +3 -2
- package/src/video/worker.ts +1 -0
- package/src/voice/engine.ts +81 -18
package/src/core/mv.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
// biome-ignore lint/style/noRestrictedImports: renameSync and unlinkSync, for mv's steps, each undone on failure; safe-fs does not rename.
|
|
2
|
+
import { existsSync, renameSync, unlinkSync } from "node:fs";
|
|
2
3
|
import { basename } from "node:path";
|
|
3
4
|
import { deckPaths } from "./deck-paths.ts";
|
|
4
5
|
import { DekError } from "./error.ts";
|
|
5
|
-
import { escapeRegExp } from "./escape.ts";
|
|
6
|
+
import { escapeRegExp, isPlainWord, shellName } from "./escape.ts";
|
|
6
7
|
import { joinLines, sectionChunks, splitLines } from "./lines.ts";
|
|
7
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
asResolvedDeck,
|
|
10
|
+
type ResolvedDeck,
|
|
11
|
+
readDeckFile,
|
|
12
|
+
requireSection,
|
|
13
|
+
SLIDE_SIDECARS,
|
|
14
|
+
} from "./resolve.ts";
|
|
15
|
+
import { writeInside } from "./safe-fs.ts";
|
|
8
16
|
import { Id } from "./schema.ts";
|
|
9
17
|
import { deckLayouts, skeletonHtml } from "./skeleton.ts";
|
|
10
18
|
import { skeletonRecord } from "./skeleton-record.ts";
|
|
@@ -16,11 +24,11 @@ export function renameSection(input: string | ResolvedDeck, from: string, to: st
|
|
|
16
24
|
if (!Id.safeParse(to).success) {
|
|
17
25
|
throw new DekError(`invalid id "${to}"`, { hint: "use [a-z0-9-] with at least one letter" });
|
|
18
26
|
}
|
|
19
|
-
const { deck } = asResolvedDeck(input);
|
|
27
|
+
const { project, deck } = asResolvedDeck(input);
|
|
20
28
|
const has = (slug: string): boolean => deck.deck.sections.some((entry) => entry.slug === slug);
|
|
21
29
|
if (!has(from) && has(to)) {
|
|
22
30
|
// script.md was edited first; only the files still carry the old id.
|
|
23
|
-
const steps = slideFileSteps(deck, from, to);
|
|
31
|
+
const steps = slideFileSteps(project.root, deck, from, to);
|
|
24
32
|
if (steps.length === 0 && !existsSync(deckPaths(deck.dir).slide(to, ".html"))) {
|
|
25
33
|
throw new DekError(`slide "${from}" not found`, {
|
|
26
34
|
path: deckPaths(deck.dir).slide(from, ".html"),
|
|
@@ -38,7 +46,7 @@ export function renameSection(input: string | ResolvedDeck, from: string, to: st
|
|
|
38
46
|
});
|
|
39
47
|
}
|
|
40
48
|
|
|
41
|
-
const source =
|
|
49
|
+
const source = readDeckFile(deck.dir, deck.scriptPath) ?? "";
|
|
42
50
|
const lines = splitLines(source);
|
|
43
51
|
const headingIndex = section.line - 1;
|
|
44
52
|
const heading = lines[headingIndex];
|
|
@@ -50,8 +58,8 @@ export function renameSection(input: string | ResolvedDeck, from: string, to: st
|
|
|
50
58
|
}
|
|
51
59
|
lines[headingIndex] = rewriteHeadingId(heading, from, to);
|
|
52
60
|
applySteps([
|
|
53
|
-
writeStep(deck.scriptPath, source, joinLines(source, lines)),
|
|
54
|
-
...slideFileSteps(deck, from, to),
|
|
61
|
+
writeStep(project.root, deck.scriptPath, source, joinLines(source, lines)),
|
|
62
|
+
...slideFileSteps(project.root, deck, from, to),
|
|
55
63
|
]);
|
|
56
64
|
}
|
|
57
65
|
|
|
@@ -63,16 +71,22 @@ export function renameSection(input: string | ResolvedDeck, from: string, to: st
|
|
|
63
71
|
* the author wrote, one under each id, stop the rename; everything that could stop it is worked
|
|
64
72
|
* out before any file changes.
|
|
65
73
|
*/
|
|
66
|
-
function slideFileSteps(
|
|
74
|
+
function slideFileSteps(
|
|
75
|
+
root: string,
|
|
76
|
+
deck: ResolvedDeck["deck"],
|
|
77
|
+
from: string,
|
|
78
|
+
to: string,
|
|
79
|
+
): FileStep[] {
|
|
67
80
|
const paths = deckPaths(deck.dir);
|
|
68
81
|
const layouts = deckLayouts(deck.dir);
|
|
69
82
|
const record = skeletonRecord(deck.dir);
|
|
70
83
|
const read = (slug: string): SlideFile | undefined => {
|
|
71
84
|
const path = paths.slide(slug, ".html");
|
|
72
|
-
|
|
85
|
+
// What is read here may be written under the other id: a link out of the project is refused.
|
|
86
|
+
const html = readDeckFile(deck.dir, path);
|
|
87
|
+
if (html === undefined) {
|
|
73
88
|
return undefined;
|
|
74
89
|
}
|
|
75
|
-
const html = readFileSync(path, "utf8");
|
|
76
90
|
return { path, html, authored: !record.owns(html, skeletonHtml(deck.deck, slug, layouts)) };
|
|
77
91
|
};
|
|
78
92
|
const source = read(from);
|
|
@@ -93,16 +107,16 @@ function slideFileSteps(deck: ResolvedDeck["deck"], from: string, to: string): F
|
|
|
93
107
|
const kept = source && (source.authored || !target) ? source : target;
|
|
94
108
|
if (source && kept === source) {
|
|
95
109
|
steps.push(
|
|
96
|
-
target ? writeStep(toPath, target.html, source.html) : renameStep(source.path, toPath),
|
|
110
|
+
target ? writeStep(root, toPath, target.html, source.html) : renameStep(source.path, toPath),
|
|
97
111
|
);
|
|
98
112
|
}
|
|
99
113
|
if (source && target) {
|
|
100
|
-
steps.push(removeStep(source.path, source.html));
|
|
114
|
+
steps.push(removeStep(root, source.path, source.html));
|
|
101
115
|
}
|
|
102
116
|
if (kept) {
|
|
103
117
|
const next = relabel(kept.html, from, to);
|
|
104
118
|
if (next !== kept.html) {
|
|
105
|
-
steps.push(writeStep(toPath, kept.html, next));
|
|
119
|
+
steps.push(writeStep(root, toPath, kept.html, next));
|
|
106
120
|
}
|
|
107
121
|
}
|
|
108
122
|
for (const ext of SLIDE_SIDECARS) {
|
|
@@ -112,7 +126,7 @@ function slideFileSteps(deck: ResolvedDeck["deck"], from: string, to: string): F
|
|
|
112
126
|
}
|
|
113
127
|
}
|
|
114
128
|
if (voice) {
|
|
115
|
-
steps.push(writeStep(voice.path, voice.source, voice.next));
|
|
129
|
+
steps.push(writeStep(root, voice.path, voice.source, voice.next));
|
|
116
130
|
}
|
|
117
131
|
return steps;
|
|
118
132
|
}
|
|
@@ -131,23 +145,39 @@ function bothHold(fromPath: string, toPath: string, from: string, to: string): D
|
|
|
131
145
|
const what = fromPath.endsWith(".html") ? "hold a slide you wrote" : "exist";
|
|
132
146
|
return new DekError(`${fromName} and ${toName} both ${what}`, {
|
|
133
147
|
path: fromPath,
|
|
134
|
-
hint: `merge them into ${toName} and remove ${fromName}, then run
|
|
148
|
+
hint: `merge them into ${toName} and remove ${isPlainWord(from) ? fromName : "the other"}, then run ${again(from, to)}`,
|
|
135
149
|
});
|
|
136
150
|
}
|
|
137
151
|
|
|
138
|
-
/**
|
|
152
|
+
/**
|
|
153
|
+
* The rename to run again: as a command when each name has a word every shell reads alike, after
|
|
154
|
+
* `--` when one starts with a dash; else as the one that was run.
|
|
155
|
+
*/
|
|
156
|
+
function again(from: string, to: string): string {
|
|
157
|
+
const [source, target] = [shellName(from), shellName(to)];
|
|
158
|
+
if (source === undefined || target === undefined) {
|
|
159
|
+
return "that `dekc mv` again";
|
|
160
|
+
}
|
|
161
|
+
const end = from.startsWith("-") || to.startsWith("-") ? "-- " : "";
|
|
162
|
+
return `\`dekc mv ${end}${source} ${target}\` again`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* One file change `dekc mv` makes, with how to take it back. A write goes to a new file renamed
|
|
167
|
+
* into place, so a crash part way leaves the old file or the new one, never half of one.
|
|
168
|
+
*/
|
|
139
169
|
type FileStep = { path: string; apply: () => void; undo: () => void };
|
|
140
170
|
|
|
141
|
-
function writeStep(path: string, before: string, after: string): FileStep {
|
|
171
|
+
function writeStep(root: string, path: string, before: string, after: string): FileStep {
|
|
142
172
|
return {
|
|
143
173
|
path,
|
|
144
|
-
apply: () =>
|
|
145
|
-
undo: () =>
|
|
174
|
+
apply: () => writeInside(path, after, root),
|
|
175
|
+
undo: () => writeInside(path, before, root),
|
|
146
176
|
};
|
|
147
177
|
}
|
|
148
178
|
|
|
149
|
-
function removeStep(path: string, before: string): FileStep {
|
|
150
|
-
return { path, apply: () => unlinkSync(path), undo: () =>
|
|
179
|
+
function removeStep(root: string, path: string, before: string): FileStep {
|
|
180
|
+
return { path, apply: () => unlinkSync(path), undo: () => writeInside(path, before, root) };
|
|
151
181
|
}
|
|
152
182
|
|
|
153
183
|
function renameStep(from: string, to: string): FileStep {
|
|
@@ -192,10 +222,10 @@ function planVoiceBeatKeys(
|
|
|
192
222
|
to: string,
|
|
193
223
|
): { path: string; source: string; next: string } | undefined {
|
|
194
224
|
const path = deckPaths(deckDir).voiceToml;
|
|
195
|
-
|
|
225
|
+
const source = readDeckFile(deckDir, path);
|
|
226
|
+
if (source === undefined) {
|
|
196
227
|
return undefined;
|
|
197
228
|
}
|
|
198
|
-
const source = readFileSync(path, "utf8");
|
|
199
229
|
let parsed: Record<string, unknown>;
|
|
200
230
|
try {
|
|
201
231
|
parsed = Bun.TOML.parse(source) as Record<string, unknown>;
|
|
@@ -248,7 +278,7 @@ export function reorderSection(
|
|
|
248
278
|
place: SectionPlace,
|
|
249
279
|
): void {
|
|
250
280
|
const target = "before" in place ? place.before : place.after;
|
|
251
|
-
const { deck } = asResolvedDeck(input);
|
|
281
|
+
const { project, deck } = asResolvedDeck(input);
|
|
252
282
|
const sections = deck.deck.sections;
|
|
253
283
|
const fromIndex = sections.findIndex((entry) => entry.slug === slug);
|
|
254
284
|
if (fromIndex < 0) {
|
|
@@ -259,7 +289,7 @@ export function reorderSection(
|
|
|
259
289
|
requireSection(deck, target);
|
|
260
290
|
}
|
|
261
291
|
|
|
262
|
-
const source =
|
|
292
|
+
const source = readDeckFile(deck.dir, deck.scriptPath) ?? "";
|
|
263
293
|
const lines = splitLines(source);
|
|
264
294
|
const firstLine = sections[0]?.line;
|
|
265
295
|
if (firstLine === undefined) {
|
|
@@ -281,7 +311,7 @@ export function reorderSection(
|
|
|
281
311
|
});
|
|
282
312
|
const insertAt = "before" in place ? remainingTarget : remainingTarget + 1;
|
|
283
313
|
chunks.splice(insertAt, 0, moved);
|
|
284
|
-
|
|
314
|
+
writeInside(deck.scriptPath, joinLines(source, [...head, ...chunks.flat()]), project.root);
|
|
285
315
|
}
|
|
286
316
|
|
|
287
317
|
function rewriteHeadingId(heading: string, from: string, to: string): string {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orders two strings by their UTF-16 code units, as `Array.prototype.sort` does with no
|
|
3
|
+
* comparator, so every list dek sorts comes out in one order, the same on every machine.
|
|
4
|
+
*
|
|
5
|
+
* `localeCompare` with no locale follows the machine's, so a teammate set to Swedish or Turkish
|
|
6
|
+
* would write dict.toml in another order. A fixed locale is no cure: its collation comes from
|
|
7
|
+
* the ICU the runtime has, which Bun takes from the system on macOS and bundles on Linux, and it
|
|
8
|
+
* counts a precomposed `ä` and an `a` with a combining mark as equal, leaving their order to
|
|
9
|
+
* whichever came first. Code units cost a dictionary's order: `B` comes before `a`, `ä` after
|
|
10
|
+
* `z`, and Japanese reads hiragana, then katakana, then kanji by code point.
|
|
11
|
+
*/
|
|
12
|
+
export function compareCodeUnits(a: string, b: string): number {
|
|
13
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
14
|
+
}
|
package/src/core/pack.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { DekError } from "./error.ts";
|
|
|
5
5
|
import { licenseText } from "./licenses.ts";
|
|
6
6
|
import { type DistOptions, distFile } from "./path.ts";
|
|
7
7
|
import { keepsInDeck, LICENSE_NAMES, MAX_SNAPSHOT_BYTES } from "./ref.ts";
|
|
8
|
-
import { isRefDeckName } from "./ref-name.ts";
|
|
8
|
+
import { isRefDeckName, REF_DECK_NAME_RULE } from "./ref-name.ts";
|
|
9
9
|
import { followInside, requireInside, writeInside } from "./safe-fs.ts";
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -22,7 +22,7 @@ export async function packDeck(
|
|
|
22
22
|
if (!isRefDeckName(deck.name)) {
|
|
23
23
|
throw new DekError(`deck "${deck.name}" cannot be read as a ref by that name`, {
|
|
24
24
|
path: deck.dir,
|
|
25
|
-
hint:
|
|
25
|
+
hint: `${REF_DECK_NAME_RULE}; rename the deck's folder, e.g. to 2026-10-talk, and pack it again`,
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
28
|
// Each file by its path in the pack and where it really is, measured before any is read.
|
package/src/core/path.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { holdsHidden } from "./foreign-text.ts";
|
|
3
4
|
|
|
4
5
|
export function moduleFilePath(url: string | URL): string {
|
|
5
6
|
return fileURLToPath(url);
|
|
@@ -16,8 +17,12 @@ export function deckProjectRoot(deckDir: string): string {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export const DECK_NAME_HINT =
|
|
19
|
-
'use a plain name like 2026-10-talk: no "/" or "\\", no control
|
|
20
|
+
'use a plain name like 2026-10-talk: no "/" or "\\", no control character or one a person cannot see, no leading "-"';
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* A name `dekc new` and `dekc init` may give a deck. A pack's ref takes its deck's name, so, as
|
|
24
|
+
* `dekc pack` does, these refuse a control character and one a person cannot see.
|
|
25
|
+
*/
|
|
21
26
|
export function isDeckName(name: string): boolean {
|
|
22
27
|
return (
|
|
23
28
|
name.length > 0 &&
|
|
@@ -28,7 +33,8 @@ export function isDeckName(name: string): boolean {
|
|
|
28
33
|
// A leading "-" reads as a flag to every tool the name is passed to.
|
|
29
34
|
!name.startsWith("-") &&
|
|
30
35
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: a control in a name is the problem.
|
|
31
|
-
!/[\x00-\x1f\x7f]/.test(name)
|
|
36
|
+
!/[\x00-\x1f\x7f-\x9f]/.test(name) &&
|
|
37
|
+
!holdsHidden(name)
|
|
32
38
|
);
|
|
33
39
|
}
|
|
34
40
|
|
|
@@ -15,6 +15,7 @@ declare module "playwright" {
|
|
|
15
15
|
clip?: { x: number; y: number; width: number; height: number };
|
|
16
16
|
type?: "png" | "jpeg";
|
|
17
17
|
quality?: number;
|
|
18
|
+
omitBackground?: boolean;
|
|
18
19
|
}): Promise<Buffer>;
|
|
19
20
|
addScriptTag(options: { content: string }): Promise<unknown>;
|
|
20
21
|
pdf(options: {
|
|
@@ -26,6 +27,11 @@ declare module "playwright" {
|
|
|
26
27
|
}): Promise<Buffer>;
|
|
27
28
|
close(): Promise<void>;
|
|
28
29
|
context(): BrowserContext;
|
|
30
|
+
mainFrame(): Frame;
|
|
31
|
+
frames(): Frame[];
|
|
32
|
+
};
|
|
33
|
+
export type Frame = {
|
|
34
|
+
url(): string;
|
|
29
35
|
};
|
|
30
36
|
/** A DevTools protocol session: Chromium's own answers, for what the page does not expose. */
|
|
31
37
|
export type CDPSession = {
|
|
@@ -43,10 +49,16 @@ declare module "playwright" {
|
|
|
43
49
|
viewport?: Viewport;
|
|
44
50
|
deviceScaleFactor?: number;
|
|
45
51
|
offline?: boolean;
|
|
52
|
+
httpCredentials?: { username: string; password: string };
|
|
46
53
|
}): Promise<BrowserContext>;
|
|
54
|
+
newBrowserCDPSession(): Promise<CDPSession>;
|
|
47
55
|
close(): Promise<void>;
|
|
48
56
|
};
|
|
49
57
|
export const chromium: {
|
|
50
|
-
launch(options?: {
|
|
58
|
+
launch(options?: {
|
|
59
|
+
headless?: boolean;
|
|
60
|
+
chromiumSandbox?: boolean;
|
|
61
|
+
executablePath?: string;
|
|
62
|
+
}): Promise<Browser>;
|
|
51
63
|
};
|
|
52
64
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// biome-ignore lint/style/noRestrictedImports: mkdirSync, in a worker, for the folder its parent chose in dek's cache, which Chromium writes into.
|
|
1
2
|
import { mkdirSync, readFileSync } from "node:fs";
|
|
2
3
|
import { dirname } from "node:path";
|
|
3
4
|
import type { Page } from "playwright";
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { Browser, BrowserContext, Page } from "playwright";
|
|
2
|
+
import { findMisalignments } from "./alignment.ts";
|
|
2
3
|
import { freezeTransition, loadVideoDoc } from "./capture-go.ts";
|
|
3
4
|
import { measureFill } from "./fill.ts";
|
|
4
5
|
import { finishBeat } from "./finish-beat.ts";
|
|
6
|
+
import { findLineBreaks, measureTextFlowsInPage } from "./line-break.ts";
|
|
5
7
|
import {
|
|
6
8
|
crossesEdge,
|
|
7
9
|
findClippedText,
|
|
@@ -31,6 +33,7 @@ import type {
|
|
|
31
33
|
PagesRequest,
|
|
32
34
|
PagesResponse,
|
|
33
35
|
PdfRequest,
|
|
36
|
+
PptxPictureFile,
|
|
34
37
|
PptxRequest,
|
|
35
38
|
PptxResponse,
|
|
36
39
|
ResponseTo,
|
|
@@ -39,8 +42,24 @@ import type {
|
|
|
39
42
|
} from "./playwright.ts";
|
|
40
43
|
import { captureMotion } from "./playwright-motion.ts";
|
|
41
44
|
import { renderSheets } from "./playwright-sheet.ts";
|
|
45
|
+
import { mapPictures } from "./pptx-package.ts";
|
|
46
|
+
import {
|
|
47
|
+
describePptxPicturesInPage,
|
|
48
|
+
hidePptxLiftedInPage,
|
|
49
|
+
measurePptxPaintInPage,
|
|
50
|
+
measurePptxThemeInPage,
|
|
51
|
+
PPTX_PSEUDO_ATTR,
|
|
52
|
+
pptxFontProbesInPage,
|
|
53
|
+
pptxPictureEmptyInPage,
|
|
54
|
+
pptxSvgInPage,
|
|
55
|
+
SVG_CHECKED,
|
|
56
|
+
SVG_PAINT,
|
|
57
|
+
showOnlyPptxNodeInPage,
|
|
58
|
+
} from "./pptx-paint.ts";
|
|
59
|
+
import { planPptxLayers, pptxPageFacts } from "./pptx-plan.ts";
|
|
42
60
|
import { measurePptxTextsInPage, PPTX_OWNER_ATTR } from "./pptx-text.ts";
|
|
43
|
-
import {
|
|
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),
|
|
@@ -421,13 +581,18 @@ async function visitPage(
|
|
|
421
581
|
* measuring again: first what the slide's script drew, then the slide's own CSS. A text that
|
|
422
582
|
* clears its threshold, or an element that no longer crosses an edge, once a layer is gone is
|
|
423
583
|
* that layer's to fix. With both gone, a text that still fails is the theme's, and an element that
|
|
424
|
-
* still overflows is its content's: too much for the theme's sizes.
|
|
584
|
+
* still overflows is its content's: too much for the theme's sizes. A text that shows no glyph
|
|
585
|
+
* once a layer is gone is no longer measured as the audience sees it: one the slide's CSS alone
|
|
586
|
+
* shows is marked so, and one the draw alone shows is no layer's unless it still fails with both
|
|
587
|
+
* gone. The page is not put back.
|
|
425
588
|
*/
|
|
426
589
|
async function attributeFindings(
|
|
427
590
|
page: Page,
|
|
428
591
|
pending: { failing: Failing[]; crossing: Crossing[] },
|
|
429
592
|
): Promise<void> {
|
|
430
593
|
let { failing, crossing } = pending;
|
|
594
|
+
// Texts that showed no glyph the last time they were measured.
|
|
595
|
+
const unseen = new Set<Failing>();
|
|
431
596
|
for (const layer of LAYERS) {
|
|
432
597
|
if (failing.length === 0 && crossing.length === 0) {
|
|
433
598
|
return;
|
|
@@ -455,20 +620,32 @@ async function attributeFindings(
|
|
|
455
620
|
const texts = await pageTexts(page, measured);
|
|
456
621
|
const contrasts = await textContrasts(page, texts);
|
|
457
622
|
const now = new Map(texts.map((text, at) => [text.key, { text, contrast: contrasts[at] }]));
|
|
458
|
-
failing = failing.filter((
|
|
459
|
-
const measuredText = now.get(key);
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
623
|
+
failing = failing.filter((entry) => {
|
|
624
|
+
const measuredText = now.get(entry.key);
|
|
625
|
+
const seen = !unseen.has(entry);
|
|
626
|
+
if (!measuredText?.contrast) {
|
|
627
|
+
if (layer.origin === "slide" && seen) {
|
|
628
|
+
entry.finding.onlyWithSlideCss = true;
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
unseen.add(entry);
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
if (measuredText.contrast.ratio >= contrastThreshold(measuredText.text)) {
|
|
635
|
+
// Hidden with the layer before gone, it may owe its low contrast to that layer as well.
|
|
636
|
+
if (seen) {
|
|
637
|
+
entry.finding.origin = layer.origin;
|
|
638
|
+
}
|
|
465
639
|
return false;
|
|
466
640
|
}
|
|
641
|
+
unseen.delete(entry);
|
|
467
642
|
return true;
|
|
468
643
|
});
|
|
469
644
|
}
|
|
470
|
-
for (const
|
|
471
|
-
|
|
645
|
+
for (const entry of failing) {
|
|
646
|
+
if (!unseen.has(entry)) {
|
|
647
|
+
entry.finding.origin = "theme";
|
|
648
|
+
}
|
|
472
649
|
}
|
|
473
650
|
for (const { finding } of crossing) {
|
|
474
651
|
finding.origin = "content";
|