@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.ja.md +1 -1
  3. package/README.md +1 -1
  4. package/package.json +2 -1
  5. package/skills/dek/SKILL.md +10 -3
  6. package/skills/dek-design/SKILL.md +42 -0
  7. package/src/cli/address.ts +84 -14
  8. package/src/cli/build.ts +2 -2
  9. package/src/cli/check.ts +2 -1
  10. package/src/cli/commands.ts +54 -13
  11. package/src/cli/contract.ts +18 -1
  12. package/src/cli/files.ts +9 -6
  13. package/src/cli/flags.ts +1 -1
  14. package/src/cli/goto.ts +3 -1
  15. package/src/cli/init.ts +10 -5
  16. package/src/cli/lint.ts +4 -3
  17. package/src/cli/main.ts +7 -5
  18. package/src/cli/new.ts +41 -10
  19. package/src/cli/pack.ts +21 -0
  20. package/src/cli/pptx.ts +9 -4
  21. package/src/cli/ref.ts +243 -9
  22. package/src/cli/result.ts +5 -2
  23. package/src/cli/scope.ts +25 -27
  24. package/src/cli/shot.ts +2 -1
  25. package/src/cli/text.ts +45 -4
  26. package/src/cli/theme.ts +16 -2
  27. package/src/cli/voice.ts +4 -3
  28. package/src/core/alignment.ts +320 -0
  29. package/src/core/annotation-rows.ts +16 -4
  30. package/src/core/annotations.ts +19 -1
  31. package/src/core/config-keys.ts +6 -2
  32. package/src/core/config.ts +88 -12
  33. package/src/core/diagnostic.ts +5 -1
  34. package/src/core/escape.ts +43 -0
  35. package/src/core/licenses/CC-BY-4.0.txt +396 -0
  36. package/src/core/licenses/CC0-1.0.txt +121 -0
  37. package/src/core/licenses.ts +20 -0
  38. package/src/core/line-break.ts +275 -0
  39. package/src/core/lint/asset-refs.ts +6 -2
  40. package/src/core/lint/files.ts +30 -5
  41. package/src/core/lint/keys.ts +7 -3
  42. package/src/core/lint/slide-html.ts +3 -1
  43. package/src/core/lint/slide-script.ts +7 -5
  44. package/src/core/mv.ts +30 -10
  45. package/src/core/pack.ts +137 -0
  46. package/src/core/playwright-module.d.ts +1 -0
  47. package/src/core/playwright-visual.ts +180 -20
  48. package/src/core/playwright.ts +202 -25
  49. package/src/core/pptx-package.ts +461 -33
  50. package/src/core/pptx-paint.ts +1168 -0
  51. package/src/core/pptx-plan.ts +879 -0
  52. package/src/core/pptx-text.ts +204 -47
  53. package/src/core/pptx-theme.ts +222 -0
  54. package/src/core/pptx.ts +55 -8
  55. package/src/core/prose.ts +23 -0
  56. package/src/core/pseudo-text.ts +32 -10
  57. package/src/core/ref-name.ts +100 -17
  58. package/src/core/ref.ts +318 -34
  59. package/src/core/resolve.ts +20 -9
  60. package/src/core/shot/morph.ts +1 -1
  61. package/src/core/shot/motion.ts +2 -3
  62. package/src/core/shot/still.ts +2 -2
  63. package/src/core/shot-cache.ts +25 -17
  64. package/src/core/slide-measure.ts +51 -1
  65. package/src/core/slide-script-eval.ts +10 -8
  66. package/src/core/slide-script.ts +114 -20
  67. package/src/core/sync.ts +3 -3
  68. package/src/core/visual-cache.ts +2 -0
  69. package/src/core/visual.ts +154 -15
  70. package/src/core/voice.ts +3 -5
  71. package/src/core/zod.ts +9 -1
  72. package/src/runtime/annotate-view.ts +4 -2
  73. package/src/server/dev.ts +15 -4
  74. package/src/server/lock.ts +48 -5
  75. package/src/server/routes.ts +7 -2
  76. package/src/theme/default.css +4 -0
  77. package/src/video/bake.ts +6 -2
