@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/cli/scope.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { join, relative } from "node:path";
|
|
3
2
|
import { DekError } from "../core/error.ts";
|
|
3
|
+
import { shellName, shellQuote } from "../core/escape.ts";
|
|
4
4
|
import {
|
|
5
5
|
isRefName,
|
|
6
|
+
packFile,
|
|
6
7
|
parseRefSource,
|
|
7
|
-
REF_MARKER,
|
|
8
|
-
readRefMeta,
|
|
9
8
|
refLicense,
|
|
9
|
+
refReadOnlyError,
|
|
10
10
|
refState,
|
|
11
|
+
requireProject,
|
|
11
12
|
} from "../core/ref.ts";
|
|
12
13
|
import {
|
|
13
14
|
locateDeck,
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
} from "../core/resolve.ts";
|
|
19
20
|
import { suggest } from "../core/suggest.ts";
|
|
20
21
|
|
|
21
|
-
export { requireSection };
|
|
22
|
+
export { requireProject, requireSection };
|
|
22
23
|
|
|
23
24
|
export type Scope = {
|
|
24
25
|
project: Project;
|
|
@@ -51,18 +52,6 @@ export type DecksTarget = {
|
|
|
51
52
|
ref?: RefInfo;
|
|
52
53
|
};
|
|
53
54
|
|
|
54
|
-
export function requireProject(cwd: string): Project {
|
|
55
|
-
const project = resolveProject(cwd);
|
|
56
|
-
if (existsSync(join(project.root, REF_MARKER))) {
|
|
57
|
-
const name = readRefMeta(project.root)?.name ?? "<ref>";
|
|
58
|
-
throw new DekError("this directory is inside a ref; refs are read-only", {
|
|
59
|
-
path: project.root,
|
|
60
|
-
hint: `run dek from your own project instead: \`dekc show ${name} <slug>\``,
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
return project;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
55
|
export function inferDeckName(project: Project, cwd: string): string | undefined {
|
|
67
56
|
const located = locateDeck(project.root, cwd);
|
|
68
57
|
if (!located) {
|
|
@@ -138,9 +127,7 @@ export function resolveScope(cwd: string, options: { deck?: string } = {}): Scop
|
|
|
138
127
|
return { project };
|
|
139
128
|
}
|
|
140
129
|
if (isRefName(name)) {
|
|
141
|
-
throw
|
|
142
|
-
hint: `read it with \`dekc show ${name} <slug>\`, then copy what you need into your own deck and rewrite it in your theme`,
|
|
143
|
-
});
|
|
130
|
+
throw refReadOnlyError(name);
|
|
144
131
|
}
|
|
145
132
|
const deck = project.decks.find((entry) => entry.name === name);
|
|
146
133
|
if (!deck) {
|
|
@@ -148,10 +135,11 @@ export function resolveScope(cwd: string, options: { deck?: string } = {}): Scop
|
|
|
148
135
|
if (failed) {
|
|
149
136
|
throw failed.error;
|
|
150
137
|
}
|
|
151
|
-
const
|
|
138
|
+
const near = suggest(name, [
|
|
152
139
|
...project.decks.map((entry) => entry.name),
|
|
153
140
|
...project.failed.map((entry) => entry.name),
|
|
154
141
|
]);
|
|
142
|
+
const guess = near === undefined ? undefined : shellName(near);
|
|
155
143
|
throw new DekError(`deck "${name}" not found`, {
|
|
156
144
|
path: join(project.root, "decks", name),
|
|
157
145
|
hint: guess ? `did you mean \`${guess}\`? run \`dekc ls\` for every deck` : "run `dekc ls`",
|
|
@@ -273,19 +261,25 @@ function resolveRef(cwd: string, arg: string): ReadableDeck & { ref: RefInfo } {
|
|
|
273
261
|
if (pinned === undefined) {
|
|
274
262
|
throw new DekError(`ref "${source.name}" is not added`, {
|
|
275
263
|
path: project.configPath,
|
|
276
|
-
hint:
|
|
264
|
+
hint:
|
|
265
|
+
source.kind === "file"
|
|
266
|
+
? "add it from the pack you were sent: `dekc ref <file>.tgz`"
|
|
267
|
+
: `run \`dekc ref ${shellQuote(arg)}\``,
|
|
277
268
|
});
|
|
278
269
|
}
|
|
279
|
-
|
|
270
|
+
// What adds the ref again: a pack's ref from its file, any other from its name.
|
|
271
|
+
const added = source.kind === "file" ? packFile(source.deck) : `${source.name}@${pinned}`;
|
|
272
|
+
const again = `dekc ref ${shellQuote(added)}`;
|
|
273
|
+
if (source.kind === "github" && source.rev !== undefined && source.rev !== pinned) {
|
|
280
274
|
throw new DekError(`"${arg}" is not the pinned version, ${pinned.slice(0, 7)}`, {
|
|
281
275
|
path: project.configPath,
|
|
282
|
-
hint: `run \`dekc ref ${arg}\` to pin that version, or drop @${source.rev}`,
|
|
276
|
+
hint: `run \`dekc ref ${shellQuote(arg)}\` to pin that version, or drop @${source.rev}`,
|
|
283
277
|
});
|
|
284
278
|
}
|
|
285
279
|
if (!fetched) {
|
|
286
280
|
throw new DekError(`ref "${source.name}" is not fetched`, {
|
|
287
281
|
path: dir,
|
|
288
|
-
hint: `run
|
|
282
|
+
hint: `run \`${again}\``,
|
|
289
283
|
});
|
|
290
284
|
}
|
|
291
285
|
const snapshot = resolveProject(dir);
|
|
@@ -297,12 +291,16 @@ function resolveRef(cwd: string, arg: string): ReadableDeck & { ref: RefInfo } {
|
|
|
297
291
|
}
|
|
298
292
|
throw new DekError(`ref "${source.name}" has no deck "${source.deck}"`, {
|
|
299
293
|
path: dir,
|
|
300
|
-
hint: `run
|
|
294
|
+
hint: `run \`${again}\` to fetch it again`,
|
|
301
295
|
});
|
|
302
296
|
}
|
|
303
297
|
return {
|
|
304
298
|
project: snapshot,
|
|
305
|
-
|
|
299
|
+
// Read where it is, but cached in this project: nothing is written in a snapshot.
|
|
300
|
+
deck: {
|
|
301
|
+
...found,
|
|
302
|
+
cache: { dir: join(project.root, ".cache", relative(project.root, dir)), root: project.root },
|
|
303
|
+
},
|
|
306
304
|
ref: { name: source.name, rev: pinned, dir, license: refLicense(dir) },
|
|
307
305
|
};
|
|
308
306
|
}
|
package/src/cli/shot.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DekError } from "../core/error.ts";
|
|
2
|
+
import { shellQuote } from "../core/escape.ts";
|
|
2
3
|
import { parseMorphAt, shotMorph } from "../core/shot/morph.ts";
|
|
3
4
|
import { type ShotMotion, shotMotion } from "../core/shot/motion.ts";
|
|
4
5
|
import { type ShotFile, shotDeck, shotSheet } from "../core/shot/still.ts";
|
|
@@ -91,7 +92,7 @@ export function parseShotMode(options: ShotCommandOptions): ShotMode {
|
|
|
91
92
|
if (slug) {
|
|
92
93
|
refuse(
|
|
93
94
|
"--sheet with a slug",
|
|
94
|
-
`--sheet tiles every slide; for one slide's beats, run \`dekc shot ${slug} --motion\``,
|
|
95
|
+
`--sheet tiles every slide; for one slide's beats, run \`dekc shot ${shellQuote(slug)} --motion\``,
|
|
95
96
|
);
|
|
96
97
|
}
|
|
97
98
|
return { kind: "sheet" };
|
package/src/cli/text.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// How each command's result reads as text; commands.ts says which command prints which.
|
|
2
2
|
// The pieces the dev server's log shares, such as diagnostics and tables, are in format.ts.
|
|
3
3
|
import type { Diagnostic, SkippedCheck } from "../core/diagnostic.ts";
|
|
4
|
+
import { shellQuote } from "../core/escape.ts";
|
|
5
|
+
import { unprinted } from "../core/prose.ts";
|
|
6
|
+
import { packFile, parseRefSource } from "../core/ref-name.ts";
|
|
4
7
|
import { formatClock } from "../core/timing.ts";
|
|
5
8
|
import type { AnnotationsCliResult } from "./annotations.ts";
|
|
6
9
|
import type { BuildCliResult } from "./build.ts";
|
|
@@ -13,7 +16,9 @@ import type { LsDeckResult, LsListResult } from "./ls.ts";
|
|
|
13
16
|
import type { MarksCliResult } from "./marks.ts";
|
|
14
17
|
import type { MvResult } from "./mv.ts";
|
|
15
18
|
import type { NewResult } from "./new.ts";
|
|
19
|
+
import type { PackCliResult } from "./pack.ts";
|
|
16
20
|
import type { PdfCliResult } from "./pdf.ts";
|
|
21
|
+
import type { PptxCliResult } from "./pptx.ts";
|
|
17
22
|
import type { RefCliResult } from "./ref.ts";
|
|
18
23
|
import type { ShotCliResult } from "./shot.ts";
|
|
19
24
|
import type { ShowResult } from "./show.ts";
|
|
@@ -130,7 +135,8 @@ export function formatInit(data: InitResult): string {
|
|
|
130
135
|
/** The deck, and any of dek's own files it brought up to date: the rest is the deck's, and new. */
|
|
131
136
|
export function formatNew(data: NewResult): string {
|
|
132
137
|
const updated = data.updated.map((path) => ` ${path} (updated)`);
|
|
133
|
-
|
|
138
|
+
const warnings = data.warnings.map((warning) => `warning: ${warning}`);
|
|
139
|
+
return withNext([`created deck ${data.name}`, ...updated, ...warnings].join("\n"), data.next);
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
export function formatMv(data: MvResult): string {
|
|
@@ -227,6 +233,31 @@ export function formatPdf(data: PdfCliResult): string {
|
|
|
227
233
|
return data.outs.map((path) => `wrote ${path}`).join("\n");
|
|
228
234
|
}
|
|
229
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Each file written, and what it laid over its pictures, naming the slides that kept everything
|
|
238
|
+
* in the picture, which are the ones to rewrite toward shapes.
|
|
239
|
+
*/
|
|
240
|
+
export function formatPptx(data: PptxCliResult): string {
|
|
241
|
+
return data.outs
|
|
242
|
+
.flatMap((path, index) => {
|
|
243
|
+
const lifted = data.lifted?.[index];
|
|
244
|
+
if (!lifted) {
|
|
245
|
+
return [`wrote ${path}`];
|
|
246
|
+
}
|
|
247
|
+
const none = lifted.untouched.length > 0 ? `; nothing on ${lifted.untouched.join(", ")}` : "";
|
|
248
|
+
const count = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
|
|
249
|
+
return [
|
|
250
|
+
`wrote ${path}`,
|
|
251
|
+
` laid ${count(lifted.shapes, "shape")} and ${count(lifted.pictures, "picture")} over the picture${none}`,
|
|
252
|
+
];
|
|
253
|
+
})
|
|
254
|
+
.join("\n");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function formatPack(data: PackCliResult): string {
|
|
258
|
+
return [formatPdf(data), ...data.warnings.map((warning) => `warning: ${warning}`)].join("\n");
|
|
259
|
+
}
|
|
260
|
+
|
|
230
261
|
export function formatLs(data: LsListResult | LsDeckResult): string {
|
|
231
262
|
switch (data.kind) {
|
|
232
263
|
case "list": {
|
|
@@ -339,6 +370,7 @@ export function formatTheme(data: ThemeResult): string {
|
|
|
339
370
|
...data.layouts.map(
|
|
340
371
|
(layout) => ` ${layout.name}${layout.example === undefined ? " (no example)" : ""}`,
|
|
341
372
|
),
|
|
373
|
+
...(data.unprintedLayouts ? [` and ${unprinted(data.unprintedLayouts)}`] : []),
|
|
342
374
|
"",
|
|
343
375
|
"CLASSES",
|
|
344
376
|
` ${data.classes.join(" ")}`,
|
|
@@ -353,7 +385,7 @@ export function formatTheme(data: ThemeResult): string {
|
|
|
353
385
|
"TOKENS",
|
|
354
386
|
...data.tokens.map((token) => ` ${token.name.padEnd(width)} ${token.value}`),
|
|
355
387
|
"",
|
|
356
|
-
|
|
388
|
+
`Markup for a layout: dekc theme ${data.ref ? `${shellQuote(data.ref.name)} ` : ""}<layout>`,
|
|
357
389
|
].join("\n");
|
|
358
390
|
}
|
|
359
391
|
|
|
@@ -409,6 +441,14 @@ export function formatShow(data: ShowResult): string {
|
|
|
409
441
|
.join("\n\n");
|
|
410
442
|
}
|
|
411
443
|
|
|
444
|
+
/** What a read does for a ref whose snapshot is not there: fetch it, or unpack its pack. */
|
|
445
|
+
function notFetched(name: string): string {
|
|
446
|
+
const source = parseRefSource(name);
|
|
447
|
+
return source.kind === "file"
|
|
448
|
+
? `(not unpacked; a read unpacks ${packFile(source.deck)})`
|
|
449
|
+
: "(not fetched; a read fetches it)";
|
|
450
|
+
}
|
|
451
|
+
|
|
412
452
|
export function formatRef(data: RefCliResult): string {
|
|
413
453
|
const short = (rev: string) => rev.slice(0, 7);
|
|
414
454
|
switch (data.action) {
|
|
@@ -421,7 +461,8 @@ export function formatRef(data: RefCliResult): string {
|
|
|
421
461
|
return [
|
|
422
462
|
head,
|
|
423
463
|
...data.warnings.map((warning) => `warning: ${warning}`),
|
|
424
|
-
|
|
464
|
+
...(data.pack ? [`commit ${data.pack}: a clone unpacks the ref from it`] : []),
|
|
465
|
+
`read it with \`dekc ls ${shellQuote(data.name)}\` and \`dekc show ${shellQuote(data.name)} <slug>\``,
|
|
425
466
|
].join("\n");
|
|
426
467
|
}
|
|
427
468
|
case "list":
|
|
@@ -433,7 +474,7 @@ export function formatRef(data: RefCliResult): string {
|
|
|
433
474
|
data.refs.map((ref) => [
|
|
434
475
|
ref.name,
|
|
435
476
|
short(ref.rev),
|
|
436
|
-
ref.fetched ? (ref.title ?? "") :
|
|
477
|
+
ref.fetched ? (ref.title ?? "") : notFetched(ref.name),
|
|
437
478
|
ref.slides === undefined ? "" : String(ref.slides),
|
|
438
479
|
]),
|
|
439
480
|
["left", "left", "left", "right"],
|
package/src/cli/theme.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { type CssToken, parseCss } from "../core/css.ts";
|
|
3
3
|
import { deckPaths } from "../core/deck-paths.ts";
|
|
4
4
|
import { DekError } from "../core/error.ts";
|
|
5
|
+
import { isPlainWord } from "../core/escape.ts";
|
|
6
|
+
import { listShown, unprinted } from "../core/prose.ts";
|
|
5
7
|
import { readDeckFile } from "../core/resolve.ts";
|
|
6
8
|
import { type ThemeLayout, themeFacts } from "../core/theme-facts.ts";
|
|
7
9
|
import type { ReadableDeck, RefInfo } from "./scope.ts";
|
|
@@ -15,7 +17,10 @@ export type ThemeResult = {
|
|
|
15
17
|
stateClasses: string[];
|
|
16
18
|
/** The tokens every slide can `var()`. */
|
|
17
19
|
tokens: CssToken[];
|
|
20
|
+
/** The layouts whose names are plain words. A name is the theme's text, maybe a pack's. */
|
|
18
21
|
layouts: ThemeLayout[];
|
|
22
|
+
/** How many layouts the theme has whose names are no plain word, left out of `layouts`. */
|
|
23
|
+
unprintedLayouts?: number;
|
|
19
24
|
/** Set when one layout was asked for; text output prints its example alone. */
|
|
20
25
|
layout?: { name: string; example: string };
|
|
21
26
|
/** Set when the deck is a ref. */
|
|
@@ -34,12 +39,15 @@ export function themeCommand({ deck, ref }: ReadableDeck, name?: string): ThemeR
|
|
|
34
39
|
const { classes, stateClasses, layouts, tokens } = themeFacts(
|
|
35
40
|
parseCss(readDeckFile(deck.dir, path) ?? ""),
|
|
36
41
|
);
|
|
42
|
+
const printed = layouts.filter((layout) => isPlainWord(layout.name));
|
|
43
|
+
const left = layouts.length - printed.length;
|
|
37
44
|
const result: ThemeResult = {
|
|
38
45
|
path,
|
|
39
46
|
classes: [...classes].sort(),
|
|
40
47
|
stateClasses: [...stateClasses].sort(),
|
|
41
48
|
tokens,
|
|
42
|
-
layouts,
|
|
49
|
+
layouts: printed,
|
|
50
|
+
...(left > 0 ? { unprintedLayouts: left } : {}),
|
|
43
51
|
...(ref ? { ref } : {}),
|
|
44
52
|
};
|
|
45
53
|
if (!name) {
|
|
@@ -49,7 +57,13 @@ export function themeCommand({ deck, ref }: ReadableDeck, name?: string): ThemeR
|
|
|
49
57
|
if (!layout) {
|
|
50
58
|
throw new DekError(`layout "${name}" is not defined in theme.css`, {
|
|
51
59
|
path,
|
|
52
|
-
hint: `use one of: ${
|
|
60
|
+
hint: `use one of: ${
|
|
61
|
+
listShown(
|
|
62
|
+
layouts.map((entry) => entry.name),
|
|
63
|
+
isPlainWord,
|
|
64
|
+
unprinted,
|
|
65
|
+
) || "(none)"
|
|
66
|
+
}`,
|
|
53
67
|
});
|
|
54
68
|
}
|
|
55
69
|
if (layout.example === undefined) {
|
package/src/cli/voice.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { DekError } from "../core/error.ts";
|
|
5
5
|
import { deckProjectRoot } from "../core/path.ts";
|
|
6
|
-
import { isCachedFile, writeInside } from "../core/safe-fs.ts";
|
|
6
|
+
import { isCachedFile, requireInside, writeInside } from "../core/safe-fs.ts";
|
|
7
7
|
import { loadVoiceDict, loadVoiceSettings, voiceCacheFile, writeVoiceDict } from "../core/voice.ts";
|
|
8
8
|
import {
|
|
9
9
|
engineBaseUrl,
|
|
@@ -91,8 +91,9 @@ export function pinVoice({ deck }: DeckTarget): VoiceCliResult {
|
|
|
91
91
|
const pinDir = join(deck.dir, "voice", "pin");
|
|
92
92
|
const pinTimeline = join(pinDir, "timeline.json");
|
|
93
93
|
const pinAudio = join(pinDir, "master.wav");
|
|
94
|
-
|
|
95
|
-
writeInside(
|
|
94
|
+
// The folders on the way may be links too; what is pinned comes from inside the project.
|
|
95
|
+
writeInside(pinTimeline, readFileSync(requireInside(timelinePath, root)), root);
|
|
96
|
+
writeInside(pinAudio, readFileSync(requireInside(audioPath, root)), root);
|
|
96
97
|
return { action: "pin", timelinePath: pinTimeline, audioPath: pinAudio };
|
|
97
98
|
}
|
|
98
99
|
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import type { Box, MeasuredElement } from "./slide-measure.ts";
|
|
2
|
+
|
|
3
|
+
/** What lining up needs to know of an element. */
|
|
4
|
+
export type AlignedElement = Pick<
|
|
5
|
+
MeasuredElement,
|
|
6
|
+
| "box"
|
|
7
|
+
| "parent"
|
|
8
|
+
| "rect"
|
|
9
|
+
| "textRects"
|
|
10
|
+
| "baseline"
|
|
11
|
+
| "opacity"
|
|
12
|
+
| "decorative"
|
|
13
|
+
| "inline"
|
|
14
|
+
| "tilted"
|
|
15
|
+
| "paints"
|
|
16
|
+
| "picture"
|
|
17
|
+
| "fontSize"
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
/** An edge items side by side share (`top`, `middle`, `bottom`), or items stacked (`left`, …). */
|
|
21
|
+
export type AlignedEdge = "top" | "middle" | "bottom" | "left" | "center" | "right";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Two items that nearly line up and do not: `element` is the one off the line, `other` the one it
|
|
25
|
+
* misses, both indices into the elements measured. `of` says whether their boxes miss each other
|
|
26
|
+
* or the text that starts inside them.
|
|
27
|
+
*/
|
|
28
|
+
export type Misalignment = {
|
|
29
|
+
element: number;
|
|
30
|
+
other: number;
|
|
31
|
+
edge: AlignedEdge;
|
|
32
|
+
of: "box" | "text";
|
|
33
|
+
/** How far apart, in the slide's own pixels. */
|
|
34
|
+
by: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Edges this close are one line; layout rounds to fractions of a pixel nobody sees. */
|
|
38
|
+
const SAME = 0.5;
|
|
39
|
+
/** Edges closer than this look like one, on a projector as on a laptop. */
|
|
40
|
+
const SEEN = 1.5;
|
|
41
|
+
/** Edges this far apart, or farther, read as a choice; nearer, as a mistake. */
|
|
42
|
+
const NEAR = 8;
|
|
43
|
+
|
|
44
|
+
type Side = "start" | "center" | "end";
|
|
45
|
+
const SIDES: Side[] = ["start", "center", "end"];
|
|
46
|
+
type Axis = Record<Side, AlignedEdge>;
|
|
47
|
+
/** Items side by side, compared down the slide. */
|
|
48
|
+
const ROW: Axis = { start: "top", center: "middle", end: "bottom" };
|
|
49
|
+
/** Items stacked, compared across it. */
|
|
50
|
+
const COLUMN: Axis = { start: "left", center: "center", end: "right" };
|
|
51
|
+
|
|
52
|
+
function along(rect: Box, axis: Axis): Record<Side, number> {
|
|
53
|
+
return axis === ROW
|
|
54
|
+
? { start: rect.top, center: (rect.top + rect.bottom) / 2, end: rect.bottom }
|
|
55
|
+
: { start: rect.left, center: (rect.left + rect.right) / 2, end: rect.right };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Side by side when only their heights overlap, stacked when only their widths do. */
|
|
59
|
+
function relation(a: Box, b: Box): Axis | undefined {
|
|
60
|
+
const across = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
|
61
|
+
const down = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
|
62
|
+
if (down > 0 && across <= 0) {
|
|
63
|
+
return ROW;
|
|
64
|
+
}
|
|
65
|
+
if (across > 0 && down <= 0) {
|
|
66
|
+
return COLUMN;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function union(rects: Box[]): Box {
|
|
72
|
+
return {
|
|
73
|
+
left: Math.min(...rects.map((rect) => rect.left)),
|
|
74
|
+
top: Math.min(...rects.map((rect) => rect.top)),
|
|
75
|
+
right: Math.max(...rects.map((rect) => rect.right)),
|
|
76
|
+
bottom: Math.max(...rects.map((rect) => rect.bottom)),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const near = (apart: number): boolean => Math.abs(apart) >= SEEN && Math.abs(apart) <= NEAR;
|
|
81
|
+
|
|
82
|
+
/** What an item is, as its tag and first class say, whatever beat it shows at or id it has. */
|
|
83
|
+
const kindOf = (box: string): string => box.replace(/#[^.[]+|\[data-step="[^"]*"\]/g, "");
|
|
84
|
+
|
|
85
|
+
/** An item compared: one element, or the row or the stack several form beside another item. */
|
|
86
|
+
type Group = {
|
|
87
|
+
members: number[];
|
|
88
|
+
rect: Box;
|
|
89
|
+
line: FirstLine | undefined;
|
|
90
|
+
/** Whether every member paints its box or is a picture, so its edges show. */
|
|
91
|
+
framed: boolean;
|
|
92
|
+
kind: string | undefined;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The items on a slide that nearly line up and do not. Items are compared within one parent, and
|
|
97
|
+
* only when they sit side by side, compared by top, middle, and bottom, or stacked, compared by
|
|
98
|
+
* left, center, and right. Several items in a row under one item, or stacked beside it, are
|
|
99
|
+
* compared with it as the row or the stack they form, by the member at that edge. An item lines
|
|
100
|
+
* up when it shares one of those edges, or, side by side, a baseline: a heading over a wider list,
|
|
101
|
+
* an icon centered beside a line of text, and a number on its unit's baseline all pass. Where
|
|
102
|
+
* nothing is shared and the nearest edge is two pixels to eight away, it reads as a mistake; so
|
|
103
|
+
* does a start, top or left, that nearly meets where the end meets, between items that hold text.
|
|
104
|
+
* Items of one kind whose box the audience sees, as a row of cards, are held to more: one that
|
|
105
|
+
* shares its top and nearly its bottom with the others is a little shorter, and ones that start
|
|
106
|
+
* on one line should start their text on one line too.
|
|
107
|
+
*
|
|
108
|
+
* Inline boxes, which sit on a line of text, decoration, what the beat has not shown, what a
|
|
109
|
+
* transform turns, and the parts of a picture, such as an SVG's shapes, are not items. Of two
|
|
110
|
+
* items that miss each other, the one fewer others line up with is off the line, and of two alike
|
|
111
|
+
* the later; each is named once for each of its edges.
|
|
112
|
+
*/
|
|
113
|
+
export function findMisalignments(elements: AlignedElement[]): Misalignment[] {
|
|
114
|
+
const inPicture = (index: number): boolean => {
|
|
115
|
+
for (let at = elements[index]?.parent ?? -1; at >= 0; at = elements[at]?.parent ?? -1) {
|
|
116
|
+
if (elements[at]?.picture) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
};
|
|
122
|
+
const items = elements.flatMap((element, index) =>
|
|
123
|
+
element.opacity >= 0.01 &&
|
|
124
|
+
!element.decorative &&
|
|
125
|
+
!element.inline &&
|
|
126
|
+
!element.tilted &&
|
|
127
|
+
element.rect.right > element.rect.left &&
|
|
128
|
+
element.rect.bottom > element.rect.top &&
|
|
129
|
+
!inPicture(index)
|
|
130
|
+
? [index]
|
|
131
|
+
: [],
|
|
132
|
+
);
|
|
133
|
+
const lines = firstLines(elements);
|
|
134
|
+
const rectOf = (index: number): Box => elements[index]?.rect ?? union([]);
|
|
135
|
+
|
|
136
|
+
// The row or the stack `item` forms beside `other`: the items `other` relates to as it does to
|
|
137
|
+
// `item`, which sit across that from `item` or from one another.
|
|
138
|
+
const groupOf = (item: number, other: number, axis: Axis, siblings: number[]): Group => {
|
|
139
|
+
const across = axis === ROW ? COLUMN : ROW;
|
|
140
|
+
const members = [item];
|
|
141
|
+
for (let at = 0; at < members.length; at++) {
|
|
142
|
+
const member = members[at] ?? item;
|
|
143
|
+
for (const candidate of siblings) {
|
|
144
|
+
if (
|
|
145
|
+
!members.includes(candidate) &&
|
|
146
|
+
candidate !== other &&
|
|
147
|
+
relation(rectOf(candidate), rectOf(other)) === axis &&
|
|
148
|
+
relation(rectOf(candidate), rectOf(member)) === across
|
|
149
|
+
) {
|
|
150
|
+
members.push(candidate);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
members.sort((a, b) => a - b);
|
|
155
|
+
const kinds = new Set(members.map((member) => kindOf(elements[member]?.box ?? "")));
|
|
156
|
+
const memberLines = members.flatMap((member) => lines[member] ?? []);
|
|
157
|
+
return {
|
|
158
|
+
members,
|
|
159
|
+
rect: union(members.map(rectOf)),
|
|
160
|
+
line: memberLines.reduce<FirstLine | undefined>(
|
|
161
|
+
(first, line) => (!first || earlier(line.rect, first.rect) ? line : first),
|
|
162
|
+
undefined,
|
|
163
|
+
),
|
|
164
|
+
framed: members.every(
|
|
165
|
+
(member) => elements[member]?.paints === true || elements[member]?.picture === true,
|
|
166
|
+
),
|
|
167
|
+
kind: kinds.size === 1 ? [...kinds][0] : undefined,
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
// The member whose edge is the group's on that side.
|
|
171
|
+
const at = (group: Group, axis: Axis, side: Side): number =>
|
|
172
|
+
side === "center"
|
|
173
|
+
? (group.members[0] ?? -1)
|
|
174
|
+
: group.members.reduce((best, member) => {
|
|
175
|
+
const value = along(rectOf(member), axis)[side];
|
|
176
|
+
const kept = along(rectOf(best), axis)[side];
|
|
177
|
+
return (side === "start" ? value < kept : value > kept) ? member : best;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
type Miss = Misalignment & { key: string };
|
|
181
|
+
const misses: Miss[] = [];
|
|
182
|
+
// How many items each one lines up with, edge by edge.
|
|
183
|
+
const support = new Map<string, number>();
|
|
184
|
+
const lineUp = (index: number, key: string) => {
|
|
185
|
+
support.set(`${index}\0${key}`, (support.get(`${index}\0${key}`) ?? 0) + 1);
|
|
186
|
+
};
|
|
187
|
+
const compare = (
|
|
188
|
+
[a, b]: [number, number],
|
|
189
|
+
edge: AlignedEdge,
|
|
190
|
+
of: Miss["of"],
|
|
191
|
+
apart: number,
|
|
192
|
+
): void => {
|
|
193
|
+
const key = `${of}\0${edge}`;
|
|
194
|
+
if (Math.abs(apart) < SAME) {
|
|
195
|
+
lineUp(a, key);
|
|
196
|
+
lineUp(b, key);
|
|
197
|
+
} else if (near(apart)) {
|
|
198
|
+
misses.push({ element: b, other: a, edge, of, by: Math.round(Math.abs(apart)), key });
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const siblings = Map.groupBy(items, (index) => elements[index]?.parent ?? -1);
|
|
203
|
+
for (const group of siblings.values()) {
|
|
204
|
+
for (const [index, a] of group.entries()) {
|
|
205
|
+
for (const b of group.slice(index + 1)) {
|
|
206
|
+
const axis = relation(rectOf(a), rectOf(b));
|
|
207
|
+
if (!axis) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const one = groupOf(a, b, axis, group);
|
|
211
|
+
const two = groupOf(b, a, axis, group);
|
|
212
|
+
// A row or a stack is compared once, as its first member.
|
|
213
|
+
if (one.members[0] !== a || two.members[0] !== b) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const from = along(one.rect, axis);
|
|
217
|
+
const to = along(two.rect, axis);
|
|
218
|
+
const apart = (side: Side) => to[side] - from[side];
|
|
219
|
+
const shared = (side: Side) => Math.abs(apart(side)) < SAME;
|
|
220
|
+
const pair = (side: Side): [number, number] => [at(one, axis, side), at(two, axis, side)];
|
|
221
|
+
for (const side of SIDES.filter(shared)) {
|
|
222
|
+
compare(pair(side), axis[side], "box", 0);
|
|
223
|
+
}
|
|
224
|
+
const texts = one.line !== undefined && two.line !== undefined;
|
|
225
|
+
const cards =
|
|
226
|
+
one.framed && two.framed && texts && one.kind !== undefined && one.kind === two.kind;
|
|
227
|
+
// Text set on one baseline lines up, whatever boxes that do not show do. A baseline is
|
|
228
|
+
// worked out from the font's ascent, which layout may round where a canvas does not, so
|
|
229
|
+
// two that look like one count as one.
|
|
230
|
+
const baseline =
|
|
231
|
+
axis === ROW &&
|
|
232
|
+
!(one.framed && two.framed) &&
|
|
233
|
+
one.line?.baseline !== undefined &&
|
|
234
|
+
two.line?.baseline !== undefined &&
|
|
235
|
+
Math.abs(one.line.baseline - two.line.baseline) < SEEN;
|
|
236
|
+
if (baseline) {
|
|
237
|
+
// Nothing more to compare.
|
|
238
|
+
} else if (shared("center")) {
|
|
239
|
+
// Centered on one line, whatever their sizes.
|
|
240
|
+
} else if (shared("start") || shared("end")) {
|
|
241
|
+
// Side by side, text starts at the top, so a top a few pixels off reads where the
|
|
242
|
+
// bottoms meet; and a row of cards is one height, so one that shares its top and nearly
|
|
243
|
+
// its bottom is a little shorter. Stacked, items take the width their words give them,
|
|
244
|
+
// as tags set flush right do, and only an edge none of them shares can be off.
|
|
245
|
+
if (axis === ROW && shared("end") && texts) {
|
|
246
|
+
compare(pair("start"), axis.start, "box", apart("start"));
|
|
247
|
+
} else if (axis === ROW && shared("start") && cards) {
|
|
248
|
+
compare(pair("end"), axis.end, "box", apart("end"));
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
const nearest = SIDES.reduce((best, side) =>
|
|
252
|
+
Math.abs(apart(side)) < Math.abs(apart(best)) ? side : best,
|
|
253
|
+
);
|
|
254
|
+
compare(pair(nearest), axis[nearest], "box", apart(nearest));
|
|
255
|
+
}
|
|
256
|
+
// Text in cards off the line moved with them, and is named by their edge. Text centered
|
|
257
|
+
// in its card lines up by its middle, whatever its length.
|
|
258
|
+
if (cards && shared("start") && one.line && two.line) {
|
|
259
|
+
const [from, to] = [along(one.line.rect, axis), along(two.line.rect, axis)];
|
|
260
|
+
const centered = Math.abs(to.center - from.center) < SAME;
|
|
261
|
+
if (axis === COLUMN || one.line.fontSize === two.line.fontSize) {
|
|
262
|
+
compare([a, b], axis.start, "text", centered ? 0 : to.start - from.start);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const named = new Set<string>();
|
|
270
|
+
return misses.flatMap(({ key, ...miss }) => {
|
|
271
|
+
const ofOther = support.get(`${miss.other}\0${key}`) ?? 0;
|
|
272
|
+
const ofElement = support.get(`${miss.element}\0${key}`) ?? 0;
|
|
273
|
+
const found =
|
|
274
|
+
ofElement > ofOther ? { ...miss, element: miss.other, other: miss.element } : miss;
|
|
275
|
+
const id = `${found.element}\0${key}`;
|
|
276
|
+
if (named.has(id)) {
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
named.add(id);
|
|
280
|
+
return [found];
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The first line of text in an element: where it is drawn, its baseline, and its size. */
|
|
285
|
+
type FirstLine = { rect: Box; baseline?: number; fontSize: number };
|
|
286
|
+
|
|
287
|
+
/** Whether `a` comes before `b` in reading order: higher, or as high and further left. */
|
|
288
|
+
function earlier(a: Box, b: Box): boolean {
|
|
289
|
+
return a.top < b.top - SAME || (Math.abs(a.top - b.top) < SAME && a.left < b.left);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Each element's first line of text, its own or a descendant's, in reading order. Decoration and
|
|
294
|
+
* what the beat has not shown have none.
|
|
295
|
+
*/
|
|
296
|
+
function firstLines(elements: AlignedElement[]): Array<FirstLine | undefined> {
|
|
297
|
+
const first: Array<FirstLine | undefined> = elements.map(() => undefined);
|
|
298
|
+
// A parent comes before its children, so walking back reaches every child before its parent.
|
|
299
|
+
for (let index = elements.length - 1; index >= 0; index--) {
|
|
300
|
+
const element = elements[index];
|
|
301
|
+
if (!element || element.decorative || element.opacity < 0.01) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const [own] = element.textRects;
|
|
305
|
+
const kept = first[index];
|
|
306
|
+
if (own && (!kept || earlier(own, kept.rect))) {
|
|
307
|
+
first[index] = {
|
|
308
|
+
rect: own,
|
|
309
|
+
fontSize: element.fontSize,
|
|
310
|
+
...(element.baseline !== undefined ? { baseline: element.baseline } : {}),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const mine = first[index];
|
|
314
|
+
const parent = first[element.parent];
|
|
315
|
+
if (mine && element.parent >= 0 && (!parent || earlier(mine.rect, parent.rect))) {
|
|
316
|
+
first[element.parent] = mine;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return first;
|
|
320
|
+
}
|