@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,879 @@
1
+ import type {
2
+ PptxFill,
3
+ PptxLayer,
4
+ PptxPaint,
5
+ PptxParagraph,
6
+ PptxPicture,
7
+ PptxRule,
8
+ PptxRun,
9
+ PptxShape,
10
+ PptxShapeText,
11
+ PptxTextBox,
12
+ PptxTextLayer,
13
+ } from "./pptx-package.ts";
14
+ import type { PptxPagePaint } from "./pptx-paint.ts";
15
+ import type { PptxPageTexts } from "./pptx-text.ts";
16
+ import { slideBackground } from "./pptx-theme.ts";
17
+ import type { Box } from "./slide-measure.ts";
18
+
19
+ // What a PPTX lays over a slide's picture, decided from what the page measured: which boxes and
20
+ // pictures become PowerPoint's own, what they are grouped with, which text becomes a shape's
21
+ // paragraphs and which stays line by line, and whether the slide's background is PowerPoint's.
22
+ // Nothing here touches a page, so every rule ADRs 20 and 21 write down is checked without a
23
+ // browser.
24
+
25
+ /**
26
+ * A place in the order the browser paints: a `[layer, index]` pair for each element from the
27
+ * slide down, the layer negative for a negative `z-index`, 0 in the flow, 1 positioned or a
28
+ * stacking context of its own, more for a positive `z-index`, and the index the element's place
29
+ * in the document. What an element holds has a longer key than the element, and paints after it.
30
+ */
31
+ export type PaintKey = number[];
32
+
33
+ /** An element of the slide, the slide itself left out. */
34
+ export type PptxPaintNode = {
35
+ /** The nearest element above it, or -1 for the slide. */
36
+ parent: number;
37
+ key: PaintKey;
38
+ /** Its border box on the slide. */
39
+ rect: Box;
40
+ /** What it draws of its own, if anything. */
41
+ paint?: PptxNodePaint;
42
+ /**
43
+ * How it paints among the blocks of its stacking context (CSS 2, Appendix E): a float after
44
+ * their backgrounds; inline-level content, an inline block, or a flex or grid item after the
45
+ * floats too, with the text; a block, unset, in its place among the blocks.
46
+ */
47
+ flow?: "float" | "inline";
48
+ };
49
+
50
+ type PptxSide = { width: number; paint: PptxPaint };
51
+
52
+ /** A box PowerPoint can draw as a shape: a color, solid borders, and round corners at most. */
53
+ type PptxBoxPaint = {
54
+ kind: "shape";
55
+ /** Its content box, inside its borders and padding. */
56
+ content: Box;
57
+ fill?: PptxPaint;
58
+ /** A border the same on every side. */
59
+ outline?: PptxSide;
60
+ /** Borders that differ per side, with square corners. */
61
+ sides?: Partial<Record<"top" | "right" | "bottom" | "left", PptxSide>>;
62
+ radius: number;
63
+ };
64
+
65
+ export type PptxNodePaint =
66
+ | PptxBoxPaint
67
+ /** A picture drawn alone: its border box, or as far as its own shadow and outline reach. */
68
+ | { kind: "picture"; description: string; extent?: Box }
69
+ /** Paint that stays in the picture, as far as it reaches, shadows included. */
70
+ | { kind: "drawn"; extent: Box };
71
+
72
+ /**
73
+ * What else stays in the picture: what a pseudo-element or a list marker draws, a text the
74
+ * picture keeps, or a text's shadow. It is painted as part of the element `within`, at `key`.
75
+ */
76
+ type PptxDrawn = { within: number; key: PaintKey; extent: Box };
77
+
78
+ /** A list item's marker that PowerPoint has a bullet for. */
79
+ type PptxMarker = {
80
+ node: number;
81
+ key: PaintKey;
82
+ extent: Box;
83
+ bullet: { char: string } | { ordinal: number };
84
+ };
85
+
86
+ /** A run as the page measures it: the element whose text it is, for its font and its place. */
87
+ export type PptxMeasuredRun = PptxRun & { owner: number };
88
+
89
+ /** A line of text, or the part of one that sits together, in the block that sets it. */
90
+ export type PptxMeasuredBox = Omit<PptxTextBox, "runs"> & {
91
+ block: number;
92
+ runs: PptxMeasuredRun[];
93
+ };
94
+
95
+ /** A block's text as it is written: its runs in order, a `<br>` as `\n`, and how it is set. */
96
+ export type PptxBlockText = {
97
+ node: number;
98
+ runs: PptxMeasuredRun[];
99
+ align: PptxParagraph["align"];
100
+ /** Its line height in pixels; not a number when it is `normal`. */
101
+ lineHeight: number;
102
+ content: Box;
103
+ textIndent: number;
104
+ };
105
+
106
+ /** Everything the page measured of a slide that the plan decides from. */
107
+ export type PptxPageFacts = {
108
+ nodes: PptxPaintNode[];
109
+ drawn: PptxDrawn[];
110
+ markers: PptxMarker[];
111
+ boxes: PptxMeasuredBox[];
112
+ /** Each block whose text a box carries; only a block that reads as plain lines is here. */
113
+ blocks: PptxBlockText[];
114
+ /** What the slide's pictures say they show, and the element that says it. */
115
+ described: Array<{ node: number; text: string }>;
116
+ /**
117
+ * The slide's background, when PowerPoint can paint it as the browser did, and whether the
118
+ * slide is a stacking context, so nothing a negative `z-index` puts beneath it is hidden by it.
119
+ */
120
+ slide?: { background?: PptxFill; stacking: boolean };
121
+ };
122
+
123
+ /** A picture as the plan has it: the element to draw alone. */
124
+ type PlannedImage = { node: number };
125
+
126
+ export type PptxPlan = {
127
+ layers: PptxLayer<PlannedImage>[];
128
+ /** The elements laid over the picture, to be taken out of it. */
129
+ lifted: { shapes: number[]; pictures: number[]; markers: number[] };
130
+ /** What the slide's picture shows, without what was laid over it. */
131
+ description: string;
132
+ /** The slide's background, taken out of its picture to be PowerPoint's. */
133
+ background?: PptxFill;
134
+ };
135
+
136
+ /**
137
+ * The facts a plan is made from, out of what the page measured and where Chromium says each
138
+ * pseudo-element and marker lies, keyed `<host>::<pseudo>` in the viewport. One it does not
139
+ * place is taken to draw anywhere in its element, or anywhere at all for the slide's, so
140
+ * nothing is laid over what it might draw.
141
+ */
142
+ export function pptxPageFacts(
143
+ paint: PptxPagePaint,
144
+ texts: PptxPageTexts,
145
+ placed: ReadonlyMap<string, Box>,
146
+ ): PptxPageFacts {
147
+ const keyOf = (node: number) => paint.nodes[node]?.key ?? [];
148
+ const drawn: PptxDrawn[] = texts.drawn.map(({ node, extent }) => ({
149
+ within: node,
150
+ key: textKey(keyOf(node)),
151
+ extent,
152
+ }));
153
+ const markers: PptxMarker[] = [];
154
+ for (const pseudo of paint.pseudos) {
155
+ const box = placed.get(`${pseudo.host}::${pseudo.pseudo}`);
156
+ if (!box) {
157
+ const host = paint.nodes[pseudo.host]?.rect;
158
+ const everywhere = { left: -Infinity, top: -Infinity, right: Infinity, bottom: Infinity };
159
+ drawn.push({
160
+ within: pseudo.host,
161
+ key: pseudo.key,
162
+ extent: host ? grown(host, pseudo.reach) : everywhere,
163
+ });
164
+ continue;
165
+ }
166
+ const extent = grown(
167
+ {
168
+ left: box.left - paint.origin.left,
169
+ top: box.top - paint.origin.top,
170
+ right: box.right - paint.origin.left,
171
+ bottom: box.bottom - paint.origin.top,
172
+ },
173
+ pseudo.reach,
174
+ );
175
+ if (pseudo.bullet) {
176
+ markers.push({ node: pseudo.host, key: pseudo.key, extent, bullet: pseudo.bullet });
177
+ } else {
178
+ drawn.push({ within: pseudo.host, key: pseudo.key, extent });
179
+ }
180
+ }
181
+ const { slide } = paint;
182
+ const background =
183
+ slide?.plain === true
184
+ ? slideBackground(slide, { width: slide.width, height: slide.height })
185
+ : undefined;
186
+ return {
187
+ nodes: paint.nodes,
188
+ drawn,
189
+ markers,
190
+ boxes: texts.boxes,
191
+ blocks: texts.blocks,
192
+ described: paint.described,
193
+ ...(slide
194
+ ? { slide: { ...(background ? { background } : {}), stacking: slide.stacking } }
195
+ : {}),
196
+ };
197
+ }
198
+
199
+ function grown(box: Box, by: number): Box {
200
+ return { left: box.left - by, top: box.top - by, right: box.right + by, bottom: box.bottom + by };
201
+ }
202
+
203
+ /** Where an element's text paints: after its own paint, before the elements it holds. */
204
+ function textKey(key: PaintKey): PaintKey {
205
+ return [...key, 0, (key.at(-1) ?? -1) + 0.5];
206
+ }
207
+
208
+ /** How far apart, in pixels, two edges may be and still be one. */
209
+ const NEAR = 1;
210
+
211
+ export function planPptxLayers(page: PptxPageFacts): PptxPlan {
212
+ const tree = new Tree(page.nodes);
213
+ const blocks = new Map(page.blocks.map((block) => [block.node, block]));
214
+ const candidates = page.nodes.flatMap((node, id) =>
215
+ node.paint?.kind === "shape" || node.paint?.kind === "picture" ? [id] : [],
216
+ );
217
+ // What a negative `z-index` puts under its stacking context's own paint is laid out of reach
218
+ // of the order the keys know, which cannot say whether an element is that context: it stays.
219
+ const behind = (id: number) => tree.key(id).some((layer, at) => at % 2 === 0 && layer < 0);
220
+ const drawn: Blocker[] = [
221
+ ...page.drawn,
222
+ ...page.nodes.flatMap((node, id): Blocker[] =>
223
+ node.paint?.kind === "drawn" ? [{ self: id, key: node.key, extent: node.paint.extent }] : [],
224
+ ),
225
+ ];
226
+
227
+ // Keeping one box or picture in the picture can keep another, and so can laying two over it
228
+ // in an order the browser may not have painted them in, so this runs until nothing changes.
229
+ // It only ever keeps more, so it ends.
230
+ const lifted = new Set(candidates.filter((id) => !behind(id)));
231
+ let state = settle(page, tree, blocks, lifted);
232
+ for (;;) {
233
+ const blockers = [
234
+ ...drawn,
235
+ ...candidates
236
+ .filter((id) => !lifted.has(id))
237
+ .map((id): Blocker => ({ self: id, key: tree.key(id), extent: tree.extent(id) })),
238
+ ...page.markers
239
+ .filter((marker) => !state.markers.has(marker.node))
240
+ .map(
241
+ (marker): Blocker => ({ within: marker.node, key: marker.key, extent: marker.extent }),
242
+ ),
243
+ ];
244
+ const covered = [...lifted].filter((id) =>
245
+ blockers.some((blocker) => covers(blocker, id, tree)),
246
+ );
247
+ const kept =
248
+ covered.length > 0 ? covered : misordered(assemble(page, tree, lifted, state), tree);
249
+ if (kept.length === 0) {
250
+ break;
251
+ }
252
+ for (const id of kept) {
253
+ lifted.delete(id);
254
+ }
255
+ state = settle(page, tree, blocks, lifted);
256
+ }
257
+
258
+ const sorted = [...lifted].sort((a, b) => a - b);
259
+ const pictures = sorted.filter((id) => page.nodes[id]?.paint?.kind === "picture");
260
+ // The slide's background paints first, unless the slide is no stacking context and something
261
+ // a negative z-index puts in it paints before that, under the background, out of sight.
262
+ const beneath = [
263
+ ...page.nodes.map((node) => node.key),
264
+ ...page.drawn.map((each) => each.key),
265
+ ...page.markers.map((marker) => marker.key),
266
+ ].some((key) => (key[0] ?? 0) < 0);
267
+ const background =
268
+ page.slide?.background && (page.slide.stacking || !beneath) ? page.slide.background : undefined;
269
+ return {
270
+ ...(background ? { background } : {}),
271
+ layers: assemble(page, tree, lifted, state).map(({ layer }) => layer),
272
+ lifted: {
273
+ shapes: sorted.filter((id) => page.nodes[id]?.paint?.kind === "shape"),
274
+ pictures,
275
+ markers: [...state.markers].sort((a, b) => a - b),
276
+ },
277
+ description: page.described
278
+ .filter((said) => !pictures.includes(said.node))
279
+ .map((said) => said.text)
280
+ .join(" / "),
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Something that stays drawn: an element's own paint (`self`), or paint that is part of an
286
+ * element (`within`), such as its pseudo-element's, its marker's, or its text's.
287
+ */
288
+ type Blocker = ({ self: number } | { within: number }) & { key: PaintKey; extent: Box };
289
+
290
+ /**
291
+ * Whether what stays drawn would be covered by the box or picture `id` laid over the picture:
292
+ * it overlaps it and is inside it, or is painted after it, or might be. An element's own paint
293
+ * is under everything it holds.
294
+ */
295
+ function covers(blocker: Blocker, id: number, tree: Tree): boolean {
296
+ if (!overlaps(blocker.extent, tree.extent(id))) {
297
+ return false;
298
+ }
299
+ const at = "self" in blocker ? blocker.self : blocker.within;
300
+ if ("self" in blocker ? tree.inside(at, id) && at !== id : tree.inside(at, id)) {
301
+ return true;
302
+ }
303
+ return !surelyBefore(blocker.key, tree.key(id), tree);
304
+ }
305
+
306
+ /**
307
+ * The lifted boxes and pictures laid over another in an order the browser may not have painted
308
+ * them in, with what they overlap: of two that overlap, the one written later is kept in the
309
+ * picture, under both, unless it is text, which is always laid over; then the one written first.
310
+ */
311
+ function misordered(written: Placed[], tree: Tree): number[] {
312
+ const items = written.flatMap(function flat(placed): Item[] {
313
+ return [placed.item, ...placed.inner.flatMap(flat)];
314
+ });
315
+ const kept = new Set<number>();
316
+ items.forEach((later, at) => {
317
+ for (const earlier of items.slice(0, at)) {
318
+ if (
319
+ overlaps(earlier.extent, later.extent) &&
320
+ !surelyBefore(earlier.key, later.key, tree) &&
321
+ !kept.has(earlier.node ?? -1) &&
322
+ !kept.has(later.node ?? -1)
323
+ ) {
324
+ const keep = later.node ?? earlier.node;
325
+ if (keep !== undefined) {
326
+ kept.add(keep);
327
+ }
328
+ }
329
+ }
330
+ });
331
+ return [...kept];
332
+ }
333
+
334
+ /**
335
+ * Whether `a` is surely painted before `b`. Where their keys part, each paints in the stacking
336
+ * context they share at the layer of the first element from there that is positioned or a
337
+ * context of its own, an element in the flow being neither: by that layer, then as the flow
338
+ * paints, then in the document's order. A key with another such element further in may paint
339
+ * elsewhere; then `a` comes first only where they part, with the rest of `a` in the flow or
340
+ * under it and the rest of `b` in the flow or over it.
341
+ */
342
+ function surelyBefore(a: PaintKey, b: PaintKey, tree: Tree): boolean {
343
+ let at = 0;
344
+ while (at < a.length && at < b.length && a[at] === b[at] && a[at + 1] === b[at + 1]) {
345
+ at += 2;
346
+ }
347
+ if (at >= a.length) {
348
+ return at < b.length;
349
+ }
350
+ if (at >= b.length) {
351
+ return false;
352
+ }
353
+ const layerA = sharedLayer(a, at);
354
+ const layerB = sharedLayer(b, at);
355
+ if (layerA !== undefined && layerB !== undefined) {
356
+ return partedBefore(a, b, at, tree) < 0;
357
+ }
358
+ const restA = a.filter((_, index) => index >= at && index % 2 === 0).every((layer) => layer <= 0);
359
+ const restB = b.filter((_, index) => index >= at && index % 2 === 0).every((layer) => layer >= 0);
360
+ const [indexA = 0, indexB = 0] = [a[at + 1], b[at + 1]];
361
+ const first =
362
+ (a[at] ?? 0) < (b[at] ?? 0) ||
363
+ (a[at] === b[at] && (a[at] !== 0 || tree.phase(a, at) <= tree.phase(b, at)) && indexA < indexB);
364
+ return first && restA && restB;
365
+ }
366
+
367
+ /**
368
+ * The layer a key paints at in the stacking context where it parts from another, at `at`: its
369
+ * first layer from there that is not the flow's, or the flow's; none when a later one is not
370
+ * the flow's either, which might paint in another context.
371
+ */
372
+ function sharedLayer(key: PaintKey, at: number): number | undefined {
373
+ const layers = key.filter((_, index) => index >= at && index % 2 === 0);
374
+ const [first = 0, ...rest] = layers.filter((layer) => layer !== 0);
375
+ return rest.length === 0 ? first : undefined;
376
+ }
377
+
378
+ /**
379
+ * The order the browser paints in, as far as the keys say: where they part, by the layer each
380
+ * paints at there; in the flow, then by when each paints among the blocks there; and then by
381
+ * place in the document.
382
+ */
383
+ function comparePaint(a: PaintKey, b: PaintKey, tree: Tree): number {
384
+ for (let at = 0; at < Math.min(a.length, b.length); at += 2) {
385
+ if (a[at] !== b[at] || a[at + 1] !== b[at + 1]) {
386
+ return partedBefore(a, b, at, tree);
387
+ }
388
+ }
389
+ return a.length - b.length;
390
+ }
391
+
392
+ function partedBefore(a: PaintKey, b: PaintKey, at: number, tree: Tree): number {
393
+ const layerA = sharedLayer(a, at) ?? a[at] ?? 0;
394
+ const layerB = sharedLayer(b, at) ?? b[at] ?? 0;
395
+ if (layerA !== layerB) {
396
+ return layerA - layerB;
397
+ }
398
+ const phase = layerA === 0 ? tree.phase(a, at) - tree.phase(b, at) : 0;
399
+ return phase !== 0 ? phase : (a[at + 1] ?? 0) - (b[at + 1] ?? 0);
400
+ }
401
+
402
+ /** The elements, with what is above and inside each. */
403
+ class Tree {
404
+ constructor(private readonly nodes: PptxPaintNode[]) {}
405
+
406
+ key(id: number): PaintKey {
407
+ return this.nodes[id]?.key ?? [];
408
+ }
409
+
410
+ rect(id: number): Box {
411
+ return this.nodes[id]?.rect ?? { left: 0, top: 0, right: 0, bottom: 0 };
412
+ }
413
+
414
+ /** What the element covers once laid over: its box, or a picture's whole drawing. */
415
+ extent(id: number): Box {
416
+ const paint = this.nodes[id]?.paint;
417
+ return (paint?.kind === "picture" ? paint.extent : undefined) ?? this.rect(id);
418
+ }
419
+
420
+ parent(id: number): number {
421
+ return this.nodes[id]?.parent ?? -1;
422
+ }
423
+
424
+ /**
425
+ * When what `key` stands for paints among the blocks of the stacking context where the key
426
+ * parts from another's, at `at` (CSS 2, Appendix E): 0 with the blocks' backgrounds, 1 with
427
+ * the floats, 2 with the inline content, as the latest of the elements it is in from there
428
+ * says. Text, a marker, and what a pseudo-element draws paint with the inline content.
429
+ */
430
+ phase(key: PaintKey, at: number): number {
431
+ let phase = 0;
432
+ for (let index = at + 1; index < key.length; index += 2) {
433
+ const id = key[index] ?? 0;
434
+ const flow = Number.isInteger(id) ? this.nodes[id]?.flow : "inline";
435
+ phase = Math.max(phase, flow === "inline" ? 2 : flow === "float" ? 1 : 0);
436
+ }
437
+ return phase;
438
+ }
439
+
440
+ /** Whether `id` is `outer` or inside it; everything is inside the slide, -1. */
441
+ inside(id: number, outer: number): boolean {
442
+ for (let at = id; at !== -1; at = this.parent(at)) {
443
+ if (at === outer) {
444
+ return true;
445
+ }
446
+ }
447
+ return outer === -1;
448
+ }
449
+
450
+ /** The nearest shape among `shapes` that is `id` or holds it, or -1. */
451
+ shapeOf(id: number, shapes: ReadonlySet<number>): number {
452
+ for (let at = id; at !== -1; at = this.parent(at)) {
453
+ if (shapes.has(at)) {
454
+ return at;
455
+ }
456
+ }
457
+ return -1;
458
+ }
459
+ }
460
+
461
+ /** What follows from which boxes are lifted: whose text each line is, and which reads as a column. */
462
+ type Settled = {
463
+ /** The lifted shapes among the lifted elements. */
464
+ shapes: Set<number>;
465
+ /** Each box's shape, by its index, or -1. */
466
+ owners: number[];
467
+ /** The paragraphs of each shape whose text reads as a column. */
468
+ columns: Map<number, ColumnText>;
469
+ /** The markers set as bullets. */
470
+ markers: Set<number>;
471
+ };
472
+
473
+ /** A shape's text as paragraphs, the boxes they replace, and the items whose marker is a bullet. */
474
+ type ColumnText = { text: PptxShapeText; boxes: Set<number>; bulleted: Set<number> };
475
+
476
+ function settle(
477
+ page: PptxPageFacts,
478
+ tree: Tree,
479
+ blocks: Map<number, PptxBlockText>,
480
+ lifted: ReadonlySet<number>,
481
+ ): Settled {
482
+ const shapes = new Set([...lifted].filter((id) => page.nodes[id]?.paint?.kind === "shape"));
483
+ const owners = page.boxes.map((box) => boxShape(box, tree, shapes));
484
+ const bullets = new Map(page.markers.map((marker) => [marker.node, marker]));
485
+ const columns = new Map<number, ColumnText>();
486
+ for (const shape of shapes) {
487
+ const text = columnOf(shape, page, tree, blocks, lifted, owners, bullets);
488
+ if (text) {
489
+ columns.set(shape, text);
490
+ }
491
+ }
492
+ // A marker is a bullet only where its item is a paragraph of a column; elsewhere it is drawn.
493
+ const markers = new Set([...columns.values()].flatMap((column) => [...column.bulleted]));
494
+ return { shapes, owners, columns, markers };
495
+ }
496
+
497
+ /** The nearest lifted shape that holds every run of the box, or -1. */
498
+ function boxShape(box: PptxMeasuredBox, tree: Tree, shapes: ReadonlySet<number>): number {
499
+ const [first, ...rest] = box.runs;
500
+ for (let at = tree.shapeOf(first?.owner ?? -1, shapes); at !== -1; ) {
501
+ if (rest.every((run) => tree.inside(run.owner, at))) {
502
+ return at;
503
+ }
504
+ at = tree.shapeOf(tree.parent(at), shapes);
505
+ }
506
+ return -1;
507
+ }
508
+
509
+ type Line = { top: number; bottom: number; boxes: PptxMeasuredBox[] };
510
+
511
+ /**
512
+ * A shape's text as paragraphs, when it reads as a column: its blocks one under another, no line
513
+ * with pieces set apart, nothing else laid over in its lines, and each block's runs the text its
514
+ * lines carry.
515
+ */
516
+ function columnOf(
517
+ shape: number,
518
+ page: PptxPageFacts,
519
+ tree: Tree,
520
+ blocks: Map<number, PptxBlockText>,
521
+ lifted: ReadonlySet<number>,
522
+ owners: number[],
523
+ markers: ReadonlyMap<number, PptxMarker>,
524
+ ): ColumnText | undefined {
525
+ const paint = page.nodes[shape]?.paint;
526
+ if (paint?.kind !== "shape") {
527
+ return undefined;
528
+ }
529
+ const mine = new Set(owners.flatMap((owner, index) => (owner === shape ? [index] : [])));
530
+ if (mine.size === 0) {
531
+ return undefined;
532
+ }
533
+ const order: number[] = [];
534
+ for (const index of mine) {
535
+ const block = page.boxes[index]?.block ?? -1;
536
+ if (!order.includes(block)) {
537
+ order.push(block);
538
+ }
539
+ }
540
+ const others = [...lifted].filter((id) => id !== shape && tree.inside(id, shape));
541
+ const content = paint.content;
542
+ const sets: Array<{ block: PptxBlockText; lines: Line[]; pitch: number }> = [];
543
+ for (const id of order) {
544
+ const block = blocks.get(id);
545
+ const all = page.boxes.flatMap((box, index) => (box.block === id ? [index] : []));
546
+ if (!block || block.runs.length === 0 || all.some((index) => !mine.has(index))) {
547
+ return undefined;
548
+ }
549
+ const lines = linesOf(all.map((index) => page.boxes[index] as PptxMeasuredBox));
550
+ if (lines.some((line) => line.boxes.length > 1)) {
551
+ return undefined;
552
+ }
553
+ const carried = lines.flatMap((line) => line.boxes.flatMap((box) => box.runs));
554
+ if (ink(block.runs) !== ink(carried)) {
555
+ return undefined;
556
+ }
557
+ if (block.content.left < content.left - NEAR || block.content.right > content.right + NEAR) {
558
+ return undefined;
559
+ }
560
+ const glyphs = lines[0] ? lines[0].bottom - lines[0].top : 0;
561
+ const second = lines[1];
562
+ const pitch =
563
+ Number.isFinite(block.lineHeight) && block.lineHeight > 0
564
+ ? block.lineHeight
565
+ : second && lines[0]
566
+ ? second.top - lines[0].top
567
+ : glyphs;
568
+ // A paragraph has one pitch; lines set farther apart, as a larger font in one of them sets
569
+ // them, would be drawn closer.
570
+ const first = lines[0];
571
+ const last = lines.at(-1);
572
+ if (
573
+ first &&
574
+ last &&
575
+ lines.length > 1 &&
576
+ Math.abs((last.top - first.top) / (lines.length - 1) - pitch) > NEAR * 2
577
+ ) {
578
+ return undefined;
579
+ }
580
+ sets.push({ block, lines, pitch });
581
+ }
582
+ for (const { lines } of sets) {
583
+ for (const line of lines) {
584
+ const extent = {
585
+ left: content.left,
586
+ top: line.top,
587
+ right: content.right,
588
+ bottom: line.bottom,
589
+ };
590
+ if (others.some((id) => overlaps(tree.extent(id), extent))) {
591
+ return undefined;
592
+ }
593
+ }
594
+ }
595
+
596
+ // Each line in its line box: the glyphs centered in the line's height, as CSS sets them.
597
+ const tops = sets.map(({ lines, pitch }) => {
598
+ const first = lines[0] as Line;
599
+ return first.top - (pitch - (first.bottom - first.top)) / 2;
600
+ });
601
+ const bottoms = sets.map(({ lines, pitch }) => {
602
+ const last = lines.at(-1) as Line;
603
+ return last.bottom + (pitch - (last.bottom - last.top)) / 2;
604
+ });
605
+ for (let index = 1; index < sets.length; index++) {
606
+ if ((tops[index] ?? 0) < (bottoms[index - 1] ?? 0) - NEAR) {
607
+ return undefined;
608
+ }
609
+ }
610
+
611
+ // PowerPoint numbers the paragraphs of a run of numbered ones on from the first, so the
612
+ // browser's numbers must count on by one, as one list's do; two lists, or a nested one, would
613
+ // be numbered as one.
614
+ const numbers = sets.map(({ block }) => {
615
+ const bullet = markers.get(block.node)?.bullet;
616
+ return bullet && "ordinal" in bullet ? bullet.ordinal : undefined;
617
+ });
618
+ if (
619
+ numbers.some(
620
+ (number, index) =>
621
+ number !== undefined &&
622
+ index > 0 &&
623
+ numbers[index - 1] !== undefined &&
624
+ number !== (numbers[index - 1] ?? 0) + 1,
625
+ )
626
+ ) {
627
+ return undefined;
628
+ }
629
+
630
+ const bulleted = new Set<number>();
631
+ let startAt: number | undefined;
632
+ const paragraphs = sets.map(({ block, pitch }, index): PptxParagraph => {
633
+ const marker = markers.get(block.node);
634
+ if (marker) {
635
+ bulleted.add(block.node);
636
+ }
637
+ const kind = marker?.bullet;
638
+ let bullet: PptxParagraph["bullet"];
639
+ if (kind && "ordinal" in kind) {
640
+ startAt ??= kind.ordinal;
641
+ bullet = { startAt };
642
+ } else {
643
+ startAt = undefined;
644
+ bullet = kind ? { char: kind.char } : undefined;
645
+ }
646
+ return {
647
+ align: alignOf(block, sets[index]?.lines ?? []),
648
+ lineHeight: pitch,
649
+ spaceBefore: index === 0 ? 0 : Math.max(0, (tops[index] ?? 0) - (bottoms[index - 1] ?? 0)),
650
+ marginLeft: block.content.left - content.left,
651
+ marginRight: Math.max(0, content.right - block.content.right),
652
+ indent: marker ? marker.extent.left - block.content.left : block.textIndent,
653
+ ...(bullet ? { bullet } : {}),
654
+ runs: block.runs.map(written),
655
+ };
656
+ });
657
+
658
+ // DrawingML has no margin below 0, and starts no line left of the text's inset: what starts
659
+ // left of the content box, a bullet hung in its padding or a block a pixel out, moves the
660
+ // inset left, as far as the shape's own edge.
661
+ const rect = tree.rect(shape);
662
+ const shift = Math.min(
663
+ 0,
664
+ ...paragraphs.map((paragraph) => paragraph.marginLeft + Math.min(0, paragraph.indent)),
665
+ );
666
+ if (content.left - rect.left + shift < -NEAR) {
667
+ return undefined;
668
+ }
669
+ for (const paragraph of paragraphs) {
670
+ paragraph.marginLeft -= shift;
671
+ }
672
+
673
+ // Set from the top, where it starts, as PowerPoint's own text is: it grows down as it is edited,
674
+ // and lands where the browser set it in every renderer, which centering does not.
675
+ return {
676
+ text: {
677
+ inset: {
678
+ left: Math.max(0, content.left - rect.left + shift),
679
+ top: (tops[0] ?? content.top) - rect.top,
680
+ right: rect.right - content.right,
681
+ bottom: rect.bottom - content.bottom,
682
+ },
683
+ wrap: sets.some(({ lines }) => lines.length > 1),
684
+ paragraphs,
685
+ },
686
+ boxes: mine,
687
+ bulleted,
688
+ };
689
+ }
690
+
691
+ /**
692
+ * How a block's lines are aligned, as they sit in its content box: text a flexbox or a grid
693
+ * centers is centered though its `text-align` says nothing of it.
694
+ */
695
+ function alignOf(block: PptxBlockText, lines: Line[]): PptxParagraph["align"] {
696
+ if (block.align !== "left" && block.align !== "justify") {
697
+ return block.align;
698
+ }
699
+ const gaps = lines.map((line) => {
700
+ const left = Math.min(...line.boxes.map((box) => box.x));
701
+ const right = Math.max(...line.boxes.map((box) => box.x + box.width));
702
+ return { before: left - block.content.left, after: block.content.right - right };
703
+ });
704
+ if (gaps.length === 0 || gaps.some((gap) => gap.before <= NEAR)) {
705
+ return block.align;
706
+ }
707
+ if (gaps.every((gap) => Math.abs(gap.before - gap.after) <= NEAR * 1.5)) {
708
+ return "center";
709
+ }
710
+ return gaps.every((gap) => gap.after <= NEAR) ? "right" : block.align;
711
+ }
712
+
713
+ /** A block's boxes in lines, top to bottom: boxes whose middles share a line are one. */
714
+ function linesOf(boxes: PptxMeasuredBox[]): Line[] {
715
+ const lines: Line[] = [];
716
+ for (const box of [...boxes].sort((a, b) => a.y - b.y || a.x - b.x)) {
717
+ const middle = box.y + box.height / 2;
718
+ const line = lines.find((each) => middle > each.top && middle < each.bottom);
719
+ if (line) {
720
+ line.boxes.push(box);
721
+ line.top = Math.min(line.top, box.y);
722
+ line.bottom = Math.max(line.bottom, box.y + box.height);
723
+ } else {
724
+ lines.push({ top: box.y, bottom: box.y + box.height, boxes: [box] });
725
+ }
726
+ }
727
+ return lines;
728
+ }
729
+
730
+ /** The runs' text without its white space, to compare what two measures of it carry. */
731
+ function ink(runs: PptxRun[]): string {
732
+ return runs
733
+ .map((run) => run.text)
734
+ .join("")
735
+ .replace(/\s+/g, "");
736
+ }
737
+
738
+ /** A layer as it is written, for the order to be checked: where it lies and where it paints. */
739
+ type Item = { extent: Box; key: PaintKey; node?: number };
740
+
741
+ /** A layer in its group: where it paints, and itself and what it holds, in the order written. */
742
+ type Placed = { key: PaintKey; layer: PptxLayer<PlannedImage>; item: Item; inner: Placed[] };
743
+
744
+ /** The lifted elements and the text, each in the group of its nearest shape, in paint order. */
745
+ function assemble(
746
+ page: PptxPageFacts,
747
+ tree: Tree,
748
+ lifted: ReadonlySet<number>,
749
+ state: Settled,
750
+ ): Placed[] {
751
+ const held = new Map<number, Placed[]>();
752
+ const place = (shape: number, placed: Placed) => {
753
+ const list = held.get(shape) ?? [];
754
+ list.push(placed);
755
+ held.set(shape, list);
756
+ };
757
+ const inColumn = new Set([...state.columns.values()].flatMap((column) => [...column.boxes]));
758
+ page.boxes.forEach((box, index) => {
759
+ if (inColumn.has(index)) {
760
+ return;
761
+ }
762
+ // A line that holds more than one element's text paints as the latest of them, such as the
763
+ // text in a chip over the chip.
764
+ const key = box.runs
765
+ .map((run) => textKey(tree.key(run.owner)))
766
+ .reduce((latest, each) => (comparePaint(each, latest, tree) > 0 ? each : latest));
767
+ const layer = textLayer(box);
768
+ place(state.owners[index] ?? -1, {
769
+ key,
770
+ layer,
771
+ item: { extent: rect(box.x, box.y, box.width, box.height), key },
772
+ inner: [],
773
+ });
774
+ });
775
+ for (const id of [...lifted].sort((a, b) => a - b)) {
776
+ const node = page.nodes[id];
777
+ if (node?.paint?.kind === "picture") {
778
+ place(tree.shapeOf(node.parent, state.shapes), {
779
+ key: node.key,
780
+ layer: pictureLayer(tree.extent(id), node.paint.description, id),
781
+ item: { extent: tree.extent(id), key: node.key, node: id },
782
+ inner: [],
783
+ });
784
+ }
785
+ }
786
+ // A stable sort: what shares a place stays in the order it was placed.
787
+ const take = (shape: number): Placed[] =>
788
+ (held.get(shape) ?? []).sort((a, b) => comparePaint(a.key, b.key, tree));
789
+
790
+ // Shapes come last: each is built from what it holds, deepest first.
791
+ const shapes = [...state.shapes].sort((a, b) => tree.key(b).length - tree.key(a).length || b - a);
792
+ for (const id of shapes) {
793
+ const node = page.nodes[id];
794
+ if (node?.paint?.kind !== "shape") {
795
+ continue;
796
+ }
797
+ const rules = rulesOf(node.rect, node.paint);
798
+ const inner = take(id);
799
+ const text = state.columns.get(id)?.text;
800
+ const parent = tree.shapeOf(node.parent, state.shapes);
801
+ const item = { extent: node.rect, key: node.key, node: id };
802
+ if (!node.paint.fill && !node.paint.outline && !text) {
803
+ // A box that draws only its borders' sides is only those lines, and holds nothing.
804
+ for (const layer of rules) {
805
+ place(parent, { key: node.key, layer, item, inner: [] });
806
+ }
807
+ for (const placed of inner) {
808
+ place(parent, placed);
809
+ }
810
+ continue;
811
+ }
812
+ const shape: PptxShape<PlannedImage> = {
813
+ kind: "shape",
814
+ ...rectOf(node.rect),
815
+ ...(node.paint.fill ? { fill: node.paint.fill } : {}),
816
+ ...(node.paint.outline ? { outline: node.paint.outline } : {}),
817
+ radius: node.paint.radius,
818
+ ...(text ? { text } : {}),
819
+ layers: [...rules, ...inner.map(({ layer }) => layer)],
820
+ };
821
+ place(parent, { key: node.key, layer: shape, item, inner });
822
+ }
823
+ return take(-1);
824
+ }
825
+
826
+ function rect(x: number, y: number, width: number, height: number): Box {
827
+ return { left: x, top: y, right: x + width, bottom: y + height };
828
+ }
829
+
830
+ const SIDES = ["top", "right", "bottom", "left"] as const;
831
+
832
+ /** Each side of a border that differs per side, a line on its midline from corner to corner. */
833
+ function rulesOf(rect: Box, paint: PptxBoxPaint): PptxRule[] {
834
+ return SIDES.flatMap((name): PptxRule[] => {
835
+ const side = paint.sides?.[name];
836
+ if (!side) {
837
+ return [];
838
+ }
839
+ const half = side.width / 2;
840
+ const ends = {
841
+ top: [rect.left, rect.top + half, rect.right, rect.top + half],
842
+ right: [rect.right - half, rect.top, rect.right - half, rect.bottom],
843
+ bottom: [rect.left, rect.bottom - half, rect.right, rect.bottom - half],
844
+ left: [rect.left + half, rect.top, rect.left + half, rect.bottom],
845
+ }[name];
846
+ const [x1 = 0, y1 = 0, x2 = 0, y2 = 0] = ends;
847
+ return [{ kind: "rule", x1, y1, x2, y2, width: side.width, paint: side.paint }];
848
+ });
849
+ }
850
+
851
+ function textLayer(box: PptxMeasuredBox): PptxTextLayer {
852
+ const { block: _block, runs, ...rest } = box;
853
+ return { kind: "text", ...rest, runs: runs.map(written) };
854
+ }
855
+
856
+ /** A run as it is written: whose text it was is the page's to know, not the file's. */
857
+ function written(run: PptxMeasuredRun): PptxRun {
858
+ const { owner: _owner, ...rest } = run;
859
+ return rest;
860
+ }
861
+
862
+ function pictureLayer(rect: Box, description: string, node: number): PptxPicture<PlannedImage> {
863
+ return { kind: "picture", ...rectOf(rect), description, node };
864
+ }
865
+
866
+ function rectOf(box: Box): { x: number; y: number; width: number; height: number } {
867
+ return { x: box.left, y: box.top, width: box.right - box.left, height: box.bottom - box.top };
868
+ }
869
+
870
+ /**
871
+ * Whether two boxes share more than an edge: more than half a pixel each way, or than half of
872
+ * the thinner, so a hairline counts.
873
+ */
874
+ function overlaps(a: Box, b: Box): boolean {
875
+ const across = (from: number, to: number, otherFrom: number, otherTo: number) =>
876
+ Math.min(to, otherTo) - Math.max(from, otherFrom) >
877
+ Math.min(0.5, (to - from) / 2, (otherTo - otherFrom) / 2);
878
+ return across(a.left, a.right, b.left, b.right) && across(a.top, a.bottom, b.top, b.bottom);
879
+ }