@hajimism/dek 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/package.json +2 -1
- package/skills/dek/SKILL.md +10 -3
- package/skills/dek-design/SKILL.md +42 -0
- package/src/cli/address.ts +84 -14
- package/src/cli/build.ts +2 -2
- package/src/cli/check.ts +2 -1
- package/src/cli/commands.ts +54 -13
- package/src/cli/contract.ts +18 -1
- package/src/cli/files.ts +9 -6
- package/src/cli/flags.ts +1 -1
- package/src/cli/goto.ts +3 -1
- package/src/cli/init.ts +10 -5
- package/src/cli/lint.ts +4 -3
- package/src/cli/main.ts +7 -5
- package/src/cli/new.ts +41 -10
- package/src/cli/pack.ts +21 -0
- package/src/cli/pptx.ts +9 -4
- package/src/cli/ref.ts +243 -9
- package/src/cli/result.ts +5 -2
- package/src/cli/scope.ts +25 -27
- package/src/cli/shot.ts +2 -1
- package/src/cli/text.ts +45 -4
- package/src/cli/theme.ts +16 -2
- package/src/cli/voice.ts +4 -3
- package/src/core/alignment.ts +320 -0
- package/src/core/annotation-rows.ts +16 -4
- package/src/core/annotations.ts +19 -1
- package/src/core/config-keys.ts +6 -2
- package/src/core/config.ts +88 -12
- package/src/core/diagnostic.ts +5 -1
- package/src/core/escape.ts +43 -0
- package/src/core/licenses/CC-BY-4.0.txt +396 -0
- package/src/core/licenses/CC0-1.0.txt +121 -0
- package/src/core/licenses.ts +20 -0
- package/src/core/line-break.ts +275 -0
- package/src/core/lint/asset-refs.ts +6 -2
- package/src/core/lint/files.ts +30 -5
- package/src/core/lint/keys.ts +7 -3
- package/src/core/lint/slide-html.ts +3 -1
- package/src/core/lint/slide-script.ts +7 -5
- package/src/core/mv.ts +30 -10
- package/src/core/pack.ts +137 -0
- package/src/core/playwright-module.d.ts +1 -0
- package/src/core/playwright-visual.ts +180 -20
- package/src/core/playwright.ts +202 -25
- package/src/core/pptx-package.ts +461 -33
- package/src/core/pptx-paint.ts +1168 -0
- package/src/core/pptx-plan.ts +879 -0
- package/src/core/pptx-text.ts +204 -47
- package/src/core/pptx-theme.ts +222 -0
- package/src/core/pptx.ts +55 -8
- package/src/core/prose.ts +23 -0
- package/src/core/pseudo-text.ts +32 -10
- package/src/core/ref-name.ts +100 -17
- package/src/core/ref.ts +318 -34
- package/src/core/resolve.ts +20 -9
- package/src/core/shot/morph.ts +1 -1
- package/src/core/shot/motion.ts +2 -3
- package/src/core/shot/still.ts +2 -2
- package/src/core/shot-cache.ts +25 -17
- package/src/core/slide-measure.ts +51 -1
- package/src/core/slide-script-eval.ts +10 -8
- package/src/core/slide-script.ts +114 -20
- package/src/core/sync.ts +3 -3
- package/src/core/visual-cache.ts +2 -0
- package/src/core/visual.ts +154 -15
- package/src/core/voice.ts +3 -5
- package/src/core/zod.ts +9 -1
- package/src/runtime/annotate-view.ts +4 -2
- package/src/server/dev.ts +15 -4
- package/src/server/lock.ts +48 -5
- package/src/server/routes.ts +7 -2
- package/src/theme/default.css +4 -0
- package/src/video/bake.ts +6 -2
|
@@ -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
|
+
}
|