@hajimism/dek 0.2.1 → 0.3.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 +46 -0
- package/README.ja.md +3 -1
- package/README.md +3 -1
- package/package.json +4 -1
- package/skills/dek/SKILL.md +44 -0
- package/src/cli/annotations.ts +26 -0
- package/src/cli/commands.ts +66 -0
- package/src/cli/contract.ts +75 -4
- package/src/cli/env-file.ts +78 -0
- package/src/cli/files.ts +3 -3
- package/src/cli/flags.ts +1 -1
- package/src/cli/format.ts +3 -0
- package/src/cli/pptx.ts +20 -0
- package/src/cli/show.ts +17 -2
- package/src/cli/text.ts +43 -0
- package/src/cli/theme.ts +7 -1
- package/src/cli.ts +5 -1
- package/src/core/annotation-rows.ts +176 -0
- package/src/core/annotations.ts +362 -0
- package/src/core/diagnostic.ts +1 -0
- package/src/core/github.ts +27 -24
- package/src/core/html-scan.ts +54 -0
- package/src/core/html.ts +53 -8
- package/src/core/lint/slide-html.ts +92 -2
- package/src/core/lint.ts +8 -1
- package/src/core/live-protocol.ts +4 -1
- package/src/core/page.ts +6 -0
- package/src/core/playwright-module.d.ts +7 -3
- package/src/core/playwright-visual.ts +76 -1
- package/src/core/playwright.ts +65 -2
- package/src/core/pptx-package.ts +312 -0
- package/src/core/pptx-text.ts +363 -0
- package/src/core/pptx.ts +75 -0
- package/src/core/slide-measure.ts +1 -0
- package/src/core/slide-script-eval.ts +2 -1
- package/src/core/spawn.ts +8 -1
- package/src/core/sync.ts +33 -6
- package/src/core/theme-excerpt.ts +61 -9
- package/src/core/theme-facts.ts +12 -2
- package/src/core/zip.ts +77 -0
- package/src/runtime/annotate-pick.ts +2 -6
- package/src/runtime/annotate-view.ts +115 -101
- package/src/runtime/annotate.ts +5 -1
- package/src/runtime/browser.ts +5 -1
- package/src/runtime/live.ts +1 -0
- package/src/runtime/routes.ts +3 -0
- package/src/server/auth.ts +3 -1
- package/src/server/routes.ts +68 -1
- package/src/video/worker.ts +2 -0
- package/tsconfig.json +26 -0
- package/src/runtime/annotate-notes.ts +0 -207
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
// What a PPTX takes from a slide as the browser drew it: each line of the text in its HTML flow,
|
|
3
|
+
// where it was laid out and how it was set, so PowerPoint can set it there again; and the slide
|
|
4
|
+
// drawn with that text taken out, as the picture under it.
|
|
5
|
+
|
|
6
|
+
import type { PptxRun, PptxTextBox } from "./pptx-package.ts";
|
|
7
|
+
|
|
8
|
+
/** A run as the page measures it: the element that owns its text, for its font to be looked up. */
|
|
9
|
+
type MeasuredRun = Omit<PptxRun, "font"> & { owner: number };
|
|
10
|
+
|
|
11
|
+
type MeasuredBox = Omit<PptxTextBox, "runs"> & { runs: MeasuredRun[] };
|
|
12
|
+
|
|
13
|
+
export type PptxPageTexts = {
|
|
14
|
+
boxes: MeasuredBox[];
|
|
15
|
+
/** How many elements own text, marked `data-dek-pptx-font="<n>"` for their fonts. */
|
|
16
|
+
owners: number;
|
|
17
|
+
/** The alternative texts the slide's pictures carry, for the slide's picture as a whole. */
|
|
18
|
+
description: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The attribute that marks each element owning measured text, by its number. */
|
|
22
|
+
export const PPTX_OWNER_ATTR = "data-dek-pptx-font";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Measures every text in the slide's HTML flow, line by line, then hides it from the drawing with
|
|
26
|
+
* a highlight, which changes no layout, so a shot taken next is the picture under the text. What
|
|
27
|
+
* is not measured stays drawn: SVG, decoration under `aria-hidden`, what a pseudo-element draws,
|
|
28
|
+
* text a transform rotates or skews, text with no fill of its own, and every text's shadow.
|
|
29
|
+
*
|
|
30
|
+
* Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
|
|
31
|
+
*/
|
|
32
|
+
export function measurePptxTextsInPage(): PptxPageTexts {
|
|
33
|
+
const slide = document.querySelector("#deck > .slide");
|
|
34
|
+
if (!(slide instanceof HTMLElement)) {
|
|
35
|
+
return { boxes: [], owners: 0, description: "" };
|
|
36
|
+
}
|
|
37
|
+
const origin = slide.getBoundingClientRect();
|
|
38
|
+
|
|
39
|
+
const canvas = document.createElement("canvas");
|
|
40
|
+
canvas.width = 1;
|
|
41
|
+
canvas.height = 1;
|
|
42
|
+
const paint = canvas.getContext("2d", { willReadFrequently: true });
|
|
43
|
+
const colors = new Map<string, { rgb: [number, number, number]; alpha: number }>();
|
|
44
|
+
/** A computed color as sRGB, whatever space it is written in, by drawing it. */
|
|
45
|
+
const rgba = (value: string): { rgb: [number, number, number]; alpha: number } => {
|
|
46
|
+
const known = colors.get(value);
|
|
47
|
+
if (known) {
|
|
48
|
+
return known;
|
|
49
|
+
}
|
|
50
|
+
let found = { rgb: [0, 0, 0] as [number, number, number], alpha: 0 };
|
|
51
|
+
if (paint) {
|
|
52
|
+
paint.clearRect(0, 0, 1, 1);
|
|
53
|
+
paint.fillStyle = value;
|
|
54
|
+
paint.fillRect(0, 0, 1, 1);
|
|
55
|
+
const [r = 0, g = 0, b = 0, a = 0] = paint.getImageData(0, 0, 1, 1).data;
|
|
56
|
+
found = { rgb: [r, g, b], alpha: a / 255 };
|
|
57
|
+
}
|
|
58
|
+
colors.set(value, found);
|
|
59
|
+
return found;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* How much the element and what it is in, up to the slide, scale it; none when one of them
|
|
64
|
+
* rotates or skews it, or scales it unevenly, which a text box cannot follow.
|
|
65
|
+
*/
|
|
66
|
+
const scaleOf = (el: Element): number | undefined => {
|
|
67
|
+
let scale = 1;
|
|
68
|
+
for (
|
|
69
|
+
let node: Element | null = el;
|
|
70
|
+
node && node !== slide.parentElement;
|
|
71
|
+
node = node.parentElement
|
|
72
|
+
) {
|
|
73
|
+
const transform = getComputedStyle(node).transform;
|
|
74
|
+
if (transform === "none" || transform === "") {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const matrix = new DOMMatrixReadOnly(transform);
|
|
78
|
+
if (!matrix.is2D || Math.abs(matrix.b) > 1e-6 || Math.abs(matrix.c) > 1e-6) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
if (Math.abs(matrix.a - matrix.d) > 1e-6 || matrix.a <= 0) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
scale *= matrix.a;
|
|
85
|
+
}
|
|
86
|
+
return scale;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const opacityOf = (el: Element): number => {
|
|
90
|
+
let opacity = 1;
|
|
91
|
+
for (
|
|
92
|
+
let node: Element | null = el;
|
|
93
|
+
node && node !== slide.parentElement;
|
|
94
|
+
node = node.parentElement
|
|
95
|
+
) {
|
|
96
|
+
opacity *= Number(getComputedStyle(node).opacity);
|
|
97
|
+
}
|
|
98
|
+
return opacity;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** The nearest element around `el` laid out as a block of its own, `el` included. */
|
|
102
|
+
const blockOf = (el: Element): Element => {
|
|
103
|
+
for (let node: Element | null = el; node; node = node.parentElement) {
|
|
104
|
+
const display = getComputedStyle(node).display;
|
|
105
|
+
if (node === slide || (display !== "inline" && display !== "contents")) {
|
|
106
|
+
return node;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return slide;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** The decorations an element's text is drawn with: its own, and its inline ancestors' up to its block. */
|
|
113
|
+
const decorationsOf = (el: Element, block: Element): { underline: boolean; strike: boolean } => {
|
|
114
|
+
let underline = false;
|
|
115
|
+
let strike = false;
|
|
116
|
+
for (let node: Element | null = el; node; node = node.parentElement) {
|
|
117
|
+
const line = getComputedStyle(node).textDecorationLine;
|
|
118
|
+
underline ||= line.includes("underline");
|
|
119
|
+
strike ||= line.includes("line-through");
|
|
120
|
+
if (node === block) {
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { underline, strike };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const transformText = (text: string, transform: string, lang: string): string => {
|
|
128
|
+
if (transform === "uppercase") {
|
|
129
|
+
return text.toLocaleUpperCase(lang || undefined);
|
|
130
|
+
}
|
|
131
|
+
if (transform === "lowercase") {
|
|
132
|
+
return text.toLocaleLowerCase(lang || undefined);
|
|
133
|
+
}
|
|
134
|
+
if (transform === "capitalize") {
|
|
135
|
+
return text.replace(
|
|
136
|
+
/(^|\s)(\p{L})/gu,
|
|
137
|
+
(_, space: string, letter: string) =>
|
|
138
|
+
`${space}${letter.toLocaleUpperCase(lang || undefined)}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return text;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
type Fragment = {
|
|
145
|
+
text: string;
|
|
146
|
+
/** Where its first glyph that is not a collapsible space starts. */
|
|
147
|
+
ink: number;
|
|
148
|
+
left: number;
|
|
149
|
+
top: number;
|
|
150
|
+
right: number;
|
|
151
|
+
bottom: number;
|
|
152
|
+
block: Element;
|
|
153
|
+
run: MeasuredRun;
|
|
154
|
+
};
|
|
155
|
+
const fragments: Fragment[] = [];
|
|
156
|
+
const ranges: Range[] = [];
|
|
157
|
+
const owners = new Map<Element, number>();
|
|
158
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
159
|
+
|
|
160
|
+
const walker = document.createTreeWalker(slide, NodeFilter.SHOW_TEXT);
|
|
161
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
162
|
+
const text = node as Text;
|
|
163
|
+
const parent = text.parentElement;
|
|
164
|
+
if (!parent || !/[^ \t\n\r\f]/.test(text.data)) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (parent.closest('svg, script, style, template, [aria-hidden="true"]')) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const style = getComputedStyle(parent);
|
|
171
|
+
if (style.visibility !== "visible") {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const fill = rgba(style.webkitTextFillColor || style.color);
|
|
175
|
+
const opacity = opacityOf(parent);
|
|
176
|
+
const scale = scaleOf(parent);
|
|
177
|
+
if (fill.alpha === 0 || opacity === 0 || scale === undefined) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const block = blockOf(parent);
|
|
181
|
+
const owner = owners.get(parent) ?? owners.size;
|
|
182
|
+
owners.set(parent, owner);
|
|
183
|
+
const weight = Number(style.fontWeight);
|
|
184
|
+
const letterSpacing =
|
|
185
|
+
style.letterSpacing === "normal" ? 0 : Number.parseFloat(style.letterSpacing);
|
|
186
|
+
const run: MeasuredRun = {
|
|
187
|
+
text: "",
|
|
188
|
+
size: Number.parseFloat(style.fontSize) * scale,
|
|
189
|
+
bold: weight >= 600,
|
|
190
|
+
italic: style.fontStyle !== "normal",
|
|
191
|
+
...decorationsOf(parent, block),
|
|
192
|
+
color: fill.rgb,
|
|
193
|
+
alpha: Math.round(fill.alpha * opacity * 1000) / 1000,
|
|
194
|
+
letterSpacing: (Number.isFinite(letterSpacing) ? letterSpacing : 0) * scale,
|
|
195
|
+
owner,
|
|
196
|
+
};
|
|
197
|
+
const collapse = !/^(pre|pre-wrap|break-spaces)$/.test(style.whiteSpace);
|
|
198
|
+
const lang = parent.closest("[lang]")?.getAttribute("lang") ?? "";
|
|
199
|
+
|
|
200
|
+
// The lines the text is drawn on, each the graphemes on it and the box they cover.
|
|
201
|
+
const range = document.createRange();
|
|
202
|
+
const lines: Array<{
|
|
203
|
+
text: string;
|
|
204
|
+
ink: number;
|
|
205
|
+
left: number;
|
|
206
|
+
top: number;
|
|
207
|
+
right: number;
|
|
208
|
+
bottom: number;
|
|
209
|
+
}> = [];
|
|
210
|
+
for (const { segment, index } of segmenter.segment(text.data)) {
|
|
211
|
+
range.setStart(text, index);
|
|
212
|
+
range.setEnd(text, index + segment.length);
|
|
213
|
+
const rect = [...range.getClientRects()].find((box) => box.width > 0 || box.height > 0);
|
|
214
|
+
// Only the white space CSS collapses; an ideographic space is a character like any other.
|
|
215
|
+
const space = /^[ \t\n\r\f]+$/.test(segment);
|
|
216
|
+
if (!rect || (space && rect.width === 0)) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const glyph = space && collapse ? " " : segment;
|
|
220
|
+
const line = lines.at(-1);
|
|
221
|
+
const sameLine =
|
|
222
|
+
line !== undefined &&
|
|
223
|
+
rect.top < line.bottom - (line.bottom - line.top) / 2 &&
|
|
224
|
+
rect.left >= line.right - Math.max(2, run.size / 2);
|
|
225
|
+
if (sameLine) {
|
|
226
|
+
if (!(collapse && glyph === " " && line.text.endsWith(" "))) {
|
|
227
|
+
line.text += glyph;
|
|
228
|
+
}
|
|
229
|
+
if (!space && line.ink === Number.POSITIVE_INFINITY) {
|
|
230
|
+
line.ink = rect.left;
|
|
231
|
+
}
|
|
232
|
+
line.left = Math.min(line.left, rect.left);
|
|
233
|
+
line.top = Math.min(line.top, rect.top);
|
|
234
|
+
line.right = Math.max(line.right, rect.right);
|
|
235
|
+
line.bottom = Math.max(line.bottom, rect.bottom);
|
|
236
|
+
} else {
|
|
237
|
+
lines.push({
|
|
238
|
+
text: glyph,
|
|
239
|
+
ink: space ? Number.POSITIVE_INFINITY : rect.left,
|
|
240
|
+
left: rect.left,
|
|
241
|
+
top: rect.top,
|
|
242
|
+
right: rect.right,
|
|
243
|
+
bottom: rect.bottom,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (lines.length === 0) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const all = document.createRange();
|
|
251
|
+
all.selectNodeContents(text);
|
|
252
|
+
ranges.push(all);
|
|
253
|
+
for (const line of lines) {
|
|
254
|
+
fragments.push({
|
|
255
|
+
...line,
|
|
256
|
+
text: transformText(line.text, style.textTransform, lang),
|
|
257
|
+
block,
|
|
258
|
+
run,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Pieces of one line in one block that sit together are one box, a run each; a gap wider than
|
|
264
|
+
// a space, such as the padding of a chip, starts another.
|
|
265
|
+
const boxes: MeasuredBox[] = [];
|
|
266
|
+
let open: { box: MeasuredBox; block: Element; right: number } | undefined;
|
|
267
|
+
for (const fragment of fragments) {
|
|
268
|
+
const height = fragment.bottom - fragment.top;
|
|
269
|
+
const gap = open ? fragment.left - open.right : Number.POSITIVE_INFINITY;
|
|
270
|
+
const joins =
|
|
271
|
+
open !== undefined &&
|
|
272
|
+
open.block === fragment.block &&
|
|
273
|
+
Math.abs(fragment.top - open.box.y - origin.top) < height / 2 &&
|
|
274
|
+
gap > -fragment.run.size / 2 &&
|
|
275
|
+
gap <= fragment.run.size * 0.3;
|
|
276
|
+
if (open && joins) {
|
|
277
|
+
const { box } = open;
|
|
278
|
+
const left = Math.min(box.x + origin.left, fragment.left);
|
|
279
|
+
const top = Math.min(box.y + origin.top, fragment.top);
|
|
280
|
+
const right = Math.max(box.x + origin.left + box.width, fragment.right);
|
|
281
|
+
const bottom = Math.max(box.y + origin.top + box.height, fragment.bottom);
|
|
282
|
+
Object.assign(box, {
|
|
283
|
+
x: left - origin.left,
|
|
284
|
+
y: top - origin.top,
|
|
285
|
+
width: right - left,
|
|
286
|
+
height: bottom - top,
|
|
287
|
+
lineHeight: Math.max(box.lineHeight, height),
|
|
288
|
+
});
|
|
289
|
+
const last = box.runs.at(-1);
|
|
290
|
+
if (last && sameStyle(last, fragment.run)) {
|
|
291
|
+
last.text += fragment.text;
|
|
292
|
+
} else {
|
|
293
|
+
box.runs.push({ ...fragment.run, text: fragment.text });
|
|
294
|
+
}
|
|
295
|
+
open.right = fragment.right;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
// A box starts at its first glyph: a space it would start with is not set.
|
|
299
|
+
const start = Number.isFinite(fragment.ink) ? fragment.ink : fragment.left;
|
|
300
|
+
const box: MeasuredBox = {
|
|
301
|
+
x: start - origin.left,
|
|
302
|
+
y: fragment.top - origin.top,
|
|
303
|
+
width: Math.max(0, fragment.right - start),
|
|
304
|
+
height,
|
|
305
|
+
lineHeight: height,
|
|
306
|
+
runs: [{ ...fragment.run, text: fragment.text }],
|
|
307
|
+
};
|
|
308
|
+
boxes.push(box);
|
|
309
|
+
open = { box, block: fragment.block, right: fragment.right };
|
|
310
|
+
}
|
|
311
|
+
for (const box of boxes) {
|
|
312
|
+
const first = box.runs[0];
|
|
313
|
+
const last = box.runs.at(-1);
|
|
314
|
+
if (first) {
|
|
315
|
+
first.text = first.text.replace(/^[ \t\n\r\f]+/, "");
|
|
316
|
+
}
|
|
317
|
+
if (last) {
|
|
318
|
+
last.text = last.text.replace(/[ \t\n\r\f]+$/, "");
|
|
319
|
+
}
|
|
320
|
+
box.runs = box.runs.filter((run) => run.text !== "");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (const [el, owner] of owners) {
|
|
324
|
+
el.setAttribute("data-dek-pptx-font", String(owner));
|
|
325
|
+
}
|
|
326
|
+
const style = document.createElement("style");
|
|
327
|
+
style.textContent =
|
|
328
|
+
"::highlight(dek-pptx) { color: transparent; -webkit-text-fill-color: transparent; text-decoration-color: transparent; }";
|
|
329
|
+
document.head.append(style);
|
|
330
|
+
CSS.highlights.set("dek-pptx", new Highlight(...ranges));
|
|
331
|
+
|
|
332
|
+
const described = [...slide.querySelectorAll("img[alt], [aria-label], svg > title")].flatMap(
|
|
333
|
+
(el) => {
|
|
334
|
+
if (el.closest('[aria-hidden="true"]')) {
|
|
335
|
+
return [];
|
|
336
|
+
}
|
|
337
|
+
const said =
|
|
338
|
+
(el.tagName.toLowerCase() === "title"
|
|
339
|
+
? el.textContent
|
|
340
|
+
: (el.getAttribute("alt") ?? el.getAttribute("aria-label"))) ?? "";
|
|
341
|
+
return said.trim() === "" ? [] : [said.trim()];
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
return {
|
|
345
|
+
boxes: boxes.filter((box) => box.runs.length > 0),
|
|
346
|
+
owners: owners.size,
|
|
347
|
+
description: described.join(" / "),
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
function sameStyle(a: MeasuredRun, b: MeasuredRun): boolean {
|
|
351
|
+
return (
|
|
352
|
+
a.owner === b.owner &&
|
|
353
|
+
a.size === b.size &&
|
|
354
|
+
a.bold === b.bold &&
|
|
355
|
+
a.italic === b.italic &&
|
|
356
|
+
a.underline === b.underline &&
|
|
357
|
+
a.strike === b.strike &&
|
|
358
|
+
a.alpha === b.alpha &&
|
|
359
|
+
a.letterSpacing === b.letterSpacing &&
|
|
360
|
+
a.color.join() === b.color.join()
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
package/src/core/pptx.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { type DistOptions, distFile } from "./path.ts";
|
|
5
|
+
import { type PlaywrightRunner, requirePlaywright } from "./playwright.ts";
|
|
6
|
+
import { pptxPackage } from "./pptx-package.ts";
|
|
7
|
+
import { presenterSlides } from "./presenter.ts";
|
|
8
|
+
import { asResolvedDeck, type ResolvedDeck } from "./resolve.ts";
|
|
9
|
+
import { writeInside } from "./safe-fs.ts";
|
|
10
|
+
import { logicalSize } from "./size.ts";
|
|
11
|
+
import { lastStop } from "./step.ts";
|
|
12
|
+
import { loadSlideSources, renderSlideHtml } from "./still-page.ts";
|
|
13
|
+
|
|
14
|
+
export type PptxResult = { outPath: string };
|
|
15
|
+
|
|
16
|
+
export type PptxOptions = DistOptions & {
|
|
17
|
+
/** For anyone who has the file: the notes leave out the stage directions and comments. */
|
|
18
|
+
public?: boolean;
|
|
19
|
+
runner?: PlaywrightRunner;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The deck as a PowerPoint file: each slide at its last beat, as the PDF prints it, drawn as a
|
|
24
|
+
* picture with its text laid over it in text boxes a recipient can edit, and its script in the
|
|
25
|
+
* notes. A slide script that cannot run stops it, as it stops the PDF.
|
|
26
|
+
*/
|
|
27
|
+
export async function pptxDeck(dir: string, options?: PptxOptions): Promise<PptxResult>;
|
|
28
|
+
export async function pptxDeck(source: ResolvedDeck, options?: PptxOptions): Promise<PptxResult>;
|
|
29
|
+
export async function pptxDeck(
|
|
30
|
+
input: string | ResolvedDeck,
|
|
31
|
+
options: PptxOptions = {},
|
|
32
|
+
): Promise<PptxResult> {
|
|
33
|
+
const { project, deck } = asResolvedDeck(input);
|
|
34
|
+
const outPath = distFile(project, deck, "pptx", options);
|
|
35
|
+
const size = logicalSize(deck.deck.ratio);
|
|
36
|
+
const sources = loadSlideSources(deck, { strict: true });
|
|
37
|
+
const { sections } = deck.deck;
|
|
38
|
+
// Rendered before Chromium starts, so a slide that cannot be drawn stops it with nothing written.
|
|
39
|
+
const pages = sections.map((section, index) => ({
|
|
40
|
+
html: renderSlideHtml(sources, section.slug, lastStop(section.beats)),
|
|
41
|
+
slug: section.slug,
|
|
42
|
+
index,
|
|
43
|
+
}));
|
|
44
|
+
const shots = mkdtempSync(join(tmpdir(), "dek-pptx-"));
|
|
45
|
+
try {
|
|
46
|
+
const answer = await requirePlaywright(
|
|
47
|
+
{
|
|
48
|
+
kind: "pptx",
|
|
49
|
+
viewport: size,
|
|
50
|
+
pages: pages.map(({ html, slug, index }) => ({
|
|
51
|
+
html,
|
|
52
|
+
slug,
|
|
53
|
+
screenshotPath: join(shots, `${index + 1}.png`),
|
|
54
|
+
})),
|
|
55
|
+
},
|
|
56
|
+
options.runner,
|
|
57
|
+
);
|
|
58
|
+
const scripts = presenterSlides(deck, options.public ? "audience" : "speaker");
|
|
59
|
+
const bytes = pptxPackage({
|
|
60
|
+
title: deck.deck.title,
|
|
61
|
+
lang: deck.deck.lang,
|
|
62
|
+
size,
|
|
63
|
+
slides: answer.slides.map((slide, index) => ({
|
|
64
|
+
picture: readFileSync(join(shots, `${index + 1}.png`)),
|
|
65
|
+
description: slide.description || (sections[index]?.title ?? slide.slug),
|
|
66
|
+
texts: slide.boxes,
|
|
67
|
+
notes: scripts[index]?.script ?? "",
|
|
68
|
+
})),
|
|
69
|
+
});
|
|
70
|
+
writeInside(outPath, bytes, project.root);
|
|
71
|
+
} finally {
|
|
72
|
+
rmSync(shots, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
return { outPath };
|
|
75
|
+
}
|
|
@@ -90,6 +90,7 @@ export function measureSlideInPage(): SlideMeasure {
|
|
|
90
90
|
if (!slide) {
|
|
91
91
|
return { slideBox: undefined, elements: [], pseudoTexts: [] };
|
|
92
92
|
}
|
|
93
|
+
// RUNTIME_CLASSES from theme-facts.ts, written out: this function cannot import.
|
|
93
94
|
const runtimeClasses = new Set(["is-current", "is-shown"]);
|
|
94
95
|
const pictures = new Set(["img", "svg", "video", "canvas", "iframe", "object", "embed"]);
|
|
95
96
|
// Computed colors come back as rgba() or, from a newer syntax, with a "/ alpha" at the end.
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type SlideScriptProblem,
|
|
8
8
|
staticProblems,
|
|
9
9
|
} from "./slide-script.ts";
|
|
10
|
+
import { BUN_FLAGS } from "./spawn.ts";
|
|
10
11
|
import { suggest } from "./suggest.ts";
|
|
11
12
|
|
|
12
13
|
/** One slide script as lint checks it: its source, and the slide's step keys to check `motion` by. */
|
|
@@ -149,7 +150,7 @@ const EVALUATOR_OPTIONS = { stdout: "pipe", stderr: "ignore", env: {} } as const
|
|
|
149
150
|
|
|
150
151
|
function evaluatorCommand(): string[] {
|
|
151
152
|
const worker = moduleFilePath(new URL("./slide-eval-worker.ts", import.meta.url));
|
|
152
|
-
return [process.execPath,
|
|
153
|
+
return [process.execPath, ...BUN_FLAGS, worker];
|
|
153
154
|
}
|
|
154
155
|
|
|
155
156
|
/** A worker that was killed still wrote a line for every script it finished. */
|
package/src/core/spawn.ts
CHANGED
|
@@ -13,9 +13,16 @@ export async function awaitPiped(proc: {
|
|
|
13
13
|
return { stdout, stderr, exitCode };
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* How dek runs bun: with no auto install, and without the working directory's `.env` or
|
|
18
|
+
* `bunfig.toml`, which a repository someone else wrote can commit to set variables or preload code.
|
|
19
|
+
* The dekc bin's shebang passes the last two to dek itself; a child does not inherit them.
|
|
20
|
+
*/
|
|
21
|
+
export const BUN_FLAGS = ["--no-install", "--no-env-file", "--config=/dev/null"];
|
|
22
|
+
|
|
16
23
|
/** `bin` as a command line: a .ts worker runs under bun, anything else as is. */
|
|
17
24
|
export function workerCommand(bin: string, args: string[] = []): string[] {
|
|
18
|
-
return bin.endsWith(".ts") ? ["bun",
|
|
25
|
+
return bin.endsWith(".ts") ? ["bun", ...BUN_FLAGS, bin, ...args] : [bin, ...args];
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
/** A browser that hangs must not hang dek; a whole visual run or capture fits well inside this. */
|
package/src/core/sync.ts
CHANGED
|
@@ -250,13 +250,16 @@ A build system for talks. Write what you will say; dek builds, measures, and shi
|
|
|
250
250
|
## Conventions
|
|
251
251
|
|
|
252
252
|
- One \`##\` heading is one slide. HTML lives in \`slides/<id>.html\`.
|
|
253
|
-
- Each deck owns its \`theme.css
|
|
254
|
-
-
|
|
253
|
+
- Each deck owns its \`theme.css\`, \`decks/<deck>/theme.css\`: lint, the build, and \`dekc theme\` read that one, and it is the \`theme.css\` this file and every hint mean. The \`theme.css\` at the project root is only the template \`dekc new\` copies into a new deck; editing it changes no deck that exists.
|
|
254
|
+
- Before writing a slide, run \`dekc theme\` in the deck for the classes, tokens, and layouts it defines, and \`dekc theme <layout>\` for a layout's markup.
|
|
255
|
+
- Shared look lives in the deck's \`theme.css\`. Decoration only one slide uses lives in \`slides/<id>.css\`, which is scoped to that slide.
|
|
255
256
|
- Use only classes defined in \`theme.css\` or in that slide's own \`slides/<id>.css\`. The theme holds at most \`max_classes\` in the frontmatter of \`script.md\`, 40 by default (\`DEK013\`); classes in \`slides/<id>.css\` do not count, so keep a class only one slide uses there.
|
|
257
|
+
- \`is-current\` and \`is-shown\` are states the player sets: \`is-current\` on the slide on screen, \`is-shown\` on each \`data-step\` element once its beat plays. \`dekc theme\` lists them apart, as state classes. Select them in CSS, never write them in markup (\`DEK010\`).
|
|
256
258
|
- A rule in \`slides/<id>.css\` weighs as if it were written at the end of \`theme.css\`: it beats the theme's \`.slide .x\`, but not a more specific rule such as a layout's \`.slide[data-layout="split"] .x\` or the beat state \`.slide.is-current [data-step]\`. To override one of those, write the same selector.
|
|
257
|
-
-
|
|
259
|
+
- In either stylesheet, colors, font families, easings, and lengths and times in an absolute, viewport, container, or root unit (\`px\`, \`rem\`, \`vw\`, \`ms\`, and the like) come from a token's \`var()\` (\`DEK014\`). Keywords such as \`bold\` or \`thin\`, unitless numbers, \`%\`, and units of the element's own font (\`em\`, \`ch\`, \`lh\`) are not raw values and pass. A value only one slide uses can be a token of its own on that slide's \`.slide\` rule in \`slides/<id>.css\`.
|
|
258
260
|
- Do not add \`<style>\`, \`style=\`, \`<script>\`, event handler attributes (\`onclick=\` and the like), or \`javascript:\` URLs inside slide HTML.
|
|
259
261
|
- Motion CSS cannot express lives in \`slides/<id>.ts\`: \`export default { motion: { <step>: ms }, draw(slide, { index, step, t }) {} } satisfies DekSlide\`. \`DekSlide\` is global, from \`.dek/slide.d.ts\`; do not import it. Key the slide's arrival, before its first beat, as \`"0"\`: every \`data-step\` element is hidden there, so draw what the slide shows before anything happens. Draw from \`t\` alone and set everything you touch on every call, with no timers and no imports, so video and screenshots can seek it. In \`draw\`, find elements by data-* attributes from the slide it is given, never by class or through \`document\`: the built deck holds every slide.
|
|
262
|
+
- Every picture says what it shows, or that it is decoration (\`DEK034\`). An \`<img>\` takes \`alt\`, \`alt=""\` for decoration. An \`<svg>\` with no text of its own takes \`role="img"\` and an \`aria-label\`, or \`aria-hidden="true"\` when it is decoration; one with \`<text>\` is read as it is. An element with \`role="img"\` takes an \`aria-label\`.
|
|
260
263
|
- Keep the deck self-contained: no remote URLs and no paths outside the deck.
|
|
261
264
|
- Every slide carries its place in \`script.md\` as \`--dek-slide-number\` and \`--dek-slide-count\`. Print a folio from them in \`theme.css\`, never by hand: \`.slide { counter-reset: folio var(--dek-slide-number) }\`, then \`content: counter(folio)\`. It follows the script as slides move.
|
|
262
265
|
|
|
@@ -267,14 +270,19 @@ A build system for talks. Write what you will say; dek builds, measures, and shi
|
|
|
267
270
|
- A shot's path names what it drew, and an edit to the slide or the theme replaces the file. Never reuse a shot's path from before an edit: run \`dekc shot <slug>\` again, which shoots only what changed, and read the path it prints.
|
|
268
271
|
- \`dekc shot --sheet\` tiles every slide on one image: read it to judge the deck's balance in one look, then open a slide's own shot for detail.
|
|
269
272
|
- Mark decoration \`aria-hidden="true"\`: a glow that bleeds off the slide, or a sample of text the talk shows as unreadable. Lint measures neither overflow nor contrast on it, and screen readers skip it, so never mark text the audience should read: \`DEK029\` warns of text under it.
|
|
270
|
-
- When a hint sends a fix to \`theme.css\`, make it there, not in \`slides/<id>.css\`: the theme alone draws it that way, so other slides share the problem, and one change fixes them all.
|
|
271
|
-
- One shot shows no motion. \`dekc shot <slug> --motion\` lays the slide's beats out as rows, each held at moments through everything it moves and ending as the shot does. \`dekc shot <a> --to <b> --at 0.5\` freezes the view transition between any two slides.
|
|
273
|
+
- When a hint sends a fix to the deck's \`theme.css\`, make it there, not in \`slides/<id>.css\`: the theme alone draws it that way, so other slides share the problem, and one change fixes them all.
|
|
274
|
+
- One shot shows no motion. \`dekc shot <slug> --motion\` lays the slide's beats out as rows, each held at moments through everything it moves and ending as the shot does. Its \`--json\` puts the sheets under \`sheets\` and each beat's frames under \`motion\`; \`shots\` is empty. \`dekc shot <a> --to <b> --at 0.5\` freezes the view transition between any two slides.
|
|
272
275
|
|
|
273
276
|
## After a rehearsal
|
|
274
277
|
|
|
275
278
|
- \`dekc marks\` lists the beats the speaker marked while rehearsing aloud (\`m\` in the presenter view): the words they stumbled over are \`was\`, at \`line\` in \`script.md\`. Rewrite those beats to be easier to say, and keep each beat's heading so its mark follows it; \`status\` turns \`edited\` once the words changed.
|
|
276
279
|
- Leave \`dekc marks clear\` to the speaker: only saying the new words aloud tells whether they work.
|
|
277
280
|
|
|
281
|
+
## When the human points at an element
|
|
282
|
+
|
|
283
|
+
- \`dekc annotations\` lists the notes the human wrote on elements of the slides (\`a\` on the dev server's page): each element at \`path\`, \`line\`, and \`column\` in the file as it is now, with its \`name\`, the note's \`text\`, and the \`shot\` that shows the slide at the beat the human saw. Read them yourself when the human says to deal with their notes. Fix each, then list them again: \`status\` turns \`edited\` once the slide's own files changed and \`gone\` once none of the note's elements is left. \`edited\` does not mean a note is dealt with: compare the \`shot\` with the note.
|
|
284
|
+
- Leave \`dekc annotations clear\` to the human: they wrote the notes and judge the result.
|
|
285
|
+
|
|
278
286
|
## Before you report a deck as done
|
|
279
287
|
|
|
280
288
|
- \`dekc lint --visual\` passes.
|
|
@@ -285,6 +293,25 @@ For commands, run \`dekc help --agent\`.
|
|
|
285
293
|
`;
|
|
286
294
|
}
|
|
287
295
|
|
|
296
|
+
/** The longest ref title AGENTS.md shows: a title names a deck, it does not describe one. */
|
|
297
|
+
const REF_TITLE_MAX = 80;
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* A ref's title as it goes into AGENTS.md. Someone else wrote it, and agents read dek's block as
|
|
301
|
+
* dek's own guidance, so a title must not start a line of its own there, such as a heading or an
|
|
302
|
+
* instruction: line breaks and control characters become one space, and it is cut to
|
|
303
|
+
* REF_TITLE_MAX characters.
|
|
304
|
+
*/
|
|
305
|
+
function refTitleLine(title: string | undefined): string | undefined {
|
|
306
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point.
|
|
307
|
+
const line = title?.replace(/[\s\x00-\x1f\x7f-\x9f]+/gu, " ").trim();
|
|
308
|
+
if (!line) {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
const chars = [...line];
|
|
312
|
+
return chars.length > REF_TITLE_MAX ? `${chars.slice(0, REF_TITLE_MAX - 1).join("")}…` : line;
|
|
313
|
+
}
|
|
314
|
+
|
|
288
315
|
/**
|
|
289
316
|
* The refs dek.toml pins, one line each, so an agent knows which decks it may
|
|
290
317
|
* read as models. Empty when there are none, leaving AGENTS.md as before. A
|
|
@@ -303,7 +330,7 @@ function referencesSection(root: string): string {
|
|
|
303
330
|
return "";
|
|
304
331
|
}
|
|
305
332
|
const lines = refs.map((name) => {
|
|
306
|
-
const title = refTitle(refDir(root, name), parseRefSource(name).deck)?.title;
|
|
333
|
+
const title = refTitleLine(refTitle(refDir(root, name), parseRefSource(name).deck)?.title);
|
|
307
334
|
return title ? `- \`${name}\`: ${title}` : `- \`${name}\``;
|
|
308
335
|
});
|
|
309
336
|
return `
|