@@ -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 { pseudoTextBoxes } from "./pseudo-text.ts";
61
+ import type { PptxPageTheme } from "./pptx-theme.ts";
62
+ import { pseudoElementBoxes, pseudoTextBoxes } from "./pseudo-text.ts";
44
63
  import { motionSheets } from "./sheet.ts";
45
64
  import {
46
65
  type Box,
@@ -133,7 +152,10 @@ async function printPdf(context: BrowserContext, request: PdfRequest): Promise<D
133
152
  return {};
134
153
  }
135
154
 
136
- /** Each slide's text measured and taken out, and the slide shot without it, page by page. */
155
+ /**
156
+ * Each slide's text and paint measured; what is laid over the picture planned, each picture
157
+ * drawn alone, and the slide shot without what was laid over it, page by page.
158
+ */
137
159
  async function shootPptx(context: BrowserContext, request: PptxRequest): Promise<PptxResponse> {
138
160
  const slides = await visitInPages(
139
161
  request.pages,
@@ -141,19 +163,86 @@ async function shootPptx(context: BrowserContext, request: PptxRequest): Promise
141
163
  async (page, pageReq) => {
142
164
  await page.setContent(pageReq.html, { waitUntil: "load" });
143
165
  await page.evaluate(() => document.fonts.ready.then(() => undefined));
144
- const measured = await page.evaluate(measurePptxTextsInPage);
166
+ const paint = {
167
+ ...(await page.evaluate(measurePptxPaintInPage)),
168
+ described: await page.evaluate(describePptxPicturesInPage),
169
+ };
170
+ const texts = await page.evaluate(measurePptxTextsInPage);
145
171
  const fonts = await platformFonts(page);
146
- await page.screenshot({ path: pageReq.screenshotPath, fullPage: false });
172
+ const placed = await pseudoElementBoxes(page, PPTX_PSEUDO_ATTR, "border", [
173
+ "before",
174
+ "after",
175
+ "marker",
176
+ ]);
177
+ for (const run of [
178
+ ...texts.boxes.flatMap((box) => box.runs),
179
+ ...texts.blocks.flatMap((block) => block.runs),
180
+ ]) {
181
+ const font = fonts.get(run.owner);
182
+ if (font !== undefined) {
183
+ run.font = font;
184
+ }
185
+ }
186
+ const plan = planPptxLayers(pptxPageFacts(paint, texts, placed));
187
+
188
+ const files = new Map<number, PptxPictureFile>();
189
+ for (const [index, node] of plan.lifted.pictures.entries()) {
190
+ const measured = paint.nodes[node];
191
+ const rect =
192
+ (measured?.paint?.kind === "picture" ? measured.paint.extent : undefined) ??
193
+ measured?.rect;
194
+ if (!rect) {
195
+ continue;
196
+ }
197
+ const svg = await page.evaluate(pptxSvgInPage, {
198
+ node,
199
+ paint: SVG_PAINT,
200
+ checked: SVG_CHECKED,
201
+ });
202
+ const png = pageReq.screenshotPath.replace(/\.png$/, `-${index + 1}.png`);
203
+ await page.evaluate(showOnlyPptxNodeInPage, node);
204
+ await page.screenshot({
205
+ path: png,
206
+ omitBackground: true,
207
+ clip: {
208
+ x: paint.origin.left + rect.left,
209
+ y: paint.origin.top + rect.top,
210
+ width: rect.right - rect.left,
211
+ height: rect.bottom - rect.top,
212
+ },
213
+ });
214
+ await page.evaluate(showOnlyPptxNodeInPage, null);
215
+ files.set(node, svg === undefined ? { png } : { png, svg });
216
+ }
217
+ const layers = mapPictures(plan.layers, ({ node, ...picture }) => ({
218
+ ...picture,
219
+ ...(files.get(node) ?? { png: "" }),
220
+ }));
221
+ // With the background PowerPoint's, the slide is shot on nothing, and a picture with
222
+ // nothing left in it is no picture.
223
+ const { background } = plan;
224
+ await page.evaluate(hidePptxLiftedInPage, {
225
+ ...plan.lifted,
226
+ background: background !== undefined,
227
+ });
228
+ const shot = await page.screenshot({
229
+ path: pageReq.screenshotPath,
230
+ fullPage: false,
231
+ ...(background ? { omitBackground: true } : {}),
232
+ });
233
+ const empty =
234
+ background !== undefined &&
235
+ (await page.evaluate(
236
+ pptxPictureEmptyInPage,
237
+ `data:image/png;base64,${shot.toString("base64")}`,
238
+ ));
147
239
  return {
148
240
  slug: pageReq.slug,
149
- description: measured.description,
150
- boxes: measured.boxes.map((box) => ({
151
- ...box,
152
- runs: box.runs.map(({ owner, ...run }) => {
153
- const font = fonts.get(owner);
154
- return font === undefined ? run : { ...run, font };
155
- }),
156
- })),
241
+ description: plan.description,
242
+ layers,
243
+ picture: !empty,
244
+ ...(background ? { background } : {}),
245
+ theme: await pageTheme(page),
157
246
  };
158
247
  },
159
248
  );
@@ -161,12 +250,44 @@ async function shootPptx(context: BrowserContext, request: PptxRequest): Promise
161
250
  }
162
251
 
163
252
  /**
164
- * The font Chromium drew each marked element's text with, by the element's number: the one that
165
- * drew most of its glyphs, as the DevTools protocol reports it. A theme names a stack, or only
166
- * `sans-serif`; this is the face that was found.
253
+ * What the slide says of the deck's theme: its color tokens, and each font token as the
254
+ * `font-family` it is and the faces Chromium drew Latin and East Asian text in with it. Measured
255
+ * after the slide's last shot, since it puts text in the page to measure.
167
256
  */
257
+ async function pageTheme(page: Page): Promise<PptxPageTheme> {
258
+ const { colors, families } = await page.evaluate(measurePptxThemeInPage);
259
+ await page.evaluate(pptxFontProbesInPage, true);
260
+ const faces = await platformFaces(page, "data-dek-pptx-probe");
261
+ await page.evaluate(pptxFontProbesInPage, false);
262
+ const fonts: PptxPageTheme["fonts"] = {};
263
+ for (const token of ["title", "body"] as const) {
264
+ const family = families[token];
265
+ const latin = faces.get(`${token}-latin`);
266
+ if (family !== undefined && latin !== undefined) {
267
+ fonts[token] = { family, latin, ea: faces.get(`${token}-ea`) ?? latin };
268
+ }
269
+ }
270
+ return { colors, fonts };
271
+ }
272
+
273
+ /** The font Chromium drew each marked element's text with, by the element's number. */
168
274
  async function platformFonts(page: Page): Promise<Map<number, string>> {
169
275
  const fonts = new Map<number, string>();
276
+ for (const [owner, face] of await platformFaces(page, PPTX_OWNER_ATTR)) {
277
+ if (Number.isInteger(Number(owner))) {
278
+ fonts.set(Number(owner), face);
279
+ }
280
+ }
281
+ return fonts;
282
+ }
283
+
284
+ /**
285
+ * The font Chromium drew each element marked with `attr` in, by the attribute's value: the one
286
+ * that drew most of its glyphs, as the DevTools protocol reports it. A theme names a stack, or
287
+ * only `sans-serif`; this is the face that was found.
288
+ */
289
+ async function platformFaces(page: Page, attr: string): Promise<Map<string, string>> {
290
+ const fonts = new Map<string, string>();
170
291
  const cdp = await page.context().newCDPSession(page);
171
292
  try {
172
293
  await cdp.send("DOM.enable");
@@ -176,19 +297,19 @@ async function platformFonts(page: Page): Promise<Map<number, string>> {
176
297
  };
177
298
  const { nodeIds } = (await cdp.send("DOM.querySelectorAll", {
178
299
  nodeId: root.nodeId,
179
- selector: `[${PPTX_OWNER_ATTR}]`,
300
+ selector: `[${attr}]`,
180
301
  })) as { nodeIds: number[] };
181
302
  for (const nodeId of nodeIds) {
182
303
  const { attributes } = (await cdp.send("DOM.getAttributes", { nodeId })) as {
183
304
  attributes: string[];
184
305
  };
185
- const owner = Number(attributes[attributes.indexOf(PPTX_OWNER_ATTR) + 1]);
306
+ const value = attributes[attributes.indexOf(attr) + 1];
186
307
  const { fonts: used } = (await cdp.send("CSS.getPlatformFontsForNode", { nodeId })) as {
187
308
  fonts: Array<{ familyName: string; glyphCount: number }>;
188
309
  };
189
310
  const main = [...used].sort((a, b) => b.glyphCount - a.glyphCount)[0];
190
- if (main && Number.isInteger(owner)) {
191
- fonts.set(owner, main.familyName);
311
+ if (main && value !== undefined) {
312
+ fonts.set(value, main.familyName);
192
313
  }
193
314
  }
194
315
  } finally {
@@ -240,6 +361,8 @@ async function visitPages(context: BrowserContext, request: PagesRequest): Promi
240
361
  drawErrors: found.flatMap((page) => page.drawErrors ?? []),
241
362
  collisions: found.flatMap((page) => page.collisions ?? []),
242
363
  fills: found.flatMap((page) => page.fills ?? []),
364
+ lineBreaks: found.flatMap((page) => page.lineBreaks ?? []),
365
+ misalignments: found.flatMap((page) => page.misalignments ?? []),
243
366
  };
244
367
  }
245
368
 
@@ -343,7 +466,14 @@ async function visitPage(
343
466
  pageReq: VisualPage,
344
467
  { actions, viewport }: Pick<PagesRequest, "actions" | "viewport">,
345
468
  ): Promise<PagesResponse> {
346
- const response: PagesResponse = { overflows: [], contrasts: [], collisions: [], fills: [] };
469
+ const response: PagesResponse = {
470
+ overflows: [],
471
+ contrasts: [],
472
+ collisions: [],
473
+ fills: [],
474
+ lineBreaks: [],
475
+ misalignments: [],
476
+ };
347
477
  const failing: Failing[] = [];
348
478
  const crossing: Crossing[] = [];
349
479
  await page.setContent(pageReq.html, { waitUntil: "load" });
@@ -362,6 +492,36 @@ async function visitPage(
362
492
  if (actions.includes("fill") && frame) {
363
493
  response.fills?.push({ slug, step, ...measureFill(frame, measured.elements) });
364
494
  }
495
+ if (actions.includes("alignment")) {
496
+ for (const { element, other, ...miss } of findMisalignments(measured.elements)) {
497
+ const [one, two] = [measured.elements[element], measured.elements[other]];
498
+ response.misalignments?.push({
499
+ slug,
500
+ step,
501
+ box: one?.box ?? "",
502
+ ...(one?.text ? { text: one.text } : {}),
503
+ other: two?.box ?? "",
504
+ ...(two?.text ? { otherText: two.text } : {}),
505
+ ...miss,
506
+ });
507
+ }
508
+ }
509
+ if (actions.includes("breaks")) {
510
+ const { wordBreak, flows } = await page.evaluate(measureTextFlowsInPage);
511
+ const textWraps = new Map(flows.map((flow) => [flow.element, flow.textWrap]));
512
+ for (const { element, ...found } of findLineBreaks(flows)) {
513
+ const el = measured.elements[element];
514
+ response.lineBreaks?.push({
515
+ slug,
516
+ step,
517
+ box: el?.box ?? "",
518
+ ...(el?.text ? { text: el.text } : {}),
519
+ ...found,
520
+ wordBreak,
521
+ textWrap: textWraps.get(element) ?? "wrap",
522
+ });
523
+ }
524
+ }
365
525
  if (actions.includes("overflow") && frame) {
366
526
  for (const { element, ...overflow } of [
367
527
  ...findOverflows(frame, measured.elements),