pptx-ts 0.0.1

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 (78) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +319 -0
  3. package/dist/autopage-DkjYZUFD.js +423 -0
  4. package/dist/autopage-DkjYZUFD.js.map +1 -0
  5. package/dist/browser.d.ts +31 -0
  6. package/dist/browser.d.ts.map +1 -0
  7. package/dist/browser.js +110 -0
  8. package/dist/browser.js.map +1 -0
  9. package/dist/diagnostics-Du_s75MT.d.ts +829 -0
  10. package/dist/diagnostics-Du_s75MT.d.ts.map +1 -0
  11. package/dist/enums-CvRYniUA.d.ts +463 -0
  12. package/dist/enums-CvRYniUA.d.ts.map +1 -0
  13. package/dist/errors-Bvln0H-7.js +112 -0
  14. package/dist/errors-Bvln0H-7.js.map +1 -0
  15. package/dist/errors-PflMPWBn.d.ts +182 -0
  16. package/dist/errors-PflMPWBn.d.ts.map +1 -0
  17. package/dist/fit-BKs0zxMI.js +1356 -0
  18. package/dist/fit-BKs0zxMI.js.map +1 -0
  19. package/dist/html-dom-B0I0QSbU.js +679 -0
  20. package/dist/html-dom-B0I0QSbU.js.map +1 -0
  21. package/dist/html.d.ts +26 -0
  22. package/dist/html.d.ts.map +1 -0
  23. package/dist/html.js +63 -0
  24. package/dist/html.js.map +1 -0
  25. package/dist/index.d.ts +24 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +57 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/inspect.d.ts +197 -0
  30. package/dist/inspect.d.ts.map +1 -0
  31. package/dist/inspect.js +317 -0
  32. package/dist/inspect.js.map +1 -0
  33. package/dist/math.d.ts +37 -0
  34. package/dist/math.d.ts.map +1 -0
  35. package/dist/math.js +203 -0
  36. package/dist/math.js.map +1 -0
  37. package/dist/measure.d.ts +313 -0
  38. package/dist/measure.d.ts.map +1 -0
  39. package/dist/measure.js +3 -0
  40. package/dist/namespaces-BVnNu7sg.js +666 -0
  41. package/dist/namespaces-BVnNu7sg.js.map +1 -0
  42. package/dist/node.d.ts +20 -0
  43. package/dist/node.d.ts.map +1 -0
  44. package/dist/node.js +55 -0
  45. package/dist/node.js.map +1 -0
  46. package/dist/notes-RLCwdlTE.js +1703 -0
  47. package/dist/notes-RLCwdlTE.js.map +1 -0
  48. package/dist/package-CaOLXSNi.d.ts +193 -0
  49. package/dist/package-CaOLXSNi.d.ts.map +1 -0
  50. package/dist/pres-C5__xkDh.d.ts +202 -0
  51. package/dist/pres-C5__xkDh.d.ts.map +1 -0
  52. package/dist/presentation-Cp7vzlAw.js +5427 -0
  53. package/dist/presentation-Cp7vzlAw.js.map +1 -0
  54. package/dist/presentation-types-DP_8jog7.d.ts +4872 -0
  55. package/dist/presentation-types-DP_8jog7.d.ts.map +1 -0
  56. package/dist/read.d.ts +55 -0
  57. package/dist/read.d.ts.map +1 -0
  58. package/dist/read.js +4 -0
  59. package/dist/script.d.ts +581 -0
  60. package/dist/script.d.ts.map +1 -0
  61. package/dist/script.js +3741 -0
  62. package/dist/script.js.map +1 -0
  63. package/dist/shapes-BemroU-z.js +6951 -0
  64. package/dist/shapes-BemroU-z.js.map +1 -0
  65. package/dist/slide-js0QpRV-.d.ts +3873 -0
  66. package/dist/slide-js0QpRV-.d.ts.map +1 -0
  67. package/dist/text-CyetbF6p.js +11278 -0
  68. package/dist/text-CyetbF6p.js.map +1 -0
  69. package/dist/units-1kRzYShk.js +231 -0
  70. package/dist/units-1kRzYShk.js.map +1 -0
  71. package/dist/units-internal-Dg95w_YD.js +753 -0
  72. package/dist/units-internal-Dg95w_YD.js.map +1 -0
  73. package/dist/zip.d.ts +68 -0
  74. package/dist/zip.d.ts.map +1 -0
  75. package/dist/zip.js +163 -0
  76. package/dist/zip.js.map +1 -0
  77. package/package.json +152 -0
  78. package/skills/ts-pptx-upstream/SKILL.md +340 -0
package/dist/script.js ADDED
@@ -0,0 +1,3741 @@
1
+ import { s as PERCENT_SCALE } from "./units-1kRzYShk.js";
2
+ import { a as TsPptxError, i as PackageReadError, n as InvalidOptionError, o as UnsupportedFeatureError, r as MediaError, t as InternalError } from "./errors-Bvln0H-7.js";
3
+ import { O as DEFAULT_COLOR_MAP, t as OOXML_NS } from "./namespaces-BVnNu7sg.js";
4
+ import { a as isConnector, c as isPicture, i as isAutoShape, o as isGraphicFrame, s as isGroupShape } from "./shapes-BemroU-z.js";
5
+ //#region src/script/fidelity.ts
6
+ /**
7
+ * Accumulates notes while a deck is walked, so the walk itself stays a plain
8
+ * read-model → IR mapping and does not thread a growing array through every function.
9
+ *
10
+ * Deduplicates on the full note identity. Without that, a construct that is lost on every
11
+ * run of a 400-run text body would produce 400 identical notes and bury the one-off losses
12
+ * that actually need reading.
13
+ */
14
+ var NoteCollector = class {
15
+ #seen = /* @__PURE__ */ new Set();
16
+ #notes = [];
17
+ /** Record a loss. A repeat of an identical note is ignored. */
18
+ add(note) {
19
+ const key = `${note.slideNumber}\0${note.shapeName}\0${note.construct}\0${note.disposition}\0${note.cause}\0${note.detail}`;
20
+ if (this.#seen.has(key)) return;
21
+ this.#seen.add(key);
22
+ this.#notes.push(note);
23
+ }
24
+ /**
25
+ * The notes, in the order first recorded — which is deck order, since the walk is
26
+ * ordered, so a reader meets them the way they meet the slides.
27
+ */
28
+ get notes() {
29
+ return this.#notes;
30
+ }
31
+ };
32
+ /**
33
+ * Namespace for a loss recorded against a shape on a **slide layout** rather than on a slide,
34
+ * prepended to the construct by {@link layoutShapeScope}.
35
+ *
36
+ * The chrome mapper reuses the slide shape mapper wholesale — a rectangle on a layout is
37
+ * transcribed by the same code that transcribes one on a slide, which is what keeps the two
38
+ * from drifting. That sharing brings the slide vocabulary with it: a themed outline on a
39
+ * layout records `line.width` exactly as a slide's would. Left unmarked, those notes would be
40
+ * wrong twice over. A template-anchored script would report them, though it never rebuilds a
41
+ * layout and loses nothing; and the round trip would let one excuse a *slide* difference,
42
+ * because a layout note carries no slide number and a name like "Rectangle 7" repeats between
43
+ * the tiers. The prefix keeps the shared vocabulary and states which tier it is about.
44
+ *
45
+ * `layout.` rather than `master.`, which is how the other chrome constructs are spelled: those
46
+ * are named after the `defineSlideMaster` call that authors them, while these name the source
47
+ * tier the loss is on — and a master's own decoration is not re-authored at all, so a note
48
+ * under this prefix is always about a layout.
49
+ */
50
+ const LAYOUT_NOTE_PREFIX = "layout.";
51
+ /** Bind a collector to a slide (and optionally a shape). */
52
+ function scopeNotes(collector, slideNumber, shapeName = null) {
53
+ return {
54
+ note(construct, disposition, cause, detail) {
55
+ collector.add({
56
+ slideNumber,
57
+ shapeName,
58
+ construct,
59
+ disposition,
60
+ cause,
61
+ detail
62
+ });
63
+ },
64
+ forShape(name) {
65
+ return scopeNotes(collector, slideNumber, name);
66
+ }
67
+ };
68
+ }
69
+ /**
70
+ * Wrap a scope so every construct recorded through it is namespaced under
71
+ * {@link LAYOUT_NOTE_PREFIX} — see there for why.
72
+ *
73
+ * Constructs written at a call site inside the layout walk are therefore *relative*: the
74
+ * decoration mapper records `decoration`, and what lands in the note list is
75
+ * `layout.decoration`. The wrapper survives `forShape`, since a layout loss is scoped to a
76
+ * shape name the same way a slide loss is.
77
+ */
78
+ function layoutShapeScope(notes) {
79
+ return {
80
+ note(construct, disposition, cause, detail) {
81
+ notes.note(`${LAYOUT_NOTE_PREFIX}${construct}`, disposition, cause, detail);
82
+ },
83
+ forShape(name) {
84
+ return layoutShapeScope(notes.forShape(name));
85
+ }
86
+ };
87
+ }
88
+ //#endregion
89
+ //#region src/script/ir.ts
90
+ /** `true` when `value` is an {@link AssetRef} rather than a plain IR object. */
91
+ function isAssetRef(value) {
92
+ return typeof value === "object" && value !== null && typeof value.$asset === "string";
93
+ }
94
+ /**
95
+ * Widen one of the typed IR records below to {@link IrValue}, for the printer and the
96
+ * canonicaliser, which walk the IR generically.
97
+ *
98
+ * A cast, and unavoidably so. An optional key on an interface types as `T | undefined`,
99
+ * while `IrValue` deliberately excludes `undefined` so that "absent" has exactly one
100
+ * spelling — which makes the two mutually unassignable even though every value a mapper
101
+ * actually produces satisfies both. The invariant that closes the gap is enforced at the
102
+ * source (`compact` and the conditional spreads in `from-read/`, pinned by the "no value is
103
+ * `undefined`" corpus test) rather than by the type system, so it is stated once here
104
+ * instead of re-argued at each call site.
105
+ */
106
+ function asIrValue(value) {
107
+ return value;
108
+ }
109
+ //#endregion
110
+ //#region src/script/from-read/values.ts
111
+ /** EMU per inch (ECMA-376 §20.1.2.1). */
112
+ const EMU_PER_INCH = 914400;
113
+ /**
114
+ * Decimal places used for the inch-typed options. See the module header: this is the
115
+ * proven minimum for an EMU-exact round-trip, not a formatting preference.
116
+ */
117
+ const INCH_DECIMALS = 6;
118
+ /** Geometry as a `Coord` the write API takes verbatim, preserving the exact EMU. */
119
+ function emu(value) {
120
+ return `${Math.round(value)}emu`;
121
+ }
122
+ /**
123
+ * Geometry as inches, for the three options that reject a raw-EMU string. Rounded to
124
+ * {@link INCH_DECIMALS}, which is EMU-exact on the way back.
125
+ */
126
+ function inches(emuValue) {
127
+ return Number((emuValue / EMU_PER_INCH).toFixed(INCH_DECIMALS));
128
+ }
129
+ /**
130
+ * Points → inches, at the same proven precision as {@link inches}. Needed because the read
131
+ * model reports text-body insets in points while the write API's `margin` takes inches —
132
+ * and warns on a value `>= 1`, on the assumption that it is a points value passed by
133
+ * mistake, so handing it points would both mis-scale the text and produce a warning.
134
+ */
135
+ function pointsToInches(pt) {
136
+ return Number((pt / 72).toFixed(INCH_DECIMALS));
137
+ }
138
+ /**
139
+ * A colour for a write-API `color` field. The read model splits a colour into a raw
140
+ * `schemeClr` token and a resolved literal hex; this prefers the token, because a token
141
+ * re-resolves against the destination theme and so keeps the deck recolourable, which is
142
+ * the whole reason a theme colour was authored.
143
+ *
144
+ * Only the ten tokens the write path's `clrMap` covers survive as tokens — the other
145
+ * seven `ST_SchemeColorVal` values degrade to a hex literal there anyway, so passing them
146
+ * through would produce a silently different colour. Callers hand those to
147
+ * {@link literalColor} instead after recording a note.
148
+ */
149
+ const WRITABLE_SCHEME_TOKENS = /* @__PURE__ */ new Set([
150
+ "accent1",
151
+ "accent2",
152
+ "accent3",
153
+ "accent4",
154
+ "accent5",
155
+ "accent6",
156
+ "bg1",
157
+ "bg2",
158
+ "tx1",
159
+ "tx2"
160
+ ]);
161
+ /** `true` when a `schemeClr` token survives as a token rather than degrading to hex. */
162
+ function isWritableSchemeToken(token) {
163
+ return token !== null && WRITABLE_SCHEME_TOKENS.has(token);
164
+ }
165
+ /** Normalize a hex to the bare 6-digit uppercase form the write API expects. */
166
+ function literalColor(hex) {
167
+ return hex.replace(/^#/, "").toUpperCase();
168
+ }
169
+ /**
170
+ * Build an IR object from possibly-absent parts, dropping every `undefined`.
171
+ *
172
+ * Every mapper funnels through this. An IR must spell "absent" exactly one way — a
173
+ * missing key — or two IRs describing the same deck compare unequal because one wrote
174
+ * `{ bold: undefined }` and the other wrote `{}`. That would surface as phantom
175
+ * round-trip failures, so the invariant is enforced in one place rather than trusted to
176
+ * every call site.
177
+ *
178
+ * Returns `undefined` when nothing survived, so a caller can drop the whole option object
179
+ * rather than emit an empty one.
180
+ */
181
+ function compact(source) {
182
+ const out = {};
183
+ let any = false;
184
+ for (const key of Object.keys(source).sort()) {
185
+ const value = source[key];
186
+ if (value === void 0) continue;
187
+ out[key] = value;
188
+ any = true;
189
+ }
190
+ return any ? out : void 0;
191
+ }
192
+ /** {@link compact}, but always an object — for a required options argument. */
193
+ function compactRequired(source) {
194
+ return compact(source) ?? {};
195
+ }
196
+ /** Drop a value that is `null`, so the read model's "unset" becomes an absent IR key. */
197
+ function orUndefined(value) {
198
+ return value === null ? void 0 : value;
199
+ }
200
+ /**
201
+ * A 0–1 opacity as the write API's 0–100 transparency; `undefined` when unset.
202
+ *
203
+ * Fully opaque is *not* `0` here — a caller that means "no transparency at all" passes
204
+ * `undefined`, because emitting `transparency: 0` for an `a:alphaModFix amt="100000"`
205
+ * would produce a key the output cannot have: the write path emits no `a:alphaModFix` for
206
+ * a zero transparency, so the re-read deck reports no alpha and the round trip sees a
207
+ * difference where there is no visible one.
208
+ */
209
+ function alphaToTransparency(alpha) {
210
+ if (alpha === void 0 || alpha === null) return void 0;
211
+ return Math.round((1 - alpha) * 100);
212
+ }
213
+ /**
214
+ * A graphic frame's position as write-API options. Identical to a shape's, minus rotation and
215
+ * flip: a `p:graphicFrame` has neither, so `a:xfrm` carries only the offset and extent.
216
+ */
217
+ function positionOfFrame(frame) {
218
+ const box = frame.absoluteFrame;
219
+ if (!box) return {};
220
+ return {
221
+ x: emu(box.left),
222
+ y: emu(box.top),
223
+ w: emu(box.width),
224
+ h: emu(box.height)
225
+ };
226
+ }
227
+ //#endregion
228
+ //#region src/script/from-read/surface-fill.ts
229
+ /**
230
+ * A read gradient as `GradientFillProps`. Stop positions convert from the read model's
231
+ * 0–1 fraction to the write API's 0–100 percentage; the angle needs no conversion, since
232
+ * both sides use OOXML degrees (clockwise from 3 o'clock).
233
+ */
234
+ function gradientStops(gradient, notes, where) {
235
+ const stops = gradient.stops.map((stop) => {
236
+ const color = isWritableSchemeToken(stop.schemeColor) ? stop.schemeColor : stop.effectiveHex === null ? null : literalColor(stop.effectiveHex);
237
+ if (color === null) return null;
238
+ return compact({
239
+ color,
240
+ position: Math.round((stop.position ?? 0) * 100),
241
+ transparency: alphaToTransparency(stop.alpha)
242
+ });
243
+ }).filter((stop) => stop !== null);
244
+ if (stops.length < 2) {
245
+ notes.note(`${where}.gradient`, "dropped", "unsupported", "a gradient with fewer than two resolvable stops cannot be expressed, so this falls back to no gradient");
246
+ return;
247
+ }
248
+ if (gradient.kind === "path") {
249
+ if (gradient.path !== null && gradient.path !== "circle") notes.note(`${where}.gradient.path`, "approximated", "unwritable", `a "${gradient.path}" path gradient is emitted as a radial one; the write API models no other path shape`);
250
+ return {
251
+ kind: "radial",
252
+ stops
253
+ };
254
+ }
255
+ return compact({
256
+ kind: "linear",
257
+ angle: gradient.angleDeg ?? 0,
258
+ stops
259
+ });
260
+ }
261
+ /**
262
+ * A read hatch as `ShapeFillProps` with `type: 'pattern'`, or `undefined` when the pattern
263
+ * names no preset — `a:pattFill/@prst` is what selects the hatch, so without it there is
264
+ * nothing to reproduce.
265
+ *
266
+ * Both colours are emitted as literals rather than scheme tokens: `PatternFillProps` types
267
+ * `fgColor`/`bgColor` as hex, so a token would not survive the write side anyway.
268
+ */
269
+ function patternOption(pattern) {
270
+ if (!pattern?.preset) return void 0;
271
+ return {
272
+ type: "pattern",
273
+ pattern: compact({
274
+ preset: pattern.preset,
275
+ fgColor: pattern.foreground ? literalColor(pattern.foreground.effectiveHex) : void 0,
276
+ bgColor: pattern.background ? literalColor(pattern.background.effectiveHex) : void 0
277
+ }) ?? { preset: pattern.preset }
278
+ };
279
+ }
280
+ //#endregion
281
+ //#region src/script/from-read/picture-fill.ts
282
+ /**
283
+ * A picture fill as `ShapeFillProps`, or `undefined` when its bytes cannot be carried —
284
+ * in which case a `dropped` note has been recorded and the surface comes out unfilled.
285
+ */
286
+ function pictureFillOption(picture, assets, notes, subject) {
287
+ const asset = pictureAsset(picture, assets, notes, subject);
288
+ if (!asset) return void 0;
289
+ noteUncarriedGeometry(picture, notes, subject);
290
+ return compactRequired({
291
+ type: "image",
292
+ image: { data: asset },
293
+ transparency: picture.alpha === 1 ? void 0 : alphaToTransparency(picture.alpha)
294
+ });
295
+ }
296
+ /** The fill's image as an asset, or `null` with a note saying why it could not be one. */
297
+ function pictureAsset(picture, assets, notes, subject) {
298
+ const partName = picture.partName;
299
+ if (partName === null) {
300
+ notes.note(subject.construct, "dropped", "unsupported", `${subject.subject} is filled with an image (${subject.element}) that embeds no image part — an external or linked blip — so there are no bytes to carry and it comes out unfilled`);
301
+ return null;
302
+ }
303
+ if (assets.contentTypeOf(partName) === "image/svg+xml") {
304
+ notes.note(subject.construct, "dropped", "unwritable", `${subject.subject} is filled with an SVG image (${subject.element} → ${partName}); a picture fill accepts raster bytes only on the write side, so it comes out unfilled`);
305
+ return null;
306
+ }
307
+ const asset = assets.assetFor(partName);
308
+ if (!asset) {
309
+ notes.note(subject.construct, "dropped", "unsupported", `the image filling ${subject.subject} (${subject.element} → ${partName}) is not present in the package, so there are no bytes to carry`);
310
+ return null;
311
+ }
312
+ return asset;
313
+ }
314
+ /** `true` when a rect actually insets an edge; an explicit but empty rect reports zeros. */
315
+ function isRectSet(rect) {
316
+ return rect !== null && (rect.left !== 0 || rect.top !== 0 || rect.right !== 0 || rect.bottom !== 0);
317
+ }
318
+ /**
319
+ * Note the parts of the fill's geometry the write path's fixed stretch cannot express.
320
+ *
321
+ * Only the ones the source actually uses: a note on every picture fill would say nothing,
322
+ * and the round-trip check reads an unmatched note as a candidate for review.
323
+ */
324
+ function noteUncarriedGeometry(picture, notes, subject) {
325
+ const lost = [];
326
+ if (picture.mode === "tile" || picture.tile !== null) lost.push("tiling (a:tile)");
327
+ if (isRectSet(picture.srcRect)) lost.push("the source crop (a:srcRect)");
328
+ if (isRectSet(picture.fillRect)) lost.push("the destination inset (a:stretch/a:fillRect)");
329
+ if (picture.dpi !== null && picture.dpi !== 0) lost.push(`the render resolution (@dpi="${picture.dpi}")`);
330
+ if (picture.rotWithShape === false) lost.push("not rotating with the shape (@rotWithShape=\"0\")");
331
+ if (lost.length === 0) return;
332
+ notes.note(`${subject.construct}.geometry`, "approximated", "unwritable", `the image filling ${subject.subject} is re-embedded, but the write path emits every picture fill as a plain stretched blip (dpi="0" rotWithShape="1", <a:srcRect/><a:stretch><a:fillRect/></a:stretch>), so ${lost.join(", ")} ${lost.length === 1 ? "does" : "do"} not carry`);
333
+ }
334
+ //#endregion
335
+ //#region src/script/from-read/detect.ts
336
+ /**
337
+ * Presence checks for constructs the read model has no accessor for.
338
+ *
339
+ * These reach through the documented raw hatch (`element_`) rather than through a typed
340
+ * accessor, which needs justifying. The rule they follow: **detect, never decode.** Each
341
+ * function answers only "is this here?", so the answer can become a {@link FidelityNote}.
342
+ * Nothing here interprets a value or feeds one into a call — that would be building a
343
+ * private read path alongside the public one, and it would rot the moment the element's
344
+ * meaning changed.
345
+ *
346
+ * The alternative is worse than the coupling. Without these checks, a deck with an embedded
347
+ * video converts to a static poster image and a deck with an equation converts to an empty
348
+ * box, both silently and both with a clean round-trip, because a diff cannot see what
349
+ * neither side read. A loss you can name is a bug report; a loss nothing observes is a
350
+ * defect that ships.
351
+ *
352
+ * Each check here is a standing argument for a real accessor. When one lands, the
353
+ * corresponding function should be deleted, not kept in sync.
354
+ */
355
+ /** Office MathML namespace — the equation body itself, wrapped by `a14:m` in a text run. */
356
+ const MATH_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math";
357
+ /** Shape-tree children that carry drawing content, as opposed to the tree's own properties. */
358
+ const SHAPE_ELEMENTS = /* @__PURE__ */ new Set([
359
+ "sp",
360
+ "pic",
361
+ "graphicFrame",
362
+ "grpSp",
363
+ "cxnSp"
364
+ ]);
365
+ const ELEMENT_NODE = 1;
366
+ function has(element, namespaceURI, localName) {
367
+ const el = element;
368
+ if (!el || typeof el.getElementsByTagNameNS !== "function") return false;
369
+ return el.getElementsByTagNameNS(namespaceURI, localName).length > 0;
370
+ }
371
+ /**
372
+ * The first *direct* child with this name.
373
+ *
374
+ * A subtree search is wrong for structural lookups, in two ways that both bite here.
375
+ * `a:ext` names two unrelated elements — a shape extent (`@cx`/`@cy`) and an extension-list
376
+ * entry (`@uri`) — so a subtree search finds whichever comes first in document order. And on
377
+ * a group it descends into the group's own children, so a nested group's transform answers
378
+ * for its parent's.
379
+ */
380
+ function child(element, namespaceURI, localName) {
381
+ if (!element) return null;
382
+ for (let node = element.firstChild; node; node = node.nextSibling) {
383
+ if (node.nodeType !== ELEMENT_NODE) continue;
384
+ const el = node;
385
+ if (el.namespaceURI === namespaceURI && el.localName === localName) return el;
386
+ }
387
+ return null;
388
+ }
389
+ /** Every direct element child, as {@link ElementLike}s. */
390
+ function children(element) {
391
+ if (!element) return [];
392
+ const out = [];
393
+ for (let node = element.firstChild; node; node = node.nextSibling) if (node.nodeType === ELEMENT_NODE) out.push(node);
394
+ return out;
395
+ }
396
+ /**
397
+ * `true` when this shape is an embedded audio or video rather than a still picture.
398
+ *
399
+ * PowerPoint authors media as a `p:pic` whose `p:nvPr` carries an `a:videoFile` or
400
+ * `a:audioFile`, with the poster frame as the picture's own blip. The read model reports
401
+ * only the poster — `mediaKind` distinguishes raster from SVG, not still from moving — so
402
+ * without this check a video converts to a static image that looks deliberate.
403
+ */
404
+ function isAudioVideo(element) {
405
+ return has(element, OOXML_NS.a, "videoFile") || has(element, OOXML_NS.a, "audioFile");
406
+ }
407
+ /**
408
+ * `true` when this shape's text body contains an OMML equation.
409
+ *
410
+ * An equation lives in `m:oMath` inside an `a14:m` run, which contributes nothing to
411
+ * `TextFrame.text`, so an equation-only shape reads as an empty text frame. The write API
412
+ * *can* author one (`TextProps.math`, plus the `ts-pptx/math` subpath for LaTeX/MathML
413
+ * input), which makes this a read-side gap rather than a hard limit.
414
+ */
415
+ function hasEquation(element) {
416
+ return has(element, MATH_NS, "oMath");
417
+ }
418
+ /**
419
+ * `true` when this `p:sp` is a text box rather than an auto shape.
420
+ *
421
+ * The discriminator is one attribute, `p:cNvSpPr/@txBox`, and nothing reads it. The
422
+ * tempting substitute — "it has no preset geometry" — is wrong for the common case:
423
+ * PowerPoint gives every text box an explicit `<a:prstGeom prst="rect"/>`, so that test
424
+ * calls every real text box an auto shape. The difference is not cosmetic. PowerPoint
425
+ * autofits, wraps and resizes a text box by different rules, and offers a different
426
+ * right-click menu for it.
427
+ *
428
+ * Direct child rather than a subtree search: `p:cNvSpPr` sits under `p:nvSpPr`, and a group
429
+ * would otherwise answer for its first child.
430
+ */
431
+ function isTextBox(element) {
432
+ return child(child(element, OOXML_NS.p, "nvSpPr"), OOXML_NS.p, "cNvSpPr")?.getAttribute("txBox") === "1";
433
+ }
434
+ /**
435
+ * `true` when a group's child coordinate space equals its own frame, so its children are
436
+ * already in slide-absolute units.
437
+ *
438
+ * `addGroup` always emits an identity child space; a source group whose `a:chOff`/`a:chExt`
439
+ * differ scales or shifts its contents, and that behaviour cannot be reproduced. The read
440
+ * model has no accessor for the child space — `absoluteFrame` deliberately composes it away,
441
+ * which is right for locating a shape and wrong for spotting this difference.
442
+ *
443
+ * A group with no explicit transform inherits an identity one, so a missing `a:xfrm` is
444
+ * `true` rather than unknown.
445
+ */
446
+ function hasIdentityChildSpace(element) {
447
+ const xfrm = child(child(element, OOXML_NS.p, "grpSpPr"), OOXML_NS.a, "xfrm");
448
+ if (!xfrm) return true;
449
+ const off = child(xfrm, OOXML_NS.a, "off");
450
+ const ext = child(xfrm, OOXML_NS.a, "ext");
451
+ const chOff = child(xfrm, OOXML_NS.a, "chOff");
452
+ const chExt = child(xfrm, OOXML_NS.a, "chExt");
453
+ if (!off || !ext || !chOff || !chExt) return true;
454
+ return off.getAttribute("x") === chOff.getAttribute("x") && off.getAttribute("y") === chOff.getAttribute("y") && ext.getAttribute("cx") === chExt.getAttribute("cx") && ext.getAttribute("cy") === chExt.getAttribute("cy");
455
+ }
456
+ /**
457
+ * `true` when this theme declares an `a:fmtScheme`.
458
+ *
459
+ * The format scheme holds the three fill, three line and three effect style lists a shape's
460
+ * `p:style` indexes into. Neither half of the library touches it: nothing reads it, and the
461
+ * write path emits a hardcoded Office one. So a deck whose theme carries a custom format
462
+ * scheme is repainted with Office's, and this is the only way to notice.
463
+ */
464
+ function hasFormatScheme(element) {
465
+ return has(element, OOXML_NS.a, "fmtScheme");
466
+ }
467
+ /**
468
+ * `true` when this slide master declares per-level text styles (`p:txStyles`) with content.
469
+ *
470
+ * `SlideMasterProps.textStyles` can author them, so this is purely a read-side gap — the
471
+ * default size, face, colour and bullet of every placeholder level is invisible to the
472
+ * converter and comes back as PowerPoint's built-in default.
473
+ */
474
+ function hasTextStyles(element) {
475
+ return children(child(element, OOXML_NS.p, "txStyles")).some((group) => children(group).length > 0);
476
+ }
477
+ /**
478
+ * `true` when this shape is a placeholder — the tier that *reserves* a region for a slide to
479
+ * fill, as opposed to the decoration that is drawn as-is.
480
+ *
481
+ * A DOM predicate rather than `shape.placeholder !== null`, and that is not an oversight: only
482
+ * `AutoShape` reports a `p:ph` through the read model, so the model-based spelling would call
483
+ * a *picture* placeholder (`p:nvPicPr/p:nvPr/p:ph`, which PowerPoint does author) decoration
484
+ * and re-draw it as a fixed image on every slide bound to the layout.
485
+ *
486
+ * Not a direct-child lookup, unlike its neighbours: the `p:ph` sits two or three levels down
487
+ * inside whichever `p:nv*Pr` the shape kind uses. That means a *group* containing a
488
+ * placeholder answers `true` for its child, which is the wanted answer anyway — a group is
489
+ * never itself a placeholder, and a layout that put one inside a group is one whose grouping
490
+ * cannot survive being flattened into loose objects.
491
+ */
492
+ function isPlaceholderShape(element) {
493
+ return has(element, OOXML_NS.p, "ph");
494
+ }
495
+ /**
496
+ * `true` when this master or layout carries a shape that is not a placeholder — a logo, a
497
+ * rule, a background image, a footer graphic.
498
+ *
499
+ * Only the **master** arm still needs this. A layout's decoration is now transcribed into its
500
+ * `defineSlideMaster({ objects })` call, so what did not survive is reported per shape rather
501
+ * than as a blanket count. A master's cannot be: `defineSlideMaster` authors a *layout* under
502
+ * the one shared master, so a master's own shape tree has no write-side counterpart at all,
503
+ * and a deck whose master carries none loses nothing — which is why this is a presence check
504
+ * rather than an unconditional note.
505
+ */
506
+ function hasDecorativeShapes(element) {
507
+ return children(child(child(element, OOXML_NS.p, "cSld"), OOXML_NS.p, "spTree")).some((shape) => shape.namespaceURI === OOXML_NS.p && SHAPE_ELEMENTS.has(shape.localName ?? "") && !isPlaceholderShape(shape));
508
+ }
509
+ //#endregion
510
+ //#region src/script/from-read/text.ts
511
+ /**
512
+ * `a:pPr/@algn` → `HAlign`. `dist` and `thaiDist` (distributed justification) are
513
+ * deliberately absent: the write API has no spelling for them, and mapping them onto
514
+ * `justify` would be a silent visual change rather than a declared loss.
515
+ */
516
+ const ALIGN = {
517
+ l: "left",
518
+ ctr: "center",
519
+ r: "right",
520
+ just: "justify"
521
+ };
522
+ /** `a:bodyPr/@anchor` → `VAlign`. */
523
+ const ANCHOR$1 = {
524
+ t: "top",
525
+ ctr: "middle",
526
+ b: "bottom"
527
+ };
528
+ /** `AutofitMode` → the write API's `fit`. */
529
+ const FIT = {
530
+ none: "none",
531
+ normAutofit: "shrink",
532
+ spAutoFit: "resize"
533
+ };
534
+ /** `a:bodyPr/@vert` values the write API's `vert` accepts (`TextVertType`). */
535
+ const WRITABLE_VERT = /* @__PURE__ */ new Set([
536
+ "eaVert",
537
+ "horz",
538
+ "mongolianVert",
539
+ "vert",
540
+ "vert270",
541
+ "wordArtVert",
542
+ "wordArtVertRtl"
543
+ ]);
544
+ /**
545
+ * The `a:buAutoNum/@type` values the write API's `numberType` names.
546
+ *
547
+ * This used to be the *discriminator* between a numbered and a character bullet, back when
548
+ * the read model reported both as one tagged string. `BulletDetail.kind` does that now, so
549
+ * the set has one job left: telling a scheme the write path can spell from one it cannot.
550
+ */
551
+ const AUTO_NUMBER_TYPES = /* @__PURE__ */ new Set([
552
+ "alphaLcParenBoth",
553
+ "alphaLcParenR",
554
+ "alphaLcPeriod",
555
+ "alphaUcParenBoth",
556
+ "alphaUcParenR",
557
+ "alphaUcPeriod",
558
+ "arabicParenBoth",
559
+ "arabicParenR",
560
+ "arabicPeriod",
561
+ "arabicPlain",
562
+ "romanLcParenBoth",
563
+ "romanLcParenR",
564
+ "romanLcPeriod",
565
+ "romanUcParenBoth",
566
+ "romanUcParenR",
567
+ "romanUcPeriod"
568
+ ]);
569
+ /**
570
+ * `Paragraph.bulletDetail` → the write API's `bullet` option.
571
+ *
572
+ * This function used to take a **tagged** string — `'none'`, `'char:<glyph>'` or
573
+ * `'autoNum:<type>'` — and its first version read it as a bare glyph. The result was silent
574
+ * and universal: a paragraph that explicitly suppressed its bullet (`a:buNone`, which is most
575
+ * of them) came back with a literal `n` bullet, because `'none'.codePointAt(0)` is `n`; a real
576
+ * character bullet came back as `c`, from the `char:` tag; and a numbered list came back as
577
+ * `a`. Every converted deck was affected and nothing failed, which is why the round-trip check
578
+ * exists — and why the accessor is now a discriminated union with no parsing left to get wrong.
579
+ */
580
+ function bulletOption(bullet, notes) {
581
+ if (bullet.kind === "none") return false;
582
+ if (bullet.kind === "picture") {
583
+ notes.note("text.bullet.picture", "approximated", "unsupported", "this paragraph uses an image as its bullet glyph (a:buBlip); the text mapper has no asset resolver to re-embed it with, so the bullet falls back to the default character");
584
+ return true;
585
+ }
586
+ const style = bulletStyle(bullet, notes);
587
+ if (bullet.kind === "autoNum") {
588
+ if (!AUTO_NUMBER_TYPES.has(bullet.scheme)) {
589
+ notes.note("text.bullet.numberType", "approximated", "unwritable", `numbering scheme "${bullet.scheme}" is outside the set the write API names, so the list falls back to the default scheme`);
590
+ return compact({
591
+ type: "number",
592
+ ...startAtOption(bullet.startAt),
593
+ ...style
594
+ }) ?? { type: "number" };
595
+ }
596
+ return compact({
597
+ type: "number",
598
+ numberType: bullet.scheme,
599
+ ...startAtOption(bullet.startAt),
600
+ ...style
601
+ }) ?? { type: "number" };
602
+ }
603
+ const code = bullet.char.codePointAt(0) ?? 8226;
604
+ if (code > 65535) notes.note("text.bullet.glyph", "approximated", "unwritable", `bullet glyph U+${code.toString(16).toUpperCase()} is outside the Basic Multilingual Plane and characterCode takes a four-digit code, so the bullet falls back to the write path's default glyph`);
605
+ return compact({
606
+ characterCode: code.toString(16).toUpperCase().padStart(4, "0"),
607
+ ...style
608
+ }) ?? {};
609
+ }
610
+ /**
611
+ * `a:pPr/@marL` / `@indent` → `paraMarginLeft` / `paraIndent`.
612
+ *
613
+ * A stated margin carries as its number. An *absent* one is the interesting case, because
614
+ * leaving the key out does not reproduce it: every `bullet` state except `'inherit'` makes the
615
+ * write path put a margin on the element — its hanging default for a drawn bullet, zero for
616
+ * `bullet: false` — so a paragraph that inherits its margin has to ask for silence explicitly.
617
+ * Under `bullet: 'inherit'` nothing is written either way and the key would be noise, which is
618
+ * why the bullet option decides rather than the paragraph being asked twice.
619
+ * @param {number|null} valuePt - the paragraph's own margin in points, `null` when unset
620
+ * @param {IrValue} bulletValue - the `bullet` option this paragraph maps onto
621
+ * @return {IrValue|undefined} the option value, or `undefined` to leave the key out
622
+ */
623
+ function paraMarginOption(valuePt, bulletValue) {
624
+ if (valuePt !== null) return valuePt;
625
+ return bulletValue === "inherit" ? void 0 : "inherit";
626
+ }
627
+ /**
628
+ * `a:buAutoNum/@startAt` → `numberStartAt`.
629
+ *
630
+ * Numbering is content rather than styling: a list continuing "5. Deploy" that comes back as
631
+ * "1. Deploy" is a different slide. `1` is the schema default, so emitting it would only add
632
+ * noise to the printed script.
633
+ */
634
+ function startAtOption(startAt) {
635
+ return startAt === null || startAt === 1 ? {} : { numberStartAt: startAt };
636
+ }
637
+ /**
638
+ * The bullet's own font, size and colour (`a:buFont` / `a:buSzPct` / `a:buClr`) as the write
639
+ * API's `fontFace` / `size` / `color`.
640
+ *
641
+ * `a:buSzPts` has no write-API counterpart — `bullet.size` is a percentage of the run size —
642
+ * so an absolute bullet size is the one part of this that still cannot carry.
643
+ */
644
+ function bulletStyle(bullet, notes) {
645
+ if (bullet.sizePt !== null) notes.note("text.bullet.sizePt", "dropped", "unwritable", "this bullet sets an absolute glyph size (a:buSzPts); bullet.size is a percentage of the run size, so the absolute value has no write-API expression and the glyph follows the text size");
646
+ const sizePct = bullet.sizePct;
647
+ if (sizePct !== null && (sizePct < 25 || sizePct > 400)) notes.note("text.bullet.sizePct", "approximated", "unwritable", `bullet glyph size ${sizePct}% is outside the 25–400% range the write API accepts, so no a:buSzPct is emitted and the glyph size is left to be inherited`);
648
+ return compact({
649
+ fontFace: orUndefined(bullet.font),
650
+ size: sizePct === null || sizePct < 25 || sizePct > 400 ? void 0 : sizePct,
651
+ color: bulletColor(bullet, notes)
652
+ }) ?? {};
653
+ }
654
+ /**
655
+ * The bullet's colour, preferring the raw `schemeClr` token over the resolved literal for the
656
+ * same reason {@link runColor} does: a token keeps tracking the destination theme.
657
+ */
658
+ function bulletColor(bullet, notes) {
659
+ const scheme = bullet.schemeColor;
660
+ if (isWritableSchemeToken(scheme)) return scheme;
661
+ if (bullet.color !== null) return literalColor(bullet.color);
662
+ const resolved = bullet.resolvedColor;
663
+ if (!resolved) return void 0;
664
+ if (scheme !== null) notes.note("text.bullet.schemeToken", "approximated", "unwritable", `bullet scheme colour "${scheme}" is outside the ten tokens the write path maps, so it is baked to a literal hex and stops tracking the theme`);
665
+ return literalColor(resolved.effectiveHex);
666
+ }
667
+ /**
668
+ * Per-run character formatting, shared by shape text and table-cell text.
669
+ *
670
+ * The three decoration tokens carry their **explicit off** — `u="none"`, `strike="noStrike"`,
671
+ * `cap="none"` — rather than collapsing it into the unstated case. Stating one is a different
672
+ * fact from stating nothing: run properties resolve down the `a:lstStyle` → placeholder →
673
+ * layout → master chain, so a run that would take `u="sng"` from its list style and states
674
+ * `u="none"` is *not* underlined, while the same run with the attribute dropped is. Only an
675
+ * absent attribute (`null` here) means "state nothing"; each off token is a member of its
676
+ * enumeration in its own right (ECMA-376 §20.1.10.81 / §20.1.10.78) and would be redundant
677
+ * with omission otherwise.
678
+ */
679
+ function runOptions(run, notes) {
680
+ const underline = run.underline;
681
+ return compact({
682
+ bold: orUndefined(run.bold),
683
+ italic: orUndefined(run.italic),
684
+ underline: underline === null ? void 0 : { style: underline },
685
+ strike: orUndefined(run.strike),
686
+ fontSize: orUndefined(run.fontSizePt),
687
+ fontFace: orUndefined(run.fontName),
688
+ color: runColor(run, notes),
689
+ highlight: run.highlight ? literalColor(run.highlight.effectiveHex) : void 0,
690
+ caps: orUndefined(run.caps),
691
+ baseline: orUndefined(run.baselinePct),
692
+ hyperlink: hyperlinkOption(run)
693
+ });
694
+ }
695
+ /**
696
+ * A run's colour, preferring the raw `schemeClr` token over the resolved literal so the
697
+ * text keeps tracking the destination theme. Only the ten tokens the write path's `clrMap`
698
+ * covers survive as tokens; the other seven degrade to a literal hex there anyway, so
699
+ * passing one through unchanged would produce a silently different colour.
700
+ */
701
+ function runColor(run, notes) {
702
+ const scheme = run.schemeColor;
703
+ if (isWritableSchemeToken(scheme)) return scheme;
704
+ if (scheme !== null) {
705
+ notes.note("text.color.schemeToken", "approximated", "unwritable", `scheme colour "${scheme}" is outside the ten tokens the write path maps, so it is baked to a literal hex and stops tracking the theme`);
706
+ const resolved = run.resolvedColor;
707
+ return resolved ? literalColor(resolved.effectiveHex) : void 0;
708
+ }
709
+ if (run.color !== null) return literalColor(run.color);
710
+ const inherited = run.resolvedColor;
711
+ if (!inherited) {
712
+ notes.note("text.color.default", "approximated", "unread", "this run inherits its colour and nothing resolves what it inherits (a table style tier, or a list style the read model does not walk), so the write path paints it black — the one case where the output colour is not merely frozen but possibly wrong");
713
+ return;
714
+ }
715
+ notes.note("text.color.inherited", "flattened", "unsupported", "this run inherits its colour from the placeholder, master or theme; the write path paints an uncoloured run black instead of leaving it to inherit, so the inherited colour is resolved and baked in and no longer tracks a theme change");
716
+ return literalColor(inherited.effectiveHex);
717
+ }
718
+ /**
719
+ * A run's hyperlink. Only external links map: the read model reports an internal jump as a
720
+ * target part name, while the write API takes a slide *number*, which the deck-level walk
721
+ * resolves — so a slide link is handled there, not here.
722
+ */
723
+ function hyperlinkOption(run) {
724
+ const link = run.hyperlink;
725
+ if (!link?.url) return void 0;
726
+ return compact({
727
+ url: link.url,
728
+ tooltip: link.tooltip ?? void 0
729
+ });
730
+ }
731
+ /** Paragraph-level properties, replicated onto each of the paragraph's runs. */
732
+ function paragraphOptions(paragraph, notes) {
733
+ const spacing = paragraph.lineSpacing;
734
+ const bullet = paragraph.bulletDetail;
735
+ const align = paragraph.align;
736
+ if (align !== null && !(align in ALIGN)) notes.note("text.align", "dropped", "unwritable", `distributed alignment "${align}" has no write-API spelling, so the paragraph falls back to its inherited alignment`);
737
+ if (paragraph.spaceBeforePt === 0 || paragraph.spaceAfterPt === 0) notes.note("text.paraSpaceZero", "dropped", "unwritable", "this paragraph explicitly sets zero space before or after (a:spcBef / a:spcAft of 0), which suppresses the spacing its list style would otherwise apply; the write path treats 0 as \"unset\" and emits nothing, so the inherited spacing comes back");
738
+ const bulletValue = bullet === null ? "inherit" : bulletOption(bullet, notes);
739
+ return compact({
740
+ align: align === null ? void 0 : ALIGN[align],
741
+ indentLevel: paragraph.level === 0 ? void 0 : paragraph.level,
742
+ paraMarginLeft: paraMarginOption(paragraph.marginLeftPt, bulletValue),
743
+ paraIndent: paraMarginOption(paragraph.indentPt, bulletValue),
744
+ paraSpaceBefore: orUndefined(paragraph.spaceBeforePt),
745
+ paraSpaceAfter: orUndefined(paragraph.spaceAfterPt),
746
+ lineSpacing: spacing?.type === "points" ? spacing.valuePt : void 0,
747
+ lineSpacingMultiple: spacing?.type === "percent" ? spacing.percent / 100 : void 0,
748
+ bullet: bulletValue
749
+ }) ?? {};
750
+ }
751
+ /**
752
+ * Flatten a text frame into the `TextProps[]` first argument of `addText`.
753
+ *
754
+ * An empty paragraph becomes an item with empty text and a `breakLine`, which is how a
755
+ * blank line survives — dropping it would silently close up vertical space the author put
756
+ * there deliberately.
757
+ */
758
+ function textRuns(frame, notes) {
759
+ const paragraphs = frame.paragraphs;
760
+ const items = [];
761
+ paragraphs.forEach((paragraph, paragraphIndex) => {
762
+ const paraOpts = paragraphOptions(paragraph, notes);
763
+ const runs = paragraph.runs;
764
+ const breaks = paragraphIndex < paragraphs.length - 1;
765
+ if (runs.length === 0) {
766
+ items.push({
767
+ text: "",
768
+ options: compact({
769
+ ...paraOpts,
770
+ breakLine: breaks
771
+ }) ?? {}
772
+ });
773
+ return;
774
+ }
775
+ runs.forEach((run, runIndex) => {
776
+ const isLastRun = runIndex === runs.length - 1;
777
+ const continuation = { ...paraOpts };
778
+ delete continuation["bullet"];
779
+ const options = compact({
780
+ ...runIndex === 0 ? paraOpts : continuation,
781
+ ...runOptions(run, notes),
782
+ ...isLastRun && breaks ? { breakLine: true } : {}
783
+ });
784
+ items.push(compact({
785
+ text: run.text,
786
+ options
787
+ }) ?? { text: run.text });
788
+ });
789
+ });
790
+ return items;
791
+ }
792
+ /**
793
+ * Frame-level layout for the `TextPropsOptions` second argument — everything that belongs
794
+ * to the body as a whole rather than to any paragraph.
795
+ *
796
+ * `resolvedAnchor` is the one place a *resolved* read is the right one: vertical anchoring
797
+ * has no per-run fallback, so an unset `a:bodyPr/@anchor` genuinely means "whatever the
798
+ * placeholder says", and an appended shape does not inherit that.
799
+ */
800
+ function textFrameOptions(frame, notes) {
801
+ const body = frame.bodyProperties;
802
+ const anchor = frame.resolvedAnchor;
803
+ const vert = body?.vert ?? null;
804
+ if (vert !== null && !WRITABLE_VERT.has(vert)) notes.note("text.vert", "dropped", "unwritable", `text direction "${vert}" is outside TextVertType, so the body falls back to horizontal`);
805
+ return compact({
806
+ valign: anchor === null ? "top" : ANCHOR$1[anchor],
807
+ wrap: body?.wrap === null || body?.wrap === void 0 ? void 0 : body.wrap !== "none",
808
+ vert: vert !== null && WRITABLE_VERT.has(vert) ? vert : void 0,
809
+ fit: fitOption(frame, notes),
810
+ margin: marginOption(body)
811
+ }) ?? {};
812
+ }
813
+ /**
814
+ * `a:bodyPr`'s autofit as the write API's `fit`.
815
+ *
816
+ * A `normAutofit` that bakes `fontScale` or `lnSpcReduction` is a *different state* from a
817
+ * bare `<a:normAutofit/>`, so the two get different spellings. ECMA-376 §21.1.2.1.3 defaults
818
+ * each attribute to 100%/0% only when it is **omitted**; PowerPoint recomputes an unbaked
819
+ * scale on edit and draws the baked one exactly as written until then. Flattening both to
820
+ * `'shrink'` therefore re-emits a frame that paints its text at full size — a deck baked at
821
+ * `fontScale="40000"` comes back two and a half times too large — and the object form is
822
+ * already what the write API accepts, so nothing has to be lost here.
823
+ *
824
+ * Out-of-range percentages are the one arm that still loses something. The write path rejects
825
+ * anything outside 0–100 and drops the attribute with a warning, so passing one through would
826
+ * turn a declared loss into a silent one; the frame falls back to bare `'shrink'` with a note.
827
+ */
828
+ function fitOption(frame, notes) {
829
+ const autofit = frame.autofit;
830
+ if (autofit === null) return void 0;
831
+ if (autofit !== "normAutofit") return FIT[autofit];
832
+ const baked = compact({
833
+ fontScale: bakedPct(frame.autofitFontScale, "fontScale", notes),
834
+ lnSpcReduction: bakedPct(frame.autofitLineSpaceReduction, "lnSpcReduction", notes)
835
+ });
836
+ return baked ? {
837
+ type: "shrink",
838
+ ...baked
839
+ } : FIT[autofit];
840
+ }
841
+ /** A baked `a:normAutofit` percentage, or `undefined` (with a note) when the write API would reject it. */
842
+ function bakedPct(value, name, notes) {
843
+ if (value === null) return void 0;
844
+ if (value >= 0 && value <= 100) return value;
845
+ notes.note(`text.autofit.${name}`, "dropped", "unwritable", `baked a:normAutofit/@${name} of ${value}% is outside the 0-100 the write API accepts, so the frame re-emits a bare <a:normAutofit/>`);
846
+ }
847
+ /**
848
+ * `a:bodyPr` insets as the write API's `margin`.
849
+ *
850
+ * A unit change, not just a copy: the read model reports insets in points, `margin` takes
851
+ * **inches**, and it warns on any value `>= 1` on the assumption it is a stray points
852
+ * value. Passing points straight through would therefore both inset the text by roughly
853
+ * 72× too much and trip that warning.
854
+ *
855
+ * Emitted as the four-tuple whenever any inset is set, since a partial tuple is not
856
+ * expressible; an unset side takes PowerPoint's own default rather than zero, so those are
857
+ * spelled out — defaulting them to 0 would visibly reflow every body that sets one side.
858
+ */
859
+ const DEFAULT_INSETS_PT = {
860
+ left: 7.2,
861
+ right: 7.2,
862
+ top: 3.6,
863
+ bottom: 3.6
864
+ };
865
+ function marginOption(body) {
866
+ const insets = body?.insetsPt;
867
+ if (!insets) return void 0;
868
+ const { left, right, top, bottom } = insets;
869
+ if (left === void 0 && right === void 0 && top === void 0 && bottom === void 0) return void 0;
870
+ return [
871
+ pointsToInches(top ?? DEFAULT_INSETS_PT.top),
872
+ pointsToInches(right ?? DEFAULT_INSETS_PT.right),
873
+ pointsToInches(bottom ?? DEFAULT_INSETS_PT.bottom),
874
+ pointsToInches(left ?? DEFAULT_INSETS_PT.left)
875
+ ];
876
+ }
877
+ //#endregion
878
+ //#region src/script/from-read/table.ts
879
+ /** How {@link pictureFillOption}'s notes name a cell. */
880
+ const CELL_PICTURE_FILL = {
881
+ construct: "table.cell.fill.picture",
882
+ subject: "this table cell",
883
+ element: "a:tcPr/a:blipFill"
884
+ };
885
+ /** How {@link pictureFillOption}'s notes name the table's own background. */
886
+ const TABLE_PICTURE_FILL = {
887
+ construct: "table.fill.picture",
888
+ subject: "this table",
889
+ element: "a:tblPr/a:blipFill"
890
+ };
891
+ /** `a:tcPr/@anchor` → the write API's `valign`. */
892
+ const ANCHOR = {
893
+ t: "top",
894
+ ctr: "middle",
895
+ b: "bottom"
896
+ };
897
+ function tableCall(frame, table, notes, assets) {
898
+ const styleId = table.styleId;
899
+ const hasStyle = table.resolvedStyle !== null;
900
+ const rows = [];
901
+ const rowHeights = [];
902
+ for (const row of table.rows) {
903
+ const cells = [];
904
+ for (const cell of row.cells) {
905
+ if (cell.isMergeContinuation) continue;
906
+ cells.push(cellIr(cell, hasStyle, notes, assets));
907
+ }
908
+ rows.push(cells);
909
+ rowHeights.push(row.heightEmu ?? 0);
910
+ }
911
+ const autoRows = rowHeights.filter((height) => height === 0).length;
912
+ if (autoRows === rowHeights.length && autoRows > 0) notes.note("table.rowAuto", "approximated", "unsupported", "every row is auto-height (a:tr/@h of 0), so no rowH is emitted and addTable divides the frame height evenly among the rows; the rows come back pinned to that even split rather than sized to their content");
913
+ else if (autoRows > 0) notes.note("table.rowAuto", "approximated", "unsupported", "at least one row is auto-height (a:tr/@h of 0) while others are not; rowH must be given for every row or none, so the auto rows are emitted as 0 and come back pinned to the height their content produced");
914
+ if (styleId === null) notes.note("table.style", "dropped", "unsupported", "this table names no a:tableStyleId, so it takes the generated deck's default table style");
915
+ const columnWidths = table.columnWidths;
916
+ return {
917
+ method: "addTable",
918
+ args: [rows, compact({
919
+ ...positionOfFrame(frame),
920
+ objectName: frame.name || void 0,
921
+ tableStyle: orUndefined(styleId),
922
+ tableFill: tableFill(table, notes, assets),
923
+ hasHeader: table.firstRowHeader ? true : void 0,
924
+ hasBandedRows: table.bandedRows ? true : void 0,
925
+ colW: columnWidths.every((w) => w === null) ? void 0 : columnWidths.map((w) => inches(w ?? 0)),
926
+ rowH: rowHeights.every((h) => h === 0) ? void 0 : rowHeights.map(inches)
927
+ }) ?? {}],
928
+ ...frame.name ? { sourceName: frame.name } : {}
929
+ };
930
+ }
931
+ /**
932
+ * The table's own background (`a:tblPr` fill).
933
+ *
934
+ * Simpler than {@link cellFill}, and for one reason: a `a:tblPr` fill can only be the
935
+ * table's own. There is no style graph feeding into it, so there is no
936
+ * own-versus-inherited ambiguity to be careful about and the resolved colour can be used
937
+ * directly. A scheme token still wins over the literal when there is one, so the replica
938
+ * keeps tracking its theme.
939
+ */
940
+ function tableFill(table, notes, assets) {
941
+ const gradient = table.gradientFill;
942
+ if (gradient) {
943
+ const stops = gradientStops(gradient, notes, "table.fill");
944
+ if (stops) return {
945
+ type: "gradient",
946
+ gradient: stops
947
+ };
948
+ }
949
+ const pattern = patternOption(table.patternFill);
950
+ if (pattern) return pattern;
951
+ const picture = table.pictureFill;
952
+ if (picture) return pictureFillOption(picture, assets, notes, TABLE_PICTURE_FILL);
953
+ const scheme = table.fillSchemeColor;
954
+ if (scheme !== null) return { color: scheme };
955
+ const resolved = table.resolvedFill;
956
+ return resolved ? { color: literalColor(resolved.effectiveHex) } : void 0;
957
+ }
958
+ /**
959
+ * One cell as a `TableCell`.
960
+ *
961
+ * Cell text reuses the shape text mapper, so a per-run format inside a cell behaves
962
+ * identically to one on a shape — the read model already shares `TextFrame` between the
963
+ * two, and diverging here would be a difference with no cause.
964
+ */
965
+ function cellIr(cell, hasStyle, notes, assets) {
966
+ const frame = cell.textFrame;
967
+ const anchor = cell.anchor;
968
+ const margins = cell.marginsEmu;
969
+ if (cell.id !== null || cell.headerIds.length > 0) notes.note("table.cell.headers", "dropped", "unwritable", "this cell carries a screen-reader header association (a:tc/@id / a:tcPr/a:headers); PowerPoint strips both on save, so there is no write option and the association is lost — hasHeader (a:tblPr/@firstRow) is the marker that survives");
970
+ const options = compact({
971
+ fill: cellFill(cell, hasStyle, notes, assets),
972
+ border: cellBorders(cell, notes),
973
+ diagonal: cellDiagonals(cell, notes),
974
+ anchorCtr: cell.anchorCtr ? true : void 0,
975
+ cell3D: cellThreeD(cell),
976
+ valign: anchor === null ? void 0 : ANCHOR[anchor],
977
+ textDirection: cellTextDirection(cell, notes),
978
+ horzOverflow: cell.horzOverflow === "clip" || cell.horzOverflow === "overflow" ? cell.horzOverflow : void 0,
979
+ colspan: cell.gridSpan > 1 ? cell.gridSpan : void 0,
980
+ rowspan: cell.rowSpan > 1 ? cell.rowSpan : void 0,
981
+ margin: margins ? [
982
+ inches(margins.top ?? 0),
983
+ inches(margins.right ?? 0),
984
+ inches(margins.bottom ?? 0),
985
+ inches(margins.left ?? 0)
986
+ ] : void 0,
987
+ ...frame?.paragraphs[0]?.runs[0] ? runOptions(frame.paragraphs[0].runs[0], notes) ?? {} : {}
988
+ });
989
+ return compact({
990
+ text: frame ? textRuns(frame, notes) : cell.text,
991
+ options
992
+ }) ?? { text: "" };
993
+ }
994
+ /**
995
+ * The two corner-to-corner rules as `TableCellProps.diagonal`.
996
+ *
997
+ * Kept out of {@link cellBorders}' tuple for the same reason the write option is: they are
998
+ * not edges. Every dash and colour a diagonal can carry is one an edge can, so they share
999
+ * {@link borderIr} — including its out-of-enum dash note.
1000
+ */
1001
+ function cellDiagonals(cell, notes) {
1002
+ const borders = cell.borders;
1003
+ if (!borders || !borders.tlToBr && !borders.blToTr) return void 0;
1004
+ return compact({
1005
+ tlToBr: borders.tlToBr ? borderIr(borders.tlToBr, notes) : void 0,
1006
+ blToTr: borders.blToTr ? borderIr(borders.blToTr, notes) : void 0
1007
+ });
1008
+ }
1009
+ /**
1010
+ * A cell's `a:cell3D` as `TableCellProps.cell3D`.
1011
+ *
1012
+ * Every field is passed through as read: the write path vets each against its `ST_` union
1013
+ * before emission, so a value from a source deck that somehow falls outside one is caught
1014
+ * there rather than being screened twice with two chances to disagree. Sizes are already in
1015
+ * points on both sides.
1016
+ */
1017
+ function cellThreeD(cell) {
1018
+ const cell3D = cell.cell3D;
1019
+ if (!cell3D) return void 0;
1020
+ const rig = cell3D.lightRig;
1021
+ return compact({
1022
+ preset: orUndefined(cell3D.bevel.preset),
1023
+ width: orUndefined(cell3D.bevel.widthPt),
1024
+ height: orUndefined(cell3D.bevel.heightPt),
1025
+ material: orUndefined(cell3D.material),
1026
+ lightRig: rig?.rig && rig.dir ? {
1027
+ rig: rig.rig,
1028
+ dir: rig.dir
1029
+ } : void 0
1030
+ }) ?? {};
1031
+ }
1032
+ /** The `ST_TextVerticalType` values `TextBaseProps.textDirection` accepts. */
1033
+ const WRITABLE_TEXT_DIRECTIONS = /* @__PURE__ */ new Set([
1034
+ "horz",
1035
+ "vert",
1036
+ "vert270",
1037
+ "wordArtVert"
1038
+ ]);
1039
+ /**
1040
+ * A cell's `a:tcPr/@vert` as the write API's `textDirection`.
1041
+ *
1042
+ * `ST_TextVerticalType` has nine values and the write option covers four. The rest
1043
+ * (`eaVert`, `mongolianVert`, the WordArt right-to-left variants) are East-Asian layout
1044
+ * modes with no `TextBaseProps` spelling, so they are noted rather than written — passing
1045
+ * one straight through would put a value the option does not admit into the attribute.
1046
+ */
1047
+ function cellTextDirection(cell, notes) {
1048
+ const vert = cell.verticalText;
1049
+ if (vert === null || vert === "horz") return void 0;
1050
+ if (WRITABLE_TEXT_DIRECTIONS.has(vert)) return vert;
1051
+ notes.note("table.cell.vert", "dropped", "unwritable", `this cell's a:tcPr/@vert is \`${vert}\`, which textDirection does not spell (it covers horz/vert/vert270/wordArtVert), so its text lands horizontal`);
1052
+ }
1053
+ /**
1054
+ * A cell's fill.
1055
+ *
1056
+ * The question here is not "what colour is this cell" but "what did the *source* say", and
1057
+ * the two differ for a styled table: `resolvedFill` answers the first, folding the cell's
1058
+ * own `a:solidFill` together with the colour it merely inherits from the style's
1059
+ * header/banding rules. Writing an inherited colour back would turn every banded cell into
1060
+ * an explicitly-filled one — the copy looks right until someone changes its table style, and
1061
+ * then nothing moves.
1062
+ *
1063
+ * {@link TableCell.hasOwnFill} separates them: an `a:tcPr` either carries an
1064
+ * `EG_FillProperties` child or it does not. So a cell's own fill is emitted (in whatever
1065
+ * form it took), and a cell with none is left to the style GUID, which reproduces the
1066
+ * banding exactly rather than approximately. Neither case loses anything, which is why
1067
+ * neither records a note.
1068
+ */
1069
+ function cellFill(cell, hasStyle, notes, assets) {
1070
+ if (cell.fillNoFill) return { type: "none" };
1071
+ const gradient = cell.gradientFill;
1072
+ if (gradient) {
1073
+ const stops = gradientStops(gradient, notes, "table.cell.fill");
1074
+ if (stops) return {
1075
+ type: "gradient",
1076
+ gradient: stops
1077
+ };
1078
+ }
1079
+ const pattern = patternOption(cell.patternFill);
1080
+ if (pattern) return pattern;
1081
+ const picture = cell.pictureFill;
1082
+ if (picture) return pictureFillOption(picture, assets, notes, CELL_PICTURE_FILL);
1083
+ const scheme = cell.fillSchemeColor;
1084
+ if (scheme !== null) return { color: scheme };
1085
+ if (hasStyle && !cell.hasOwnFill) return void 0;
1086
+ const resolved = cell.resolvedFill;
1087
+ if (!resolved) return void 0;
1088
+ return { color: literalColor(resolved.effectiveHex) };
1089
+ }
1090
+ /**
1091
+ * The four edge borders as the write API's `[top, right, bottom, left]` tuple.
1092
+ *
1093
+ * A suppressed edge (`a:noFill`) becomes `type: 'none'`, which is distinct from an absent
1094
+ * one — the first means "deliberately no rule here", the second means "inherit". The two
1095
+ * diagonals the read model also decodes are carried separately, by {@link cellDiagonals}.
1096
+ */
1097
+ function cellBorders(cell, notes) {
1098
+ const borders = cell.borders;
1099
+ if (!borders) return void 0;
1100
+ const edges = [
1101
+ borders.top,
1102
+ borders.right,
1103
+ borders.bottom,
1104
+ borders.left
1105
+ ];
1106
+ if (edges.every((edge) => edge === null)) return void 0;
1107
+ return edges.map((edge) => borderIr(edge, notes));
1108
+ }
1109
+ /** The `ST_PresetLineDashVal` tokens `BorderProps.dashType` accepts. */
1110
+ const WRITABLE_DASHES$1 = /* @__PURE__ */ new Set([
1111
+ "solid",
1112
+ "dot",
1113
+ "dash",
1114
+ "lgDash",
1115
+ "dashDot",
1116
+ "lgDashDot",
1117
+ "lgDashDotDot",
1118
+ "sysDash",
1119
+ "sysDot",
1120
+ "sysDashDot",
1121
+ "sysDashDotDot"
1122
+ ]);
1123
+ /**
1124
+ * One decoded edge (or diagonal) as `BorderProps`.
1125
+ *
1126
+ * `type` stays the coarse on/off switch and `dashType` carries the exact preset, so a
1127
+ * `lgDashDot` rule comes back as itself rather than as the generic `sysDash` every dash
1128
+ * used to collapse onto. A dash outside `ST_PresetLineDashVal` cannot have come from a
1129
+ * conformant deck, so it is dropped to a plain dashed rule and noted.
1130
+ */
1131
+ function borderIr(edge, notes) {
1132
+ if (!edge) return {};
1133
+ if (edge.noFill) return { type: "none" };
1134
+ const dash = edge.dash;
1135
+ const known = dash === null || WRITABLE_DASHES$1.has(dash);
1136
+ if (!known) notes.note("table.cell.borders.dash", "approximated", "unsupported", `a cell border's a:prstDash/@val is \`${dash}\`, which is outside ST_PresetLineDashVal; it is written as a plain dashed rule`);
1137
+ return compact({
1138
+ type: dash === null || dash === "solid" ? "solid" : "dash",
1139
+ dashType: known && dash !== null && dash !== "solid" ? dash : void 0,
1140
+ color: edge.schemeColor ?? (edge.color === null ? void 0 : literalColor(edge.color)),
1141
+ width: orUndefined(edge.widthPt)
1142
+ }) ?? {};
1143
+ }
1144
+ //#endregion
1145
+ //#region src/script/from-read/chart.ts
1146
+ /**
1147
+ * Read chart-group token → `CHART_NAME`. The read model strips the `Chart` suffix from the
1148
+ * plot-area element name, so `c:barChart` arrives as `bar`. A 3-D group maps onto the
1149
+ * matching flat type where one exists; anything absent from this table has no write-API
1150
+ * chart type and is reported rather than guessed at.
1151
+ */
1152
+ const CHART_TYPE = {
1153
+ area: "area",
1154
+ area3D: "area",
1155
+ bar: "bar",
1156
+ bar3D: "bar3D",
1157
+ bubble: "bubble",
1158
+ bubble3D: "bubble3D",
1159
+ doughnut: "doughnut",
1160
+ line: "line",
1161
+ line3D: "line",
1162
+ pie: "pie",
1163
+ pie3D: "pie",
1164
+ radar: "radar",
1165
+ scatter: "scatter",
1166
+ stock: "stock",
1167
+ surface: "surface",
1168
+ surface3D: "surface"
1169
+ };
1170
+ /** `c:legendPos/@val` → the write API's `legendPos`. */
1171
+ const LEGEND_POS = {
1172
+ r: "r",
1173
+ l: "l",
1174
+ t: "t",
1175
+ b: "b",
1176
+ tr: "tr"
1177
+ };
1178
+ function chartCall(frame, chart, notes) {
1179
+ const sourceType = chart.chartType;
1180
+ const type = sourceType === null ? void 0 : CHART_TYPE[sourceType];
1181
+ if (!type) {
1182
+ notes.note("chart.type", "dropped", "unwritable", `chart type "${sourceType ?? "none"}" has no write-API counterpart, so the chart is omitted`);
1183
+ return null;
1184
+ }
1185
+ if (chart.chartTypes.length > 1) notes.note("chart.combo", "flattened", "unsupported", `a combo chart (${chart.chartTypes.join(" + ")}) is emitted as a single ${type} chart; addChart's multi-type form needs a per-series type the read model does not report`);
1186
+ notes.note("chart.workbook", "approximated", "unsupported", "the chart is rebuilt from its cached plot values, so its embedded workbook is regenerated: the plotted numbers match, but the source sheet's formulas, extra columns and formatting are gone");
1187
+ const data = seriesData(chart, notes);
1188
+ if (data.length === 0) {
1189
+ notes.note("chart.data", "dropped", "unsupported", "this chart caches no plottable series values");
1190
+ return null;
1191
+ }
1192
+ return {
1193
+ method: "addChart",
1194
+ args: [data, compact({
1195
+ type,
1196
+ ...positionOfFrame(frame),
1197
+ objectName: frame.name || void 0,
1198
+ ...titleOptions(chart),
1199
+ ...legendOptions(chart),
1200
+ ...labelOptions(chart),
1201
+ ...axisOptions(chart),
1202
+ chartColors: seriesColors(chart)
1203
+ }) ?? { type }],
1204
+ ...frame.name ? { sourceName: frame.name } : {}
1205
+ };
1206
+ }
1207
+ /**
1208
+ * Series as `OptsChartData[]`. Category labels ride on the first series, which is the shape
1209
+ * `addChart` expects; the read model reports them per-series but they are shared in
1210
+ * practice.
1211
+ */
1212
+ function seriesData(chart, notes) {
1213
+ const categories = chart.categories.map((label) => label ?? "");
1214
+ let sawBlank = false;
1215
+ const data = chart.series.map((series, index) => {
1216
+ const values = series.values.map((value) => {
1217
+ if (value === null) sawBlank = true;
1218
+ return value ?? 0;
1219
+ });
1220
+ return compact({
1221
+ name: orUndefined(series.name),
1222
+ values,
1223
+ labels: index === 0 && categories.length > 0 ? categories : void 0
1224
+ }) ?? { values };
1225
+ });
1226
+ if (sawBlank) notes.note("chart.blanks", "approximated", "unwritable", "a blank cached data point becomes 0, because OptsChartData.values is number[] and has no spelling for a gap; on a line chart this draws a dip to the axis where the source showed a break");
1227
+ return data;
1228
+ }
1229
+ function titleOptions(chart) {
1230
+ const title = chart.title;
1231
+ return title === null ? {} : {
1232
+ showTitle: true,
1233
+ title
1234
+ };
1235
+ }
1236
+ function legendOptions(chart) {
1237
+ const legend = chart.legend;
1238
+ if (!legend) return { showLegend: false };
1239
+ return {
1240
+ showLegend: true,
1241
+ legendPos: legend.position === null ? void 0 : LEGEND_POS[legend.position]
1242
+ };
1243
+ }
1244
+ function labelOptions(chart) {
1245
+ const labels = chart.dataLabels;
1246
+ if (!labels) return {};
1247
+ return {
1248
+ showValue: labels.showValue ?? void 0,
1249
+ showSerName: labels.showSeriesName ?? void 0,
1250
+ showCatName: labels.showCategoryName ?? void 0,
1251
+ showPercent: labels.showPercent ?? void 0,
1252
+ showLegendKey: labels.showLegendKey ?? void 0,
1253
+ showLeaderLines: labels.showLeaderLines ?? void 0,
1254
+ dataLabelPosition: orUndefined(labels.position),
1255
+ dataLabelFormatCode: labels.numberFormat?.formatCode ?? void 0
1256
+ };
1257
+ }
1258
+ /**
1259
+ * Axis bounds, titles and gridlines. The write API spells these as a flat `catAxis*` /
1260
+ * `valAxis*` prefix set rather than an axis object, so each side is unpacked by hand.
1261
+ */
1262
+ function axisOptions(chart) {
1263
+ const cat = chart.categoryAxis;
1264
+ const val = chart.valueAxis;
1265
+ return {
1266
+ catAxisHidden: cat?.hidden ? true : void 0,
1267
+ catAxisTitle: cat?.title ?? void 0,
1268
+ valAxisHidden: val?.hidden ? true : void 0,
1269
+ valAxisTitle: val?.title ?? void 0,
1270
+ valAxisMinVal: val?.min ?? void 0,
1271
+ valAxisMaxVal: val?.max ?? void 0,
1272
+ valAxisMajorUnit: val?.majorUnit ?? void 0,
1273
+ valAxisLabelFormatCode: val?.numberFormat?.formatCode ?? void 0
1274
+ };
1275
+ }
1276
+ /**
1277
+ * Per-series fill colours as `chartColors`, which is positional — entry `n` colours series
1278
+ * `n`. A series with no explicit fill takes the theme's accent for its position, which the
1279
+ * read model does not resolve, so the whole option is dropped unless every series names a
1280
+ * colour; a partial list would silently recolour the unnamed ones.
1281
+ */
1282
+ function seriesColors(chart) {
1283
+ const series = chart.series;
1284
+ if (series.length === 0) return void 0;
1285
+ const colors = [];
1286
+ for (const one of series) {
1287
+ const hex = one.fill?.color;
1288
+ if (!hex) return void 0;
1289
+ colors.push(literalColor(hex));
1290
+ }
1291
+ return colors;
1292
+ }
1293
+ //#endregion
1294
+ //#region src/script/from-read/shape.ts
1295
+ /**
1296
+ * `AnyShape` → one {@link CallIr}.
1297
+ *
1298
+ * The dispatch is on what the shape *is*, not on what it looks like: an `AutoShape` with a
1299
+ * text frame and no geometry is an `addText`, one with geometry is an `addShape` that may
1300
+ * also carry text, a `Picture` is an `addImage`, and a `GraphicFrame` delegates to the
1301
+ * table or chart mapper. A `GroupShape` recurses.
1302
+ *
1303
+ * The single most important thing this module does is record what it cannot carry.
1304
+ * Measurement against the fixture corpus found the binding constraint is the *read* side,
1305
+ * not the write side — several constructs the write API can express have no accessor at
1306
+ * all — so a converter that mapped only what it could see would produce clean-looking
1307
+ * output with silent visual losses. The clearest is theme-referenced line styling: a shape
1308
+ * whose outline comes from `p:style/a:lnRef` reports a colour through `resolvedLine` but
1309
+ * reports `null` for width and dash, because there is a resolved-colour path and no
1310
+ * resolved-width path. In the corpus that was every such shape, so a themed 2pt
1311
+ * border silently becomes a 1pt default. Each one is noted here rather than quietly thinned.
1312
+ *
1313
+ * The same dispatch serves the deck's *chrome*: {@link masterObject} re-tags a call as an
1314
+ * entry in a `defineSlideMaster({ objects })` array, so a rectangle on a layout is transcribed
1315
+ * by the code that transcribes one on a slide. Sharing it is the point — a second mapper would
1316
+ * be a second set of decisions about the same OOXML, drifting from this one silently.
1317
+ */
1318
+ /** How {@link pictureFillOption}'s notes name a shape's surface. */
1319
+ const SHAPE_PICTURE_FILL = {
1320
+ construct: "fill.picture",
1321
+ subject: "this shape's surface",
1322
+ element: "a:blipFill"
1323
+ };
1324
+ /** Arrowhead types `ShapeLineProps` accepts; `a:headEnd/@type` uses the same tokens. */
1325
+ const WRITABLE_ARROWS = /* @__PURE__ */ new Set([
1326
+ "none",
1327
+ "arrow",
1328
+ "diamond",
1329
+ "oval",
1330
+ "stealth",
1331
+ "triangle"
1332
+ ]);
1333
+ /** Dash tokens `ShapeLineProps.dashType` accepts — the whole `ST_PresetLineDashVal` set. */
1334
+ const WRITABLE_DASHES = /* @__PURE__ */ new Set([
1335
+ "solid",
1336
+ "dot",
1337
+ "dash",
1338
+ "lgDash",
1339
+ "dashDot",
1340
+ "lgDashDot",
1341
+ "lgDashDotDot",
1342
+ "sysDash",
1343
+ "sysDot",
1344
+ "sysDashDot",
1345
+ "sysDashDotDot"
1346
+ ]);
1347
+ /**
1348
+ * `a:ln/@cap` tokens → the `ShapeLineProps.cap` spelling that authors them. The read side
1349
+ * reports the raw OOXML token (`AutoShape.lineCap`) and the write side takes a friendly
1350
+ * name, so this is the whole of the mapping between them. `ST_LineCap` has exactly these
1351
+ * three members, so nothing falls off the end.
1352
+ */
1353
+ const CAP_TOKENS = {
1354
+ flat: "flat",
1355
+ sq: "square",
1356
+ rnd: "round"
1357
+ };
1358
+ /**
1359
+ * Map one shape. Returns `null` when the shape produces no call at all — a note is always
1360
+ * recorded in that case, so a dropped shape is never silent.
1361
+ */
1362
+ function shapeCall(shape, notes, assets) {
1363
+ const scoped = notes.forShape(shape.name || null);
1364
+ if (shape.hidden) {
1365
+ noteHidden(scoped);
1366
+ return null;
1367
+ }
1368
+ if (isGroupShape(shape)) return groupCall(shape, scoped, assets);
1369
+ if (isPicture(shape)) return pictureCall(shape, scoped, assets);
1370
+ if (isConnector(shape)) return connectorCall(shape, scoped);
1371
+ if (isGraphicFrame(shape)) return graphicFrameCall(shape, scoped, assets);
1372
+ if (isAutoShape(shape)) return autoShapeCall(shape, scoped, assets);
1373
+ return null;
1374
+ }
1375
+ /**
1376
+ * `p:cNvPr/@hidden` has no write-API counterpart, so a hidden shape would come back visible.
1377
+ * Omitting it preserves what the deck looks like, which is the lesser loss. Shared with the
1378
+ * layout-decoration mapper below, whose connector arm does not pass through {@link shapeCall}.
1379
+ */
1380
+ function noteHidden(notes) {
1381
+ notes.note("shape.hidden", "dropped", "unwritable", "a hidden shape has no write-API expression; it is omitted rather than emitted visible");
1382
+ }
1383
+ /**
1384
+ * Position as `Coord`-typed EMU strings, so the source geometry survives exactly.
1385
+ *
1386
+ * `absoluteFrame` rather than the raw `left`/`top`: a shape inside a group is positioned in
1387
+ * its group's child coordinate space, which a flattened call list cannot express, so the
1388
+ * group transform has to be composed in here. For a top-level shape the two are identical.
1389
+ */
1390
+ function positionOptions(shape, notes) {
1391
+ const frame = shape.absoluteFrame;
1392
+ if (frame) return {
1393
+ x: emu(frame.left),
1394
+ y: emu(frame.top),
1395
+ w: emu(frame.width),
1396
+ h: emu(frame.height)
1397
+ };
1398
+ const resolved = shape.resolvedFrame;
1399
+ if (!resolved) return {};
1400
+ notes?.note("shape.frameInherited", "flattened", "unsupported", `this shape has no transform of its own and takes its geometry from the ${resolved.source}; a regenerated slide inherits nothing, so the resolved position is baked in and stops tracking later edits to that ${resolved.source}`);
1401
+ return {
1402
+ x: emu(resolved.left),
1403
+ y: emu(resolved.top),
1404
+ w: emu(resolved.width),
1405
+ h: emu(resolved.height)
1406
+ };
1407
+ }
1408
+ /**
1409
+ * Rotation and flips. Taken from `absoluteFrame`, which composes enclosing group rotations
1410
+ * and XOR-composes group flips — the same reason position is absolute.
1411
+ */
1412
+ function transformOptions(shape) {
1413
+ const frame = shape.absoluteFrame;
1414
+ const rotate = frame ? frame.rotation : shape.rotation ?? 0;
1415
+ return {
1416
+ rotate: rotate === 0 ? void 0 : rotate,
1417
+ flipH: frame?.flipH ?? shape.flipH ? true : void 0,
1418
+ flipV: frame?.flipV ?? shape.flipV ? true : void 0
1419
+ };
1420
+ }
1421
+ /**
1422
+ * A shape's fill as `ShapeFillProps`.
1423
+ *
1424
+ * Prefers the raw scheme token over `resolvedFill` for the same reason run colour does: a
1425
+ * token keeps tracking the destination theme. `resolvedFill` is the fallback because it is
1426
+ * the only accessor that sees a `p:style/a:fillRef` — a shape styled entirely from the
1427
+ * theme has no `a:solidFill` of its own, so without it the shape would come out unfilled.
1428
+ */
1429
+ function fillOption(shape, notes, assets) {
1430
+ const gradient = shape.gradientFill;
1431
+ if (gradient) {
1432
+ const stops = gradientStops(gradient, notes, "fill");
1433
+ if (stops) return {
1434
+ type: "gradient",
1435
+ gradient: stops
1436
+ };
1437
+ }
1438
+ const pattern = patternOption(shape.patternFill);
1439
+ if (pattern) return pattern;
1440
+ const picture = shape.pictureFill;
1441
+ if (picture) return pictureFillOption(picture, assets, notes, SHAPE_PICTURE_FILL);
1442
+ const scheme = shape.fillSchemeColor;
1443
+ if (isWritableSchemeToken(scheme)) return { color: scheme };
1444
+ if (shape.fillColor !== null) return { color: literalColor(shape.fillColor) };
1445
+ const resolved = shape.resolvedFill;
1446
+ if (!resolved) return void 0;
1447
+ if (scheme !== null) notes.note("fill.schemeToken", "approximated", "unwritable", `fill scheme colour "${scheme}" is outside the ten tokens the write path maps, so it is baked to a literal hex and stops tracking the theme`);
1448
+ return compact({
1449
+ color: literalColor(resolved.effectiveHex),
1450
+ transparency: alphaToTransparency(resolved.alpha)
1451
+ });
1452
+ }
1453
+ /**
1454
+ * A shape's outline as `ShapeLineProps`.
1455
+ *
1456
+ * This is where the measured silent loss lives. When a shape takes its outline from
1457
+ * `p:style/a:lnRef`, `resolvedLine` supplies the colour but `lineWidthPt` and `lineDash`
1458
+ * both report `null` — the theme's `a:lnStyleLst` entry, where the width and dash actually
1459
+ * live, has no accessor. Emitting the colour and noting the rest is the honest outcome;
1460
+ * inventing a width would be worse, because it would look deliberate.
1461
+ */
1462
+ function lineOption(shape, notes) {
1463
+ if (shape.lineNoFill) return { type: "none" };
1464
+ const gradient = shape.lineGradient;
1465
+ if (gradient) {
1466
+ const stops = gradientStops(gradient, notes, "line");
1467
+ if (stops) return {
1468
+ type: "gradient",
1469
+ gradient: stops
1470
+ };
1471
+ }
1472
+ const widthPt = shape.lineWidthPt;
1473
+ const dash = shape.lineDash;
1474
+ const cap = shape.lineCap;
1475
+ const scheme = shape.lineSchemeColor;
1476
+ const resolved = shape.resolvedLine;
1477
+ let color;
1478
+ if (isWritableSchemeToken(scheme)) color = scheme;
1479
+ else if (shape.lineColor !== null) color = literalColor(shape.lineColor);
1480
+ else if (resolved) color = literalColor(resolved.effectiveHex);
1481
+ if (resolved !== null && shape.lineColor === null && scheme === null && widthPt === null) notes.note("line.width", "dropped", "unread", "this outline comes from the theme style list (p:style/a:lnRef); its width and dash live in the theme fmtScheme a:lnStyleLst, which has no accessor, so only the colour carries");
1482
+ if (dash !== null && !WRITABLE_DASHES.has(dash)) notes.note("line.dash", "approximated", "unwritable", `dash style "${dash}" is outside the eight the write API accepts, so the outline falls back to solid`);
1483
+ if (shape.lineAlign === "in") notes.note("line.align", "dropped", "unwritable", "this outline is inset (a:ln/@algn=\"in\", drawn wholly inside the shape); ShapeLineProps has no alignment option, so it comes back centred on the edge and sits half its width further out");
1484
+ return compact({
1485
+ color,
1486
+ width: orUndefined(widthPt),
1487
+ dashType: dash !== null && WRITABLE_DASHES.has(dash) ? dash : void 0,
1488
+ cap: cap === null ? void 0 : CAP_TOKENS[cap],
1489
+ transparency: alphaToTransparency(resolved?.alpha),
1490
+ ...arrowOptions(shape, notes)
1491
+ });
1492
+ }
1493
+ /** Arrowheads. Types map directly; the `sm`/`med`/`lg` size classes have no option. */
1494
+ function arrowOptions(shape, notes) {
1495
+ const ends = shape.lineEnds;
1496
+ if (!ends) return {};
1497
+ if ([ends.head, ends.tail].some((end) => end && (end.width !== null || end.length !== null))) notes.note("line.arrowSize", "dropped", "unwritable", "arrowhead width and length classes (@w / @len) have no write-API option, so arrowheads render at the default size");
1498
+ return {
1499
+ beginArrowType: ends.head && WRITABLE_ARROWS.has(ends.head.type) ? ends.head.type : void 0,
1500
+ endArrowType: ends.tail && WRITABLE_ARROWS.has(ends.tail.type) ? ends.tail.type : void 0
1501
+ };
1502
+ }
1503
+ /** Outer shadow as `ShadowProps`; an inner shadow uses the same option with `type: 'inner'`. */
1504
+ function shadowOption(shape, notes) {
1505
+ if (shape.reflection || shape.softEdge) notes.note("shape.effects", "dropped", "unwritable", "a:reflection and a:softEdge are read but have no write-API emitter, so those effects are lost");
1506
+ const outer = shape.shadow;
1507
+ const source = outer ?? shape.innerShadow;
1508
+ if (!source) return void 0;
1509
+ return compact({
1510
+ type: outer ? "outer" : "inner",
1511
+ color: source.color === null ? void 0 : literalColor(source.color),
1512
+ blur: source.blurPt,
1513
+ offset: source.offsetPt,
1514
+ angle: source.angleDeg,
1515
+ transparency: alphaToTransparency(source.alpha)
1516
+ });
1517
+ }
1518
+ /** Glow. The read model reports opacity as a 0–1 alpha, which is the write API's unit too. */
1519
+ function glowOption(shape) {
1520
+ const glow = shape.glow;
1521
+ if (!glow) return void 0;
1522
+ return compact({
1523
+ size: glow.radiusPt,
1524
+ color: glow.color === null ? void 0 : literalColor(glow.color),
1525
+ opacity: glow.alpha
1526
+ });
1527
+ }
1528
+ /** The style block every shape kind shares. */
1529
+ function styleOptions(shape, notes, assets) {
1530
+ return {
1531
+ fill: fillOption(shape, notes, assets),
1532
+ line: lineOption(shape, notes),
1533
+ shadow: shadowOption(shape, notes),
1534
+ glow: glowOption(shape)
1535
+ };
1536
+ }
1537
+ /**
1538
+ * An `AutoShape` becomes an `addShape` when it is bare geometry and an `addText` when it
1539
+ * carries text. A shape with both takes the `addText` form, because `addShape` has no text
1540
+ * argument while `addText` accepts a `shape` option — so only that form expresses both.
1541
+ */
1542
+ function autoShapeCall(shape, notes, assets) {
1543
+ const preset = shape.presetGeometry;
1544
+ const custom = shape.customGeometry;
1545
+ const frame = shape.hasTextFrame ? shape.textFrame : null;
1546
+ const runText = frame === null ? "" : frame.paragraphs.flatMap((p) => p.runs.map((run) => run.text)).join("");
1547
+ const hasText = runText.length > 0;
1548
+ if (frame !== null && frame.text.replaceAll("\n", "").length > runText.length) notes.note("text.field", "dropped", "unread", "this shape holds an automatic field (a:fld — a slide number, date or footer), which has no accessor and no addText expression, so its text is not reproduced");
1549
+ if (frame !== null && hasEquation(shape.element_)) notes.note("text.equation", "dropped", "unread", "this shape holds an OMML equation, which no accessor exposes, so the shape is emitted without it — even though TextProps.math (and the ts-pptx/math subpath) could author one");
1550
+ if (shape.placeholder) notes.note("shape.placeholder", "flattened", "unsupported", "a placeholder is emitted as a plain shape with its inherited geometry and styling baked in, so it no longer follows its layout");
1551
+ const common = {
1552
+ ...positionOptions(shape, notes),
1553
+ ...transformOptions(shape),
1554
+ ...styleOptions(shape, notes, assets),
1555
+ objectName: shape.name || void 0
1556
+ };
1557
+ if (custom) {
1558
+ notes.note("shape.custGeom.guides", "dropped", "unread", "custGeom guides, adjust handles and connection sites (a:gdLst / a:ahLst / a:cxnLst) have no accessor, so only the path outline carries");
1559
+ return {
1560
+ method: "addShape",
1561
+ args: ["custGeom", compact({
1562
+ ...common,
1563
+ points: customGeometryPoints(shape, custom)
1564
+ }) ?? {}],
1565
+ ...nameOf(shape)
1566
+ };
1567
+ }
1568
+ if (hasText && frame !== null) {
1569
+ const options = compact({
1570
+ ...common,
1571
+ ...textFrameOptions(frame, notes),
1572
+ ...isTextBox(shape.element_) ? { isTextBox: true } : {},
1573
+ ...preset === null ? {} : {
1574
+ shape: preset,
1575
+ ...adjustOptions(shape)
1576
+ }
1577
+ });
1578
+ return {
1579
+ method: "addText",
1580
+ args: [textRuns(frame, notes), options ?? {}],
1581
+ ...nameOf(shape)
1582
+ };
1583
+ }
1584
+ if (preset === null) {
1585
+ notes.note("shape.empty", "dropped", "unsupported", "this shape carries no text and no geometry of its own (an unfilled placeholder drawing its outline from the layout), so there is no addShape preset to name and it is omitted");
1586
+ return null;
1587
+ }
1588
+ return {
1589
+ method: "addShape",
1590
+ args: [preset, compact({
1591
+ ...common,
1592
+ ...adjustOptions(shape)
1593
+ }) ?? {}],
1594
+ ...nameOf(shape)
1595
+ };
1596
+ }
1597
+ /**
1598
+ * Preset-geometry adjust handles (`a:avLst`) as `shapeAdjust`.
1599
+ *
1600
+ * The read model reports each guide's raw formula string; a preset's adjust values are
1601
+ * `val <n>` where `<n>` is in 1000ths of a percent, and the write API takes the same handle
1602
+ * as a `0.0–1.0` fraction. Anything that is not a plain `val` is a computed guide, which
1603
+ * has no write-API expression, so it is skipped rather than mis-scaled.
1604
+ */
1605
+ function adjustOptions(shape) {
1606
+ const values = shape.adjustValues;
1607
+ const guides = [];
1608
+ for (const name of Object.keys(values).sort()) {
1609
+ const match = /^val\s+(-?\d+)$/.exec((values[name] ?? "").trim());
1610
+ if (!match?.[1]) continue;
1611
+ guides.push({
1612
+ name,
1613
+ value: Number(match[1]) / PERCENT_SCALE
1614
+ });
1615
+ }
1616
+ return guides.length ? { shapeAdjust: guides } : {};
1617
+ }
1618
+ /**
1619
+ * `custGeom` path commands as the write API's `points` array.
1620
+ *
1621
+ * Path coordinates are integers in the path's own `0..w`/`0..h` viewport, not EMU, so each
1622
+ * is scaled onto the shape's box before being printed as EMU. A path that declares no
1623
+ * viewport (`@w`/`@h` of 0) is already in the shape's own space and passes through.
1624
+ */
1625
+ function customGeometryPoints(shape, custom) {
1626
+ const frame = shape.absoluteFrame;
1627
+ const points = [];
1628
+ for (const path of custom.paths) {
1629
+ const sx = path.w > 0 && frame ? frame.width / path.w : 1;
1630
+ const sy = path.h > 0 && frame ? frame.height / path.h : 1;
1631
+ const px = (v) => emu(v * sx);
1632
+ const py = (v) => emu(v * sy);
1633
+ for (const command of path.commands) switch (command.cmd) {
1634
+ case "moveTo":
1635
+ points.push({
1636
+ x: px(command.x),
1637
+ y: py(command.y),
1638
+ moveTo: true
1639
+ });
1640
+ break;
1641
+ case "lnTo":
1642
+ points.push({
1643
+ x: px(command.x),
1644
+ y: py(command.y)
1645
+ });
1646
+ break;
1647
+ case "cubicBezTo":
1648
+ points.push({
1649
+ x: px(command.x),
1650
+ y: py(command.y),
1651
+ curve: {
1652
+ type: "cubic",
1653
+ x1: px(command.x1),
1654
+ y1: py(command.y1),
1655
+ x2: px(command.x2),
1656
+ y2: py(command.y2)
1657
+ }
1658
+ });
1659
+ break;
1660
+ case "quadBezTo":
1661
+ points.push({
1662
+ x: px(command.x),
1663
+ y: py(command.y),
1664
+ curve: {
1665
+ type: "quadratic",
1666
+ x1: px(command.x1),
1667
+ y1: py(command.y1)
1668
+ }
1669
+ });
1670
+ break;
1671
+ case "arcTo":
1672
+ points.push({ curve: {
1673
+ type: "arc",
1674
+ wR: px(command.wR),
1675
+ hR: py(command.hR),
1676
+ stAng: command.stAng,
1677
+ swAng: command.swAng
1678
+ } });
1679
+ break;
1680
+ case "close": points.push({ close: true });
1681
+ }
1682
+ }
1683
+ return points;
1684
+ }
1685
+ /**
1686
+ * A `Picture` becomes `addImage`, with its bytes carried as an asset.
1687
+ *
1688
+ * PowerPoint authors embedded audio and video as a picture too — the poster frame is the
1689
+ * blip, and the media itself hangs off `p:nvPr` as `a:videoFile`/`a:audioFile`. The read
1690
+ * model surfaces only the poster (`mediaKind` separates raster from SVG, not still from
1691
+ * moving), so a video would otherwise convert to a static image with nothing to show it had
1692
+ * ever moved. The write API has `addMedia`, so this is a read-side gap, and it is declared
1693
+ * rather than silently flattened.
1694
+ */
1695
+ function pictureCall(shape, notes, assets) {
1696
+ if (isAudioVideo(shape.element_)) notes.note("media.audioVideo", "flattened", "unread", "this is embedded audio or video: no accessor reports the media part or its kind, only the poster frame, so it is emitted as a still image even though addMedia could author it");
1697
+ const partName = shape.svgPartName ?? shape.imagePartName;
1698
+ if (!partName) {
1699
+ notes.note("image.data", "dropped", "unsupported", "this picture references no embedded image part (an external or linked image), so there are no bytes to carry");
1700
+ return null;
1701
+ }
1702
+ const asset = assets.assetFor(partName);
1703
+ if (!asset) {
1704
+ notes.note("image.data", "dropped", "unsupported", `image part ${partName} is not present in the package`);
1705
+ return null;
1706
+ }
1707
+ if (shape.svgPartName && shape.imagePartName) notes.note("image.svg", "approximated", "unsupported", "the vector part of this SVG picture is carried and the source's own raster fallback is not; the write path generates a fresh fallback, so a renderer that cannot draw SVG shows a different image from the one the source deck shipped");
1708
+ if (shape.recolor) notes.note("image.recolor", "dropped", "unwritable", "a picture recolour (a:duotone / a:clrChange / a:grayscl) is read but has no write-API option");
1709
+ const crop = shape.crop;
1710
+ return {
1711
+ method: "addImage",
1712
+ args: [{
1713
+ ...compact({
1714
+ ...positionOptions(shape, notes),
1715
+ ...transformOptions(shape),
1716
+ objectName: shape.name || void 0,
1717
+ crop: crop === null ? void 0 : {
1718
+ l: crop.left * 100,
1719
+ t: crop.top * 100,
1720
+ r: crop.right * 100,
1721
+ b: crop.bottom * 100
1722
+ }
1723
+ }),
1724
+ data: asset
1725
+ }],
1726
+ ...nameOf(shape)
1727
+ };
1728
+ }
1729
+ /**
1730
+ * `ConnectorProps` keys that {@link lineOption} can produce. A connector styles its stroke
1731
+ * with *flat* options rather than the nested `line` object every other shape takes, and it
1732
+ * accepts a strict subset: no `transparency`, no gradient stroke, and its own `type` names
1733
+ * the routing (`straight`/`elbow`/`curved`), not a fill kind — so spreading a line option
1734
+ * object into it wholesale would both drop silently and collide.
1735
+ */
1736
+ const CONNECTOR_LINE_KEYS = [
1737
+ "color",
1738
+ "width",
1739
+ "dashType",
1740
+ "beginArrowType",
1741
+ "endArrowType"
1742
+ ];
1743
+ /**
1744
+ * A `Connector` becomes `addConnector`.
1745
+ *
1746
+ * The endpoints are the one real translation here. OOXML gives a connector a bounding box
1747
+ * plus flip flags; `addConnector` takes two points. `a:flipH`/`a:flipV` are what say which
1748
+ * diagonal of the box the line runs along, so dropping them would silently mirror every
1749
+ * connector that runs up or leftward.
1750
+ *
1751
+ * The *bindings* (`a:stCxn`/`a:endCxn`) reference source shape ids that have no counterpart
1752
+ * in the output, so a connector lands unbound and stops following its shapes when they move.
1753
+ */
1754
+ function connectorCall(shape, notes) {
1755
+ const frame = shape.absoluteFrame;
1756
+ if (!frame) return null;
1757
+ if (shape.startConnection || shape.endConnection) notes.note("connector.binding", "dropped", "unsupported", "connector endpoint bindings reference source shape ids with no counterpart in the output, so the connector lands unbound and no longer follows its shapes");
1758
+ if (shape.rotation) notes.note("connector.rotation", "dropped", "unwritable", "addConnector takes two endpoints and no rotation, so a rotated connector lands along the unrotated diagonal of its box");
1759
+ const line = lineOption(shape, notes) ?? {};
1760
+ const stroke = {};
1761
+ for (const key of CONNECTOR_LINE_KEYS) {
1762
+ const value = line[key];
1763
+ if (value !== void 0) stroke[key] = value;
1764
+ }
1765
+ if (line["type"] === "gradient" || line["transparency"] !== void 0) notes.note("connector.line", "flattened", "unwritable", "a connector styles its stroke with flat colour/width options, which cannot express a gradient stroke or a stroke transparency, so those fall back to a plain line");
1766
+ const right = frame.left + frame.width;
1767
+ const bottom = frame.top + frame.height;
1768
+ return {
1769
+ method: "addConnector",
1770
+ args: [compact({
1771
+ type: "straight",
1772
+ x1: emu(shape.flipH ? right : frame.left),
1773
+ y1: emu(shape.flipV ? bottom : frame.top),
1774
+ x2: emu(shape.flipH ? frame.left : right),
1775
+ y2: emu(shape.flipV ? frame.top : bottom),
1776
+ objectName: shape.name || void 0,
1777
+ ...stroke
1778
+ }) ?? {}],
1779
+ ...nameOf(shape)
1780
+ };
1781
+ }
1782
+ /**
1783
+ * Why a graphic frame cannot be authored from a script, or `null` when it can.
1784
+ *
1785
+ * The single enumeration of the payloads with no write-API emitter, and the reason it is a
1786
+ * function rather than a condition spelled at each site: `graphicFrameCall` needs it to pick
1787
+ * a fidelity note, and `hasUnwritableContent` in `from-read/deck.ts` needs it to decide
1788
+ * whether the printer must copy the slide instead of transcribing it. Those were two
1789
+ * hand-maintained lists, they disagreed — the deck-level one named extended charts only — and
1790
+ * a SmartArt slide was therefore transcribed with a hole where the graphic had been. The two
1791
+ * consequences are the same for every member, so the membership has to be too.
1792
+ */
1793
+ function unwritableFramePayload(shape) {
1794
+ if (shape.table || shape.chart) return null;
1795
+ if (shape.hasChartEx) return "chartEx";
1796
+ if (shape.hasDiagram) return "diagram";
1797
+ return "unknown";
1798
+ }
1799
+ /** A `GraphicFrame` hosts a table or a chart; both have their own mapper. */
1800
+ function graphicFrameCall(shape, notes, assets) {
1801
+ const table = shape.table;
1802
+ if (table) return tableCall(shape, table, notes, assets);
1803
+ const chart = shape.chart;
1804
+ if (chart) return chartCall(shape, chart, notes);
1805
+ switch (unwritableFramePayload(shape)) {
1806
+ case "chartEx":
1807
+ notes.note("chartEx.all", "dropped", "unwritable", "an extended chart (waterfall, funnel, box-and-whisker, …) has a full reader but no write-API counterpart");
1808
+ return null;
1809
+ case "diagram":
1810
+ notes.note("diagram.all", "dropped", "unwritable", "a SmartArt diagram has a full reader and its text can be edited in place through it (DiagramPoint.text), but no write API authors one from a script, so a converted script cannot describe this frame");
1811
+ return null;
1812
+ default:
1813
+ notes.note("graphicFrame.unknown", "dropped", "unread", "this graphic frame hosts none of a table, a chart or a SmartArt diagram (an OLE object or an ink annotation), which the read model does not decode");
1814
+ return null;
1815
+ }
1816
+ }
1817
+ /**
1818
+ * A `GroupShape` becomes `addGroup`, with each child mapped and re-tagged into the
1819
+ * key-tagged `GroupChildProps` shape the write API expects.
1820
+ *
1821
+ * Children keep slide-absolute coordinates, which is not a compromise here: `addGroup`
1822
+ * holds an identity child coordinate space (`chOff/chExt == off/ext`) at every depth
1823
+ * precisely so they can. Nested groups therefore nest rather than flatten. What does not
1824
+ * survive is a source group with a *non*-identity child space — that group scales its
1825
+ * contents when resized and the emitted one will not — so that case, and only that case,
1826
+ * is noted.
1827
+ *
1828
+ * Charts, tables, media and placeholders are excluded from groups by the write API, so a
1829
+ * group holding one loses that child.
1830
+ */
1831
+ function groupCall(shape, notes, assets) {
1832
+ const children = [];
1833
+ for (const child of shape.shapes) {
1834
+ const call = shapeCall(child, notes, assets);
1835
+ if (!call) continue;
1836
+ const tagged = asGroupChild(call);
1837
+ if (!tagged) {
1838
+ notes.note("group.child", "dropped", "unsupported", `addGroup accepts no ${call.method.replace(/^add/, "").toLowerCase()} child (charts, tables and media are excluded by design), so this child is omitted from the group`);
1839
+ continue;
1840
+ }
1841
+ children.push(tagged);
1842
+ }
1843
+ if (children.length === 0) {
1844
+ notes.note("group.empty", "dropped", "unsupported", "no child of this group produced a call, and addGroup rejects an empty child list, so the group itself is omitted too");
1845
+ return null;
1846
+ }
1847
+ if (!hasIdentityChildSpace(shape.element_)) notes.note("group.childSpace", "flattened", "unsupported", "this group scales its contents (its a:chOff/a:chExt differ from its a:off/a:ext); addGroup's child space is always identity, so the children are emitted pre-scaled and resizing the group no longer rescales them");
1848
+ if (shape.rotation !== 0 || shape.flipH || shape.flipV) notes.note("group.transform", "flattened", "unsupported", "the group's own rotation and flips are baked into its children's coordinates rather than kept on the group, because addGroup would otherwise apply them twice; the shapes render identically but rotating or resetting the group no longer moves them together");
1849
+ return {
1850
+ method: "addGroup",
1851
+ args: [children, compact({
1852
+ ...positionOptions(shape, notes),
1853
+ objectName: shape.name || void 0
1854
+ }) ?? {}],
1855
+ ...nameOf(shape)
1856
+ };
1857
+ }
1858
+ /**
1859
+ * Re-tag a call as a `GroupChildProps` entry, or `null` when the content type is one
1860
+ * `addGroup` excludes. The tagged form is a single-key object, not a `{ type, args }` pair.
1861
+ */
1862
+ function asGroupChild(call) {
1863
+ const shared = commonShapeVariant(call);
1864
+ if (shared) return shared;
1865
+ const [first, second] = call.args;
1866
+ switch (call.method) {
1867
+ case "addGroup": return { group: compact({
1868
+ children: first,
1869
+ options: second
1870
+ }) ?? {} };
1871
+ default: return null;
1872
+ }
1873
+ }
1874
+ /**
1875
+ * The three key-tagged variants both union-shaped call sites emit identically: a text box, an
1876
+ * auto shape, and an image. `null` when `call.method` is not one of them, which is the signal
1877
+ * for the caller to try its own extra arms.
1878
+ *
1879
+ * Shared rather than duplicated because this is exactly where a fourth common variant would be
1880
+ * added to one site and forgotten in the other. What is deliberately *not* shared is either
1881
+ * caller's `default`: a group child silently declines a chart, while a layout object reports it
1882
+ * as a fidelity loss, and that difference is the point of having two functions.
1883
+ */
1884
+ function commonShapeVariant(call) {
1885
+ const [first, second] = call.args;
1886
+ switch (call.method) {
1887
+ case "addText": return { text: compact({
1888
+ text: first,
1889
+ options: second
1890
+ }) ?? {} };
1891
+ case "addShape": return { shape: compact({
1892
+ type: first,
1893
+ options: second
1894
+ }) ?? {} };
1895
+ case "addImage": return { image: first ?? {} };
1896
+ default: return null;
1897
+ }
1898
+ }
1899
+ /** Attach the source name to a call, when the shape had one. */
1900
+ function nameOf(shape) {
1901
+ return shape.name ? { sourceName: shape.name } : {};
1902
+ }
1903
+ /**
1904
+ * One shape from a **layout's** shape tree as a `SlideMasterObject` — an entry in the
1905
+ * `objects` array of a `defineSlideMaster` call.
1906
+ *
1907
+ * Everything about the shape itself is decided by {@link shapeCall}, so a rectangle on a
1908
+ * layout is transcribed by the same code that transcribes one on a slide and cannot drift
1909
+ * from it. Only the *envelope* differs: `objects` is a key-tagged union rather than a method
1910
+ * name plus arguments — the same shape `GroupChildProps` takes — which is why this reads as a
1911
+ * sibling of {@link asGroupChild}.
1912
+ *
1913
+ * `notes` must already be a `layoutShapeScope`, so the slide vocabulary this borrows lands
1914
+ * under `layout.` and cannot be mistaken for a loss on a slide.
1915
+ *
1916
+ * Two methods have no variant in the union. `addGroup` never reaches here — the layout walk
1917
+ * flattens a group into its children first, since children already carry slide-absolute
1918
+ * coordinates. `addTable` genuinely has nowhere to go, and is the one kind this reports as
1919
+ * lost.
1920
+ */
1921
+ function masterObject(shape, notes, assets) {
1922
+ if (isConnector(shape)) {
1923
+ const scoped = notes.forShape(shape.name || null);
1924
+ if (shape.hidden) {
1925
+ noteHidden(scoped);
1926
+ return null;
1927
+ }
1928
+ return connectorObject(shape, scoped);
1929
+ }
1930
+ const call = shapeCall(shape, notes, assets);
1931
+ if (!call) return null;
1932
+ const shared = commonShapeVariant(call);
1933
+ if (shared) return shared;
1934
+ const [first, second] = call.args;
1935
+ switch (call.method) {
1936
+ case "addChart": {
1937
+ const options = second !== null && typeof second === "object" && !Array.isArray(second) && !isAssetRef(second) ? second : null;
1938
+ const type = options?.["type"];
1939
+ if (type === void 0 || first === void 0) return null;
1940
+ return { chart: compact({
1941
+ type,
1942
+ data: first,
1943
+ opts: options
1944
+ }) ?? {} };
1945
+ }
1946
+ default:
1947
+ notes.forShape(shape.name || null).note("decoration", "dropped", "unwritable", `a ${call.method.replace(/^add/, "").toLowerCase()} on a slide layout has no defineSlideMaster({ objects }) variant — the union covers a chart, an image, a shape and a text box — so it is dropped from the layout the output rebuilds`);
1948
+ return null;
1949
+ }
1950
+ }
1951
+ /**
1952
+ * A `Connector` as a `line` shape descriptor, which is the only way one reaches a layout.
1953
+ *
1954
+ * The alternative to this arm is dropping every connector a layout carries, and in the fixture
1955
+ * corpus that is 18 of the 45 shapes every layout actually draws — because PowerPoint's
1956
+ * line tool authors a `p:cxnSp`, so a plain horizontal rule under a title is usually a
1957
+ * connector rather than a shape. Drawing it as a `line` preset paints the identical stroke.
1958
+ *
1959
+ * What that costs is the endpoint binding, and on a layout it costs nothing that was working:
1960
+ * `a:stCxn`/`a:endCxn` reference shapes in the *layout's* tree, and nothing a slide later puts
1961
+ * on top is one of them. The slide-side `addConnector` mapping loses the binding too.
1962
+ *
1963
+ * It is also better than that mapping in one respect. `addConnector` takes two endpoints and
1964
+ * no rotation, so a rotated connector lands along the unrotated diagonal of its box; a shape
1965
+ * carries `rotate`, so this keeps it.
1966
+ */
1967
+ function connectorObject(shape, notes) {
1968
+ if (!shape.absoluteFrame) return null;
1969
+ if (shape.startConnection || shape.endConnection) notes.note("connector.binding", "dropped", "unsupported", "this connector is bound to shapes on the layout; it is re-authored as a line shape, which paints the same stroke but no longer follows them");
1970
+ return { shape: compact({
1971
+ type: "line",
1972
+ options: compact({
1973
+ ...positionOptions(shape, notes),
1974
+ ...transformOptions(shape),
1975
+ line: lineOption(shape, notes),
1976
+ shadow: shadowOption(shape, notes),
1977
+ glow: glowOption(shape),
1978
+ objectName: shape.name || void 0
1979
+ }) ?? {}
1980
+ }) ?? {} };
1981
+ }
1982
+ //#endregion
1983
+ //#region src/script/from-read/chrome.ts
1984
+ /** Build the chrome IR for a deck. */
1985
+ function chromeToIr(pres, notes, assets) {
1986
+ const masters = pres.masters();
1987
+ if (masters.length > 1) notes.note("master.multiple", "flattened", "unsupported", `this deck has ${masters.length} slide masters; the write API models a deck as one shared master with many layouts, so every layout is rebuilt under a single master and only the first master's theme and colour map survive`);
1988
+ const theme = masters[0]?.theme ?? null;
1989
+ recordMasterLosses(masters, theme, notes);
1990
+ return {
1991
+ theme: themeToIr(theme),
1992
+ masters: layoutsToIr(masters, notes, assets)
1993
+ };
1994
+ }
1995
+ /** The theme's colour scheme and font faces, in `ThemeProps` spelling. */
1996
+ function themeToIr(theme) {
1997
+ if (!theme) return {};
1998
+ const scheme = theme.colorScheme;
1999
+ const colors = {};
2000
+ for (const [slot, hex] of Object.entries(scheme)) if (hex !== null) colors[slot] = literalColor(hex);
2001
+ const fonts = theme.fontScheme;
2002
+ return compact({
2003
+ headFontFace: orUndefined(fonts?.major.latin ?? null),
2004
+ bodyFontFace: orUndefined(fonts?.minor.latin ?? null),
2005
+ headFontFaceEA: orUndefined(fonts?.major.ea ?? null),
2006
+ bodyFontFaceEA: orUndefined(fonts?.minor.ea ?? null),
2007
+ headFontFaceCS: orUndefined(fonts?.major.cs ?? null),
2008
+ bodyFontFaceCS: orUndefined(fonts?.minor.cs ?? null),
2009
+ colorScheme: Object.keys(colors).length > 0 ? colors : void 0
2010
+ });
2011
+ }
2012
+ /** The losses that belong to the master/theme tier as a whole rather than to one layout. */
2013
+ function recordMasterLosses(masters, theme, notes) {
2014
+ if (theme && hasFormatScheme(theme.element_)) notes.note("theme.fmtScheme", "approximated", "unread", "the theme's format scheme (a:fmtScheme — the three fill, line and effect style lists a shape's p:style indexes into) has no read accessor and no write-API option, so the output carries Office's default; a shape whose fill or outline came from the theme rather than from its own a:ln keeps its colour but not its width, dash or effect");
2015
+ for (const master of masters) {
2016
+ const root = master.part.dom.documentElement;
2017
+ if (hasTextStyles(root)) notes.note("master.txStyles", "dropped", "unread", "the master's per-level text styles (p:txStyles — the default size, face, colour, indent and bullet of each of the nine list levels) have no read accessor, so placeholder text falls back to PowerPoint's built-in defaults; SlideMasterProps.textStyles could author them if they could be seen");
2018
+ if (hasDecorativeShapes(root)) notes.note("master.decoration", "dropped", "unwritable", "this slide master carries shapes that are not placeholders (a logo, a rule, a background graphic); the read model decodes them (SlideMaster.shapes) and a layout's are re-authored, but a master's cannot be — defineSlideMaster creates a layout under the one shared master, so the master's own shape tree has no write-side counterpart to receive them");
2019
+ const map = master.colorMap;
2020
+ const remapped = Object.entries(DEFAULT_COLOR_MAP).filter(([token, slot]) => map[token] !== slot);
2021
+ if (remapped.length > 0) notes.note("master.colorMap", "dropped", "unwritable", `this master remaps ${remapped.map(([token]) => token).join(", ")} to a different theme slot and the write path always emits the identity map, so every scheme colour that passes through as a token resolves to a different hex than it did in the source`);
2022
+ }
2023
+ }
2024
+ /**
2025
+ * One `defineSlideMaster` descriptor per source layout, in the same gallery order
2026
+ * `Presentation.layouts()` reports — which is what lets a slide bind by index.
2027
+ *
2028
+ * Titles are made unique because they are the lookup key `addSlide({ masterTitle })` matches
2029
+ * on, and `p:cSld@name` is not unique: a multi-master deck routinely carries several layouts
2030
+ * called "Title and Content", and two `defineSlideMaster` calls sharing a title would make
2031
+ * every slide bind to whichever won.
2032
+ */
2033
+ function layoutsToIr(masters, notes, assets) {
2034
+ const out = [];
2035
+ const used = /* @__PURE__ */ new Set();
2036
+ let placeholderCount = 0;
2037
+ let placeholderLayouts = 0;
2038
+ const renamed = [];
2039
+ const collided = [];
2040
+ for (const master of masters) for (const layout of master.layouts) {
2041
+ const index = out.length;
2042
+ const flattened = flattenAttributeValue(layout.name);
2043
+ if (flattened !== layout.name) renamed.push(`${JSON.stringify(layout.name)} → ${JSON.stringify(flattened)}`);
2044
+ const base = flattened || `Layout ${index + 1}`;
2045
+ let title = base;
2046
+ for (let suffix = 2; used.has(title); suffix++) title = `${base} (${suffix})`;
2047
+ if (title !== base) collided.push(`${JSON.stringify(base)} → ${JSON.stringify(title)}`);
2048
+ used.add(title);
2049
+ const placeholders = layout.placeholders.length;
2050
+ if (placeholders > 0) {
2051
+ placeholderCount += placeholders;
2052
+ placeholderLayouts++;
2053
+ }
2054
+ const background = backgroundProps(layout.background ?? master.background, notes, assets);
2055
+ const objects = layoutObjects(layout, notes, assets);
2056
+ out.push({
2057
+ layoutIndex: index,
2058
+ props: compact({
2059
+ title,
2060
+ background,
2061
+ objects: objects.length > 0 ? objects : void 0
2062
+ })
2063
+ });
2064
+ }
2065
+ if (collided.length > 0) notes.note("master.nameCollision", "approximated", "unsupported", `${collided.length} layout name(s) are not unique in the source — normally because the deck has several masters, each with its own "Title and Content" — and a layout title here is also the key addSlide({ masterTitle }) binds on, so duplicates cannot both exist. The later ones are suffixed and the slides bound to them follow: ${collided.join("; ")}`);
2066
+ if (renamed.length > 0) notes.note("master.name", "flattened", "unwritable", `${renamed.length} layout name(s) contain a line break or tab, which cannot survive as a layout title: the write path emits the name as a literal XML attribute value rather than a character reference, and XML normalises whitespace in an attribute to spaces. Collapsed here so the emitted script says what the output will contain — ${renamed.join("; ")}`);
2067
+ if (placeholderCount > 0) notes.note("master.placeholders", "dropped", "unsupported", `the ${placeholderCount} placeholder definition(s) across ${placeholderLayouts} slide layout(s) are not reproduced: the write path seeds every slide with each layout placeholder the slide did not populate, and since every source shape is authored here as concrete content rather than into a placeholder, re-declaring them would add an empty shape to every slide rather than a prompt`);
2068
+ return out;
2069
+ }
2070
+ /**
2071
+ * A layout's own shapes as the `objects` of its `defineSlideMaster` call — the bands, rules,
2072
+ * wordmarks and footer graphics that make a deck recognisable as somebody's template.
2073
+ *
2074
+ * **Placeholders are filtered out, not mapped.** They are the other half of a layout's tree and
2075
+ * the write path already reproduces them from the far side: `addPlaceholdersToSlideLayouts`
2076
+ * seeds every slide with each layout placeholder it did not populate, so re-declaring one here
2077
+ * would put an empty ghost shape on every slide bound to the layout (see the module header).
2078
+ * The test is a DOM predicate rather than `shape.placeholder`, because only `AutoShape` reports
2079
+ * one — see {@link isPlaceholderShape}.
2080
+ *
2081
+ * **A group is flattened into its children rather than dropped.** `SlideMasterObject` has no
2082
+ * `group` variant, and unlike the slide-side `addGroup` mapping this loses nothing visual:
2083
+ * `absoluteFrame` already composes an enclosing group's offset, rotation, flips *and* its
2084
+ * child-space scaling, so each child lands exactly where the group put it. What goes is the
2085
+ * grouping itself — one selectable object in PowerPoint becomes several.
2086
+ *
2087
+ * Notes are recorded through a {@link layoutShapeScope}, so the slide vocabulary the shared
2088
+ * mapper speaks lands under `layout.` and stays attributable to the layout tier. Constructs
2089
+ * written below are therefore relative to that prefix.
2090
+ */
2091
+ function layoutObjects(layout, notes, assets) {
2092
+ const out = [];
2093
+ collectObjects(layout.shapes, out, layoutShapeScope(notes), assets);
2094
+ return out;
2095
+ }
2096
+ /** {@link layoutObjects}'s walk, recursing through groups. */
2097
+ function collectObjects(shapes, out, notes, assets) {
2098
+ for (const shape of shapes) {
2099
+ if (isPlaceholderShape(shape.element_)) continue;
2100
+ if (isGroupShape(shape)) {
2101
+ notes.forShape(shape.name || null).note("group", "flattened", "unwritable", "defineSlideMaster({ objects }) has no group variant, so this group's children are emitted as loose objects; they land in the same places — the group's offset, rotation, flips and child-space scaling are composed into each child's coordinates — but the layout no longer offers them as one selectable object");
2102
+ collectObjects(shape.shapes, out, notes, assets);
2103
+ continue;
2104
+ }
2105
+ const object = masterObject(shape, notes, assets);
2106
+ if (object !== null) out.push(object);
2107
+ }
2108
+ }
2109
+ /**
2110
+ * Collapse the whitespace an XML attribute value cannot carry.
2111
+ *
2112
+ * Not a style choice: XML normalises a literal tab, carriage return or line feed inside an
2113
+ * attribute value to a space (XML 1.0 §3.3.3), and the write path emits a layout title as a
2114
+ * literal rather than as a `&#10;` character reference. So a PowerPoint layout named across
2115
+ * two lines — "Abschnitts-⏎überschrift" is a real one, in the German built-in set — comes back
2116
+ * with a space either way. Doing it here rather than leaving it to the serializer means the
2117
+ * emitted script states the title the output will actually have, and the round trip compares
2118
+ * two decks that agree instead of reporting a rename it cannot act on.
2119
+ */
2120
+ function flattenAttributeValue(value) {
2121
+ return value.replace(/[\t\r\n]+/g, " ");
2122
+ }
2123
+ /** A master/layout background reduced to what `BackgroundProps` accepts. */
2124
+ function backgroundProps(background, notes, assets) {
2125
+ if (!background || background.type === "none") return void 0;
2126
+ switch (background.type) {
2127
+ case "solid": return background.color ? { color: literalColor(background.color.effectiveHex) } : void 0;
2128
+ case "image": {
2129
+ const asset = background.partName === null ? null : assets.assetFor(background.partName);
2130
+ return asset ? { data: asset } : void 0;
2131
+ }
2132
+ case "themeRef": {
2133
+ const fill = background.resolvedFill;
2134
+ if (fill?.type === "solid" && fill.color) {
2135
+ notes.note("master.background", "flattened", "unwritable", "this layout's background is a theme reference (p:bgRef into the theme's background fill list), which has no write-API option; the colour it currently resolves to is baked in and stops following the theme");
2136
+ return { color: literalColor(fill.color.effectiveHex) };
2137
+ }
2138
+ notes.note("master.background", "dropped", "unwritable", "this layout's background is a theme reference to a non-solid fill, which has no write-API option; the layout is emitted with no background");
2139
+ return;
2140
+ }
2141
+ default:
2142
+ notes.note("master.background", "dropped", "unsupported", `a ${background.type} layout background is not expressible through the write API's background option, so the layout is emitted with no background`);
2143
+ return;
2144
+ }
2145
+ }
2146
+ //#endregion
2147
+ //#region src/script/from-read/transition.ts
2148
+ /**
2149
+ * The transitions the write API can author, as a lookup.
2150
+ *
2151
+ * Spelled as a `Record` keyed by `TransitionType` rather than as an array of strings so the
2152
+ * compiler polices it in *both* directions: a name the union does not have is rejected here,
2153
+ * and a name the union gains without being added here fails to satisfy the record. A plain
2154
+ * `Set<string>` would silently drift out of date, and the symptom would be a printed script
2155
+ * that does not compile — found by whoever runs it, not by this repository's test suite.
2156
+ */
2157
+ const WRITABLE_TYPES = {
2158
+ blinds: true,
2159
+ checker: true,
2160
+ circle: true,
2161
+ comb: true,
2162
+ cover: true,
2163
+ cut: true,
2164
+ diamond: true,
2165
+ dissolve: true,
2166
+ fade: true,
2167
+ newsflash: true,
2168
+ plus: true,
2169
+ pull: true,
2170
+ push: true,
2171
+ random: true,
2172
+ randomBar: true,
2173
+ split: true,
2174
+ strips: true,
2175
+ wedge: true,
2176
+ wheel: true,
2177
+ wipe: true,
2178
+ zoom: true
2179
+ };
2180
+ /**
2181
+ * `true` when the write API has a name for this transition.
2182
+ *
2183
+ * The namespace check does no work on PowerPoint's own output and is kept anyway. Every one
2184
+ * of the 21 modern effect names in the probed table (`conveyor`, `morph`, `vortex`, …) is
2185
+ * distinct from every base name, so today the name lookup alone would reach the same verdict
2186
+ * — measured, not assumed. But "is this a base ECMA-376 transition" is a question about the
2187
+ * *namespace*, and answering it by name would be relying on that disjointness holding for
2188
+ * every namespace Microsoft ever adds. The write path emits `p:${type}` unconditionally, so
2189
+ * the cost of being wrong is a modern effect silently rewritten as a base one with the same
2190
+ * name and no note — a conversion that reports itself as faithful. `script-ir.test.js`
2191
+ * authors a `p14:fade` to keep this branch honest, since no fixture can.
2192
+ */
2193
+ function isWritable(info) {
2194
+ return info.namespace === "p" && Object.hasOwn(WRITABLE_TYPES, info.type);
2195
+ }
2196
+ /**
2197
+ * Map a slide's transition, recording a note for anything that does not survive.
2198
+ *
2199
+ * Returns `undefined` both when the slide has no transition and when it has one this
2200
+ * library cannot author — the difference between those two is what the note carries.
2201
+ */
2202
+ function transitionToIr(slide, notes, assets) {
2203
+ const info = slide.transition;
2204
+ if (!info) return void 0;
2205
+ if (!isWritable(info)) {
2206
+ notes.note("slide.transition", "dropped", "unwritable", `this slide's ${info.namespace === "p" ? "" : `${info.namespace} `}${info.type} transition is not one of the 21 base ECMA-376 transitions the write API can author${info.namespace === "p" ? "" : " — it is one of PowerPoint's modern effects, which have no write-API vocabulary"}, so the regenerated slide advances with no effect`);
2207
+ return;
2208
+ }
2209
+ const sound = soundToIr(slide, info, notes, assets);
2210
+ return {
2211
+ type: info.type,
2212
+ speed: info.speed,
2213
+ ...info.durationMs === null ? {} : { durationMs: info.durationMs },
2214
+ ...info.advanceOnClick ? {} : { advanceOnClick: false },
2215
+ ...info.advanceAfterMs === null ? {} : { advanceAfterMs: info.advanceAfterMs },
2216
+ ...Object.keys(info.variant).length > 0 ? { variant: { ...info.variant } } : {},
2217
+ ...sound ? { sound } : {}
2218
+ };
2219
+ }
2220
+ /**
2221
+ * The transition's sound (`p:sndAc`), in the two forms OOXML gives it.
2222
+ *
2223
+ * The stop-previous form is pure structure — no relationship, no media part — so it maps
2224
+ * unconditionally. A start sound is an embedded WAV reached through the slide's own
2225
+ * relationships, which is the only place in this converter that resolves an `r:embed` by
2226
+ * hand: every other media reference arrives already resolved to a partname by the read
2227
+ * model's shape accessors, and a transition sound has no shape to hang off.
2228
+ */
2229
+ function soundToIr(slide, info, notes, assets) {
2230
+ const sound = info.sound;
2231
+ if (!sound) return void 0;
2232
+ if (sound.form === "stop") return { stopPrevious: true };
2233
+ const partName = soundPartName(slide, sound.embedRid);
2234
+ const asset = partName === null ? null : assets.assetFor(partName);
2235
+ if (!asset) {
2236
+ notes.note("slide.transitionSound", "dropped", "unsupported", `this transition's start sound (${sound.embedRid ?? "no r:embed"}) does not resolve to an audio part in the package, so the regenerated transition is silent`);
2237
+ return;
2238
+ }
2239
+ return {
2240
+ data: asset,
2241
+ ...sound.name === null ? {} : { name: sound.name },
2242
+ ...sound.loop ? { loop: true } : {}
2243
+ };
2244
+ }
2245
+ /** Resolve `p:snd@r:embed` to the audio part it names, or `null` when it does not resolve. */
2246
+ function soundPartName(slide, embedRid) {
2247
+ if (!embedRid) return null;
2248
+ const relationship = slide.relationships.get(embedRid);
2249
+ if (!relationship || relationship.targetMode === "External") return null;
2250
+ return slide.relationships.resolveTarget(embedRid);
2251
+ }
2252
+ //#endregion
2253
+ //#region src/script/from-read/deck.ts
2254
+ /** Default slide size (10" × 7.5") for a deck whose `presentation.xml` declares none. */
2255
+ const DEFAULT_SLIDE_SIZE = {
2256
+ widthEmu: 9144e3,
2257
+ heightEmu: 6858e3
2258
+ };
2259
+ /** Extensions by content type, so an emitted asset filename is one a viewer recognises. */
2260
+ const ASSET_EXTENSIONS = {
2261
+ "image/png": "png",
2262
+ "image/jpeg": "jpg",
2263
+ "image/gif": "gif",
2264
+ "image/bmp": "bmp",
2265
+ "image/webp": "webp",
2266
+ "image/tiff": "tif",
2267
+ "image/x-emf": "emf",
2268
+ "image/x-wmf": "wmf",
2269
+ "image/svg+xml": "svg",
2270
+ "audio/x-wav": "wav",
2271
+ "audio/wav": "wav",
2272
+ "audio/mpeg": "mp3"
2273
+ };
2274
+ /**
2275
+ * Collects media as shapes reference it, assigning each part a stable sequential name.
2276
+ *
2277
+ * Names are assigned in first-reference order rather than derived from the source part
2278
+ * name, so the output is deterministic for a given deck and does not leak whatever the
2279
+ * source package happened to call its files. A part referenced twice resolves to one asset.
2280
+ *
2281
+ * Numbering runs per *kind* (`image1.png`, `image2.png`, `audio1.wav`) rather than over one
2282
+ * shared counter. Not cosmetics: the asset name becomes a `const` identifier in the emitted
2283
+ * script and a filename in its asset directory, and a transition sound bound to `image7`
2284
+ * reads as a bug in the generated source every time anyone opens it.
2285
+ */
2286
+ var Assets = class {
2287
+ pres;
2288
+ #byPartName = /* @__PURE__ */ new Map();
2289
+ #assets = [];
2290
+ #counts = /* @__PURE__ */ new Map();
2291
+ constructor(pres) {
2292
+ this.pres = pres;
2293
+ }
2294
+ assetFor(partName) {
2295
+ const existing = this.#byPartName.get(partName);
2296
+ if (existing) return existing;
2297
+ const part = this.pres.opc.part(partName);
2298
+ if (!part) return null;
2299
+ const extension = ASSET_EXTENSIONS[part.contentType] ?? partName.split(".").pop() ?? "bin";
2300
+ const kind = part.contentType.startsWith("audio/") ? "audio" : "image";
2301
+ const index = (this.#counts.get(kind) ?? 0) + 1;
2302
+ this.#counts.set(kind, index);
2303
+ const name = `${kind}${index}.${extension}`;
2304
+ const ref = { $asset: name };
2305
+ this.#byPartName.set(partName, ref);
2306
+ this.#assets.push({
2307
+ name,
2308
+ contentType: part.contentType,
2309
+ bytes: part.bytes
2310
+ });
2311
+ return ref;
2312
+ }
2313
+ contentTypeOf(partName) {
2314
+ return this.pres.opc.part(partName)?.contentType ?? null;
2315
+ }
2316
+ get assets() {
2317
+ return this.#assets;
2318
+ }
2319
+ };
2320
+ /** Convert a loaded deck into its IR. */
2321
+ function readModelToIr(pres) {
2322
+ const collector = new NoteCollector();
2323
+ const assets = new Assets(pres);
2324
+ const size = pres.slideSize;
2325
+ const deckScope = scopeNotes(collector, null);
2326
+ if (!size) deckScope.note("deck.slideSize", "approximated", "unsupported", `this deck declares no slide size, so the output uses the ${DEFAULT_SLIDE_SIZE.widthEmu / 914400}in × ${DEFAULT_SLIDE_SIZE.heightEmu / 914400}in default`);
2327
+ const layouts = layoutGallery(pres);
2328
+ const chrome = chromeToIr(pres, deckScope, assets);
2329
+ const slides = pres.slides.map((slide, index) => slideToIr(slide, index + 1, collector, assets, layouts));
2330
+ return {
2331
+ slideSize: size ? {
2332
+ widthEmu: size.widthEmu,
2333
+ heightEmu: size.heightEmu
2334
+ } : DEFAULT_SLIDE_SIZE,
2335
+ props: deckProps(pres, deckScope),
2336
+ chrome,
2337
+ slides,
2338
+ assets: assets.assets,
2339
+ fidelity: collector.notes
2340
+ };
2341
+ }
2342
+ /**
2343
+ * Deck properties, reduced to the five `docProps` fields the write API sets. The other
2344
+ * seven the read model exposes have no setter — they survive anyway in a template-anchored
2345
+ * output, since the source deck itself becomes the template, so this is only a loss for a
2346
+ * standalone one.
2347
+ */
2348
+ function deckProps(pres, notes) {
2349
+ const core = pres.coreProperties;
2350
+ if ([
2351
+ "keywords",
2352
+ "description",
2353
+ "category",
2354
+ "contentStatus",
2355
+ "lastModifiedBy"
2356
+ ].some((key) => core[key] !== void 0)) notes.note("deck.docProps", "dropped", "unwritable", "only title, author, company, subject and revision have write-API setters; keywords, description, category, content status and last-modified-by have none");
2357
+ return compact({
2358
+ title: core.title,
2359
+ author: core.creator,
2360
+ subject: core.subject,
2361
+ revision: core.revision
2362
+ }) ?? {};
2363
+ }
2364
+ /**
2365
+ * The deck's layouts keyed by partname, each resolved to what a printer needs to bind to
2366
+ * it: its gallery position and whether its name identifies it on its own.
2367
+ *
2368
+ * Built once per deck rather than per slide — `layouts()` walks every master's layout list,
2369
+ * and the uniqueness question is about the gallery as a whole, so asking it per slide would
2370
+ * be both quadratic and unable to see layouts no slide happens to use.
2371
+ */
2372
+ function layoutGallery(pres) {
2373
+ const handles = pres.layouts();
2374
+ const nameCounts = /* @__PURE__ */ new Map();
2375
+ for (const handle of handles) nameCounts.set(handle.name, (nameCounts.get(handle.name) ?? 0) + 1);
2376
+ return new Map(handles.map((handle, index) => [handle.partName, {
2377
+ name: handle.name,
2378
+ index,
2379
+ nameIsUnique: (nameCounts.get(handle.name) ?? 0) === 1
2380
+ }]));
2381
+ }
2382
+ function slideToIr(slide, number, collector, assets, layouts) {
2383
+ const notes = scopeNotes(collector, number);
2384
+ const layoutPartName = slide.layout?.partName;
2385
+ const base = {
2386
+ number,
2387
+ layout: (layoutPartName === void 0 ? void 0 : layouts.get(layoutPartName)) ?? null,
2388
+ hidden: slide.hidden,
2389
+ ...slide.name ? { name: slide.name } : {},
2390
+ ...backgroundOf(slide, notes),
2391
+ ...notesOf(slide, notes)
2392
+ };
2393
+ const transition = transitionToIr(slide, notes, assets);
2394
+ const carried = hasUnwritableContent(slide);
2395
+ if (carried) notes.note("slide.carried", "flattened", "unwritable", "this slide holds a graphic frame the write API cannot author (an extended chart, a SmartArt diagram, an OLE object or ink); a printer that can copy the source slide does so instead of transcribing it, so it renders correctly but the emitted script does not describe its contents. The per-shape note beside this one says which construct forced the copy");
2396
+ const calls = [];
2397
+ for (const shape of slide.shapes) {
2398
+ const call = shapeCall(shape, notes, assets);
2399
+ if (call) calls.push(call);
2400
+ }
2401
+ recordAnimationLoss(slide, notes);
2402
+ return {
2403
+ ...base,
2404
+ ...transition ? { transition } : {},
2405
+ source: carried ? "carried" : "authored",
2406
+ calls
2407
+ };
2408
+ }
2409
+ /**
2410
+ * `true` when the slide holds something with no write-API expression at all, forcing a
2411
+ * carried slide. Descends into groups, since a group can host a graphic frame.
2412
+ *
2413
+ * Membership comes from {@link unwritableFramePayload}, which `graphicFrameCall` also uses to
2414
+ * pick its note — one enumeration, so the two cannot drift apart again. This test named
2415
+ * extended charts alone until then, which is why a slide holding SmartArt (or an OLE object,
2416
+ * or ink) was transcribed as `authored` and rendered with a hole where the graphic had been.
2417
+ */
2418
+ function hasUnwritableContent(slide) {
2419
+ const check = (shapes) => shapes.some((shape) => {
2420
+ if (isGroupShape(shape)) return check(shape.shapes);
2421
+ return isGraphicFrame(shape) && unwritableFramePayload(shape) !== null;
2422
+ });
2423
+ return check(slide.shapes);
2424
+ }
2425
+ function backgroundOf(slide, notes) {
2426
+ const background = slide.background;
2427
+ if (!background || background.source !== "slide") return {};
2428
+ switch (background.type) {
2429
+ case "solid": return background.color ? { background: compact({ color: background.color.effectiveHex.replace(/^#/, "").toUpperCase() }) } : {};
2430
+ case "none": return {};
2431
+ default:
2432
+ notes.note("slide.background", "dropped", "unsupported", `a ${background.type} slide background is not expressible through the write API's background option, so the slide takes its layout's background`);
2433
+ return {};
2434
+ }
2435
+ }
2436
+ function notesOf(slide, notes) {
2437
+ const text = slide.notesText;
2438
+ if (!text) return {};
2439
+ const frame = slide.notesTextFrame;
2440
+ if (frame && frame.paragraphs.some((paragraph) => paragraph.runs.some((run) => run.bold || run.italic))) notes.note("notes.formatting", "flattened", "unsupported", "speaker notes carry as plain text; per-run formatting and the notes slide's own placeholder geometry do not");
2441
+ return { notesText: text };
2442
+ }
2443
+ /**
2444
+ * Build animations, which live in a slide-scoped `p:timing` element rather than in any
2445
+ * shape's subtree.
2446
+ *
2447
+ * The transition next door used to be recorded here for the same reason and is now mapped
2448
+ * instead (`from-read/transition.ts`), which leaves this the one genuinely slide-scoped
2449
+ * loss: there is no structural reader for `p:timing` at all, only spid manipulation for the
2450
+ * deck-to-deck import path, so a build sequence is invisible to this converter.
2451
+ */
2452
+ function recordAnimationLoss(slide, notes) {
2453
+ if (slide.hasAnimations) notes.note("slide.animation", "dropped", "unread", "build animations have no structural reader (p:timing is only manipulated by spid for the deck-to-deck import path), so every shape on this slide lands static");
2454
+ }
2455
+ //#endregion
2456
+ //#region src/script/print/literal.ts
2457
+ /**
2458
+ * {@link IrValue} → TypeScript source text.
2459
+ *
2460
+ * This module is deliberately the dumbest part of the subsystem. It knows nothing about
2461
+ * decks, shapes, or units — only how a value is spelled. Every semantic decision was made
2462
+ * upstream in the read half, which is what lets the printed text be checked by comparing
2463
+ * IRs rather than by reading source: if this file were allowed to interpret, the same
2464
+ * construct could mean two things depending on which half you asked.
2465
+ *
2466
+ * Two consequences worth stating, because they are easy to erode later:
2467
+ *
2468
+ * - **Numbers are printed, not reformatted.** The IR already holds geometry either as a
2469
+ * `` `${n}emu` `` string (exact) or as a six-decimal inch number (the proven minimum for
2470
+ * an EMU-exact round-trip). Rounding here to suppress `0.5000000001`-style noise would
2471
+ * turn a cosmetic problem into a real geometry loss, so `printNumber` only refuses
2472
+ * values JavaScript cannot spell back.
2473
+ * - **Layout is a function of the value alone.** Whether an object prints on one line or
2474
+ * several depends only on its rendered width, never on where it came from, so the same
2475
+ * deck prints byte-identical text every time.
2476
+ */
2477
+ /** Matches `.oxfmtrc.jsonc`: `printWidth: 120`. */
2478
+ const MAX_WIDTH = 120;
2479
+ /**
2480
+ * Columns a tab is assumed to occupy when measuring against {@link MAX_WIDTH}. The
2481
+ * formatter's default `tabWidth`, since the repo sets `useTabs` but not `tabWidth`.
2482
+ */
2483
+ const TAB_COLUMNS = 2;
2484
+ /** A bare object key needs no quotes only if it is a plain identifier. */
2485
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
2486
+ /**
2487
+ * A string literal in the repo's style (`singleQuote: true`).
2488
+ *
2489
+ * Deck text is arbitrary user content, so every case below is reachable in practice: a
2490
+ * quote, a backslash, a hard line break inside a run. `JSON.stringify` would handle those
2491
+ * correctly — it is avoided here for the quote style, not for correctness.
2492
+ */
2493
+ function printString(value) {
2494
+ let out = "'";
2495
+ for (const char of value) switch (char) {
2496
+ case "\\":
2497
+ out += "\\\\";
2498
+ break;
2499
+ case "'":
2500
+ out += "\\'";
2501
+ break;
2502
+ case "\n":
2503
+ out += "\\n";
2504
+ break;
2505
+ case "\r":
2506
+ out += "\\r";
2507
+ break;
2508
+ case " ":
2509
+ out += "\\t";
2510
+ break;
2511
+ default: {
2512
+ const code = char.codePointAt(0) ?? 0;
2513
+ out += code < 32 || code >= 127 && code <= 159 || code === 8232 || code === 8233 ? "\\u" + code.toString(16).padStart(4, "0") : char;
2514
+ }
2515
+ }
2516
+ return `${out}'`;
2517
+ }
2518
+ /**
2519
+ * A number literal. `String` gives the shortest text that reads back as the same double,
2520
+ * which is exactly the guarantee needed: the printed script must produce the same value the
2521
+ * IR held. Non-finite values cannot satisfy that (`NaN` and `Infinity` are identifiers, not
2522
+ * literals) and never reach here from a well-formed IR, so they throw rather than emit
2523
+ * source that would not run.
2524
+ */
2525
+ function printNumber(value) {
2526
+ if (!Number.isFinite(value)) throw new InvalidOptionError("script/non-finite-literal", `Cannot print a non-finite number: ${value}`);
2527
+ return String(value);
2528
+ }
2529
+ /** An object key, bare when it is a plain identifier and quoted when it is not. */
2530
+ function printKey(key) {
2531
+ return IDENTIFIER.test(key) ? key : printString(key);
2532
+ }
2533
+ /** The value on a single line, however long that line comes out. */
2534
+ function inline(value, asset) {
2535
+ if (value === null) return "null";
2536
+ if (typeof value === "boolean") return String(value);
2537
+ if (typeof value === "number") return printNumber(value);
2538
+ if (typeof value === "string") return printString(value);
2539
+ if (isAssetRef(value)) return asset(value);
2540
+ if (Array.isArray(value)) return `[${value.map((item) => inline(item, asset)).join(", ")}]`;
2541
+ const keys = Object.keys(value);
2542
+ if (keys.length === 0) return "{}";
2543
+ return `{ ${keys.map((key) => `${printKey(key)}: ${inline(value[key], asset)}`).join(", ")} }`;
2544
+ }
2545
+ /**
2546
+ * The value as source text, broken across lines only where it would otherwise overflow
2547
+ * {@link MAX_WIDTH}.
2548
+ *
2549
+ * `indentLevel` is the depth the value *starts* at, so a caller that has already written a
2550
+ * prefix (`slide1.addText(`) passes the level its closing brace should line up with.
2551
+ */
2552
+ function printValue(value, indentLevel, asset) {
2553
+ const flat = inline(value, asset);
2554
+ if (indentLevel * TAB_COLUMNS + flat.length <= MAX_WIDTH) return flat;
2555
+ const pad = " ".repeat(indentLevel);
2556
+ const inner = " ".repeat(indentLevel + 1);
2557
+ if (Array.isArray(value)) {
2558
+ if (value.length === 0) return "[]";
2559
+ return `[\n${value.map((item) => `${inner}${printValue(item, indentLevel + 1, asset)},`).join("\n")}\n${pad}]`;
2560
+ }
2561
+ if (typeof value === "object" && value !== null && !isAssetRef(value)) {
2562
+ const keys = Object.keys(value);
2563
+ if (keys.length === 0) return "{}";
2564
+ return `{\n${keys.map((key) => `${inner}${printKey(key)}: ${printValue(value[key], indentLevel + 1, asset)},`).join("\n")}\n${pad}}`;
2565
+ }
2566
+ return flat;
2567
+ }
2568
+ /**
2569
+ * A call's argument list, either inline after `prefix` or one argument per line.
2570
+ *
2571
+ * The decision is made on the *whole* call rather than per argument, so a call either reads
2572
+ * as one line or as a list — never as a short first argument followed by a hanging brace,
2573
+ * which is what a per-argument rule produces.
2574
+ */
2575
+ function printArguments(prefix, args, indentLevel, asset) {
2576
+ const pad = " ".repeat(indentLevel);
2577
+ const flat = `${pad}${prefix}(${args.map((arg) => inline(arg, asset)).join(", ")})`;
2578
+ if (indentLevel * TAB_COLUMNS + flat.length - pad.length <= MAX_WIDTH) return flat;
2579
+ const inner = " ".repeat(indentLevel + 1);
2580
+ return `${pad}${prefix}(\n${args.map((arg) => `${inner}${printValue(arg, indentLevel + 1, asset)}`).join(",\n")}\n${pad})`;
2581
+ }
2582
+ /**
2583
+ * Apply the defaults both printers share.
2584
+ *
2585
+ * `templatePath` is deliberately not here: only the template-anchored tier has one, and that
2586
+ * difference is the whole reason there are two printers.
2587
+ * @param options - the caller's options, any of which may be absent
2588
+ */
2589
+ function resolvePrintOptions(options) {
2590
+ return {
2591
+ outputPath: options.outputPath ?? "./output.pptx",
2592
+ assetDir: (options.assetDir ?? "./assets").replace(/\/$/, ""),
2593
+ assetMode: options.assets ?? "file",
2594
+ packageName: options.packageName ?? "@shbernal/ts-pptx"
2595
+ };
2596
+ }
2597
+ /** Width the header comment's prose wraps at, leaving room for the ` * ` prefix. */
2598
+ const COMMENT_WIDTH = 110;
2599
+ /**
2600
+ * A stable JavaScript identifier per asset.
2601
+ *
2602
+ * Derived from the asset's own name rather than its index, so a reader can match a `const`
2603
+ * in the script against a file in the asset directory. The dedupe suffix is defensive: the
2604
+ * read half currently names assets `image1.png`, `image2.jpg`, … which cannot collide after
2605
+ * the extension is stripped, but nothing in the IR contract promises that.
2606
+ */
2607
+ function assetIdentifiers(ir) {
2608
+ const out = /* @__PURE__ */ new Map();
2609
+ const used = /* @__PURE__ */ new Set();
2610
+ for (const asset of ir.assets) {
2611
+ const base = asset.name.replace(/\.[^.]*$/, "").replace(/[^A-Za-z0-9_$]/g, "_");
2612
+ let identifier = /^[A-Za-z_$]/.test(base) ? base : `_${base}`;
2613
+ let suffix = 2;
2614
+ while (used.has(identifier)) identifier = `${base}_${suffix++}`;
2615
+ used.add(identifier);
2616
+ out.set(asset.name, identifier);
2617
+ }
2618
+ return out;
2619
+ }
2620
+ /** Resolve an {@link AssetRef} to the `const` identifier standing for its bytes. */
2621
+ function assetPrinter(identifiers) {
2622
+ return (ref) => {
2623
+ const identifier = identifiers.get(ref.$asset);
2624
+ if (!identifier) throw new InvalidOptionError("script/unresolved-asset-reference", `Asset reference ${ref.$asset} has no entry in DeckIr.assets`);
2625
+ return identifier;
2626
+ };
2627
+ }
2628
+ /**
2629
+ * One `const` per asset, each holding a complete `data:` URI.
2630
+ *
2631
+ * Both modes bind the same *kind* of value — a data URI string — so the call sites are
2632
+ * identical and `addImage`'s `data` option is used either way. Only where the bytes come
2633
+ * from changes. Rewriting the option key to `path` in file mode would have the printer
2634
+ * reshaping an argument, which is precisely the thing that keeps the IR checkable.
2635
+ */
2636
+ function printAssetBindings(ir, identifiers, assetDir, mode, inlineComment = "// Media bytes, inlined so this script needs no files but its template.") {
2637
+ if (ir.assets.length === 0) return [];
2638
+ const lines = mode === "file" ? ["// Media bytes, loaded from the asset directory beside this script."] : [inlineComment];
2639
+ for (const asset of ir.assets) {
2640
+ const identifier = identifiers.get(asset.name);
2641
+ if (!identifier) continue;
2642
+ if (mode === "file") {
2643
+ const path = printString(`${assetDir}/${asset.name}`);
2644
+ lines.push(`const ${identifier} = \`data:${asset.contentType};base64,\${(await readFile(here(${path}))).toString('base64')}\``);
2645
+ } else lines.push(`const ${identifier} = ${printString(`data:${asset.contentType};base64,${toBase64(asset.bytes)}`)}`);
2646
+ }
2647
+ return lines;
2648
+ }
2649
+ /** Base64 without `Buffer`, matching the rest of the library's isomorphic media handling. */
2650
+ function toBase64(bytes) {
2651
+ let binary = "";
2652
+ const CHUNK = 32768;
2653
+ for (let i = 0; i < bytes.length; i += CHUNK) binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
2654
+ return btoa(binary);
2655
+ }
2656
+ /**
2657
+ * One slide: a comment, the statement that creates it, then its slide-level properties and
2658
+ * its calls in z-order.
2659
+ *
2660
+ * `comment` and `construction` are the tier's business — one binds a layout by name at append
2661
+ * time, the other names a master up front — and everything after them is not.
2662
+ */
2663
+ function printSlide(slide, parts, collector, printAsset) {
2664
+ const notes = scopeNotes(collector, slide.number);
2665
+ const identifier = `slide${slide.number}`;
2666
+ const lines = [
2667
+ "",
2668
+ `// ${parts.comment}`,
2669
+ `const ${identifier} = ${parts.construction}`
2670
+ ];
2671
+ if (slide.name !== void 0) notes.note("slide.name", "dropped", "unwritable", `the slide's own name (p:cSld@name, here ${JSON.stringify(slide.name)}) has no public write-API setter, so the generated slide is unnamed`);
2672
+ if (slide.hidden) lines.push(`${identifier}.hidden = true`);
2673
+ if (slide.background) lines.push(`${identifier}.background = ${printValue(slide.background, 0, printAsset)}`);
2674
+ if (slide.transition) lines.push(`${identifier}.transition = ${printValue(asIrValue(slide.transition), 0, printAsset)}`);
2675
+ if (slide.notesText !== void 0) lines.push(printArguments(`${identifier}.addNotes`, [slide.notesText], 0, printAsset));
2676
+ for (const call of slide.calls) {
2677
+ if (call.sourceName) lines.push(`// ${call.sourceName.replace(/\s+/g, " ")}`);
2678
+ lines.push(printArguments(`${identifier}.${call.method}`, call.args, 0, printAsset));
2679
+ }
2680
+ return lines;
2681
+ }
2682
+ /** The banner comment: `prose` as written, then every loss that applies to this output. */
2683
+ function header(prose, notes) {
2684
+ const lines = [...prose, ""];
2685
+ if (notes.length === 0) lines.push("FIDELITY: nothing was lost that this converter can detect.");
2686
+ else {
2687
+ lines.push(`FIDELITY: ${notes.length} declared loss(es). Everything not listed here is expected to`, "survive unchanged; a difference that is not in this list is a defect, not a caveat.", "");
2688
+ for (const [scope, group] of groupNotes(notes)) {
2689
+ lines.push(`${scope}:`);
2690
+ for (const note of group) {
2691
+ const where = note.shapeName ? ` (${note.shapeName})` : "";
2692
+ lines.push(...wrap(`- ${note.construct}${where} [${note.disposition}, ${note.cause}] — ${note.detail}`, COMMENT_WIDTH, " "));
2693
+ }
2694
+ }
2695
+ }
2696
+ return [
2697
+ "/*",
2698
+ ...lines.map((line) => line ? ` * ${line}` : " *"),
2699
+ " */"
2700
+ ].join("\n");
2701
+ }
2702
+ /** Notes bucketed by slide, in deck order, so a reader meets them the way they meet the deck. */
2703
+ function groupNotes(notes) {
2704
+ const deckLevel = notes.filter((note) => note.slideNumber === null);
2705
+ const bySlide = /* @__PURE__ */ new Map();
2706
+ for (const note of notes) {
2707
+ if (note.slideNumber === null) continue;
2708
+ const existing = bySlide.get(note.slideNumber);
2709
+ if (existing) existing.push(note);
2710
+ else bySlide.set(note.slideNumber, [note]);
2711
+ }
2712
+ const out = [];
2713
+ if (deckLevel.length > 0) out.push(["Deck", deckLevel]);
2714
+ for (const number of [...bySlide.keys()].sort((a, b) => a - b)) out.push([`Slide ${number}`, bySlide.get(number) ?? []]);
2715
+ return out;
2716
+ }
2717
+ /** Wrap prose to `width`, indenting every line after the first by `continuation`. */
2718
+ function wrap(text, width, continuation) {
2719
+ const words = text.split(/\s+/).filter(Boolean);
2720
+ const lines = [];
2721
+ let current = "";
2722
+ for (const word of words) {
2723
+ const candidate = current ? `${current} ${word}` : `${continuation}${word}`;
2724
+ if (current && candidate.length > width) {
2725
+ lines.push(current);
2726
+ current = `${continuation}${word}`;
2727
+ } else current = current ? candidate : word;
2728
+ }
2729
+ if (current) lines.push(current);
2730
+ return lines;
2731
+ }
2732
+ /**
2733
+ * Close out a printed script: the accumulated lines as one module source, plus the
2734
+ * asset side-car the caller has to write beside it.
2735
+ *
2736
+ * Both tiers end this way, and the `assetMode` test is the whole reason it is worth
2737
+ * naming. In `'inline'` mode the bytes are already in {@link PrintedScript.code} as
2738
+ * `data:` literals, so the map must come back **empty** — a tier that returned them
2739
+ * anyway would have its caller write every image to disk as well, and the script would
2740
+ * ignore the files it was handed.
2741
+ * @param {string[]} lines - the script's lines, in order
2742
+ * @param {DeckIr} ir - the IR being printed, for its assets
2743
+ * @param {AssetMode} assetMode - how image bytes reach the script
2744
+ * @param {FidelityNote[]} notes - the losses that apply to this output
2745
+ * @returns {PrintedScript} the finished script
2746
+ */
2747
+ function printedScript(lines, ir, assetMode, notes) {
2748
+ return {
2749
+ code: lines.join("\n"),
2750
+ assets: new Map(assetMode === "file" ? ir.assets.map((asset) => [asset.name, asset.bytes]) : []),
2751
+ notes
2752
+ };
2753
+ }
2754
+ //#endregion
2755
+ //#region src/script/print/script.ts
2756
+ /**
2757
+ * Constructs the IR declares lost that a template-anchored output keeps anyway.
2758
+ *
2759
+ * Document properties are the obvious case: the read half notes that only five of the twelve
2760
+ * have write-API setters, which is true and matters for a standalone output — but this tier
2761
+ * never authors them at all. They ride in the template, untouched, all twelve of them.
2762
+ *
2763
+ * The whole of the chrome is the same story on a larger scale. The theme's format scheme, the
2764
+ * master's text styles and colour map, the layouts' decoration and placeholder definitions —
2765
+ * every one of them is a genuine loss for a script that has to rebuild the deck's design, and
2766
+ * none of them is touched here, because the design *is* the template. A caveat that does not
2767
+ * apply to the output in front of you is worse than no caveat: it teaches the reader to skim
2768
+ * the ones that do.
2769
+ *
2770
+ * Alongside this list, {@link isTemplateCarried} drops every note under
2771
+ * {@link LAYOUT_NOTE_PREFIX}. Those are the per-shape losses of re-authoring a layout's
2772
+ * decoration, recorded in the slide vocabulary because the shape mapper is shared — and every
2773
+ * one of them is about a layout, which this tier does not rebuild.
2774
+ */
2775
+ const TEMPLATE_CARRIED_CONSTRUCTS = /* @__PURE__ */ new Set([
2776
+ "deck.docProps",
2777
+ "master.background",
2778
+ "master.colorMap",
2779
+ "master.decoration",
2780
+ "master.multiple",
2781
+ "master.name",
2782
+ "master.nameCollision",
2783
+ "master.placeholders",
2784
+ "master.txStyles",
2785
+ "theme.fmtScheme"
2786
+ ]);
2787
+ /** `true` when the source deck carries this loss through untouched, so this tier does not have it. */
2788
+ function isTemplateCarried(construct) {
2789
+ return TEMPLATE_CARRIED_CONSTRUCTS.has(construct) || construct.startsWith("layout.");
2790
+ }
2791
+ /** Turn a deck IR into a runnable, template-anchored TypeScript module. */
2792
+ function printScript(ir, options = {}) {
2793
+ const templatePath = options.templatePath ?? "./template.pptx";
2794
+ const { outputPath, assetDir, assetMode, packageName } = resolvePrintOptions(options);
2795
+ const collector = new NoteCollector();
2796
+ const assetNames = assetIdentifiers(ir);
2797
+ const body = printSlides$1(ir, collector, assetPrinter(assetNames));
2798
+ const needsSource = ir.slides.some((slide) => slide.source === "carried");
2799
+ const needsFallbackLayout = ir.slides.some((slide) => slide.source === "authored" && slide.layout === null);
2800
+ const notes = [...ir.fidelity.filter((note) => !isTemplateCarried(note.construct)), ...collector.notes];
2801
+ const lines = [
2802
+ header([
2803
+ "Generated from a .pptx by ts-pptx/script.",
2804
+ "",
2805
+ "Template-anchored: the source deck is reused as the template, so its slide masters,",
2806
+ "layouts, theme and document properties are the originals, byte for byte. Only the",
2807
+ "slide content below was rebuilt through the public write API.",
2808
+ "",
2809
+ `Expects ${templatePath} (the source deck, unmodified) beside this file${assetMode === "file" && ir.assets.length > 0 ? `, plus ${ir.assets.length} media file(s) in ${assetDir}` : ""}.`,
2810
+ `Writes ${outputPath}. Needs an ESM context — it uses top-level await.`
2811
+ ], notes),
2812
+ "",
2813
+ "import { readFile, writeFile } from 'node:fs/promises'",
2814
+ "import { fileURLToPath } from 'node:url'",
2815
+ `import TsPptx from ${printString(packageName)}`,
2816
+ `import { Presentation } from ${printString(`${packageName}/read`)}`,
2817
+ "",
2818
+ "/** Resolve a path against this script rather than against the working directory. */",
2819
+ "const here = (name: string): string => fileURLToPath(new URL(name, import.meta.url))",
2820
+ "",
2821
+ "// The source deck is the template: fromTemplate strips its slides and leaves its masters,",
2822
+ "// layouts, theme and document properties exactly as they were.",
2823
+ `const deck = await Presentation.fromTemplate(here(${printString(templatePath)}))`
2824
+ ];
2825
+ if (needsSource) lines.push("", "// A second, unstripped handle on the same file, for the slides copied verbatim.", `const source = await Presentation.load(here(${printString(templatePath)}))`);
2826
+ if (needsFallbackLayout) lines.push("", "// Some source slides resolved no layout of their own; they bind to the first one.", "const fallbackLayout = deck.layouts()[0]", "if (!fallbackLayout) throw new Error('the template declares no slide layouts to bind to')");
2827
+ const assetLines = printAssetBindings(ir, assetNames, assetDir, assetMode);
2828
+ if (assetLines.length > 0) lines.push("", ...assetLines);
2829
+ lines.push("", "/**", " * A generator sized to the template. appendSlides compares slide sizes exactly and", " * throws when they differ, so the inches here must round-trip to the source EMU.", " */", "function generator(): TsPptx {", " const pptx = new TsPptx()", `\tpptx.defineLayout({ name: 'source', width: ${inches(ir.slideSize.widthEmu)}, height: ${inches(ir.slideSize.heightEmu)} })`, " pptx.layout = 'source'", " return pptx", "}", ...body, "", `await writeFile(here(${printString(outputPath)}), await deck.save())`, "");
2830
+ return printedScript(lines, ir, assetMode, notes);
2831
+ }
2832
+ /**
2833
+ * The slide statements, batched into generators, preceded by the layout handles they bind to.
2834
+ *
2835
+ * A batch ends when the layout changes or a carried slide interrupts, because
2836
+ * `appendSlides` binds one layout per call and a carried slide is not authored at all.
2837
+ *
2838
+ * Every layout `const` is hoisted above the first statement rather than emitted where it is
2839
+ * first used. `deck.layouts()` is a snapshot of the gallery, and `importSlide` — the call a
2840
+ * carried slide prints — grows that gallery with the source slide's own layout and master.
2841
+ * Resolving a handle after one has run is therefore resolving against a deck that has already
2842
+ * moved; capturing every handle up front is the only ordering that cannot.
2843
+ */
2844
+ function printSlides$1(ir, collector, printAsset) {
2845
+ const declarations = [];
2846
+ const lines = [];
2847
+ let batch = [];
2848
+ let generatorIndex = 0;
2849
+ const declaredLayouts = /* @__PURE__ */ new Set();
2850
+ const flush = () => {
2851
+ if (batch.length === 0) return;
2852
+ const first = batch[0];
2853
+ if (!first) return;
2854
+ const name = `gen${++generatorIndex}`;
2855
+ lines.push("", `const ${name} = generator()`);
2856
+ for (const slide of batch) lines.push(...printAuthoredSlide(slide, name, collector, printAsset));
2857
+ lines.push("", printAppend(name, first, collector, declaredLayouts, declarations));
2858
+ batch = [];
2859
+ };
2860
+ for (const slide of ir.slides) {
2861
+ if (slide.source === "carried") {
2862
+ flush();
2863
+ lines.push("", `// Slide ${slide.number} — copied from the source deck rather than rebuilt; see the fidelity notes.`, `deck.importSlide(source, ${slide.number - 1})`);
2864
+ continue;
2865
+ }
2866
+ const previous = batch[0];
2867
+ if (previous && previous.layout?.index !== slide.layout?.index) flush();
2868
+ batch.push(slide);
2869
+ }
2870
+ flush();
2871
+ return declarations.length > 0 ? [
2872
+ "",
2873
+ ...declarations,
2874
+ ...lines
2875
+ ] : lines;
2876
+ }
2877
+ /**
2878
+ * The `appendSlides` call closing a batch, binding by layout name where that is
2879
+ * unambiguous and by gallery position where it is not. Any handle it needs is appended to
2880
+ * `declarations`, which the caller hoists above every statement.
2881
+ *
2882
+ * Binding by name is preferred because it survives being re-pointed at a different
2883
+ * template and because it is legible. But `appendSlides` *throws* on an ambiguous name
2884
+ * rather than picking one, so the positional form resolves the case where the name does not
2885
+ * identify one layout: a multi-master deck that repeats layout names.
2886
+ *
2887
+ * A carried slide used to demote every binding in the script to a position too, because its
2888
+ * `importSlide` copied the source layout in under a second name-alike entry. `importSlide`
2889
+ * now binds to the chrome the template already holds (`read/api/ops/part-reuse.ts`), and
2890
+ * this deck's template *is* the source file, so the gallery no longer grows and the names
2891
+ * stay unambiguous.
2892
+ */
2893
+ function printAppend(generatorName, first, collector, declared, declarations) {
2894
+ const layout = first.layout;
2895
+ if (!layout) return `await deck.appendSlides(${generatorName}, { layout: fallbackLayout })`;
2896
+ if (layout.nameIsUnique) return `await deck.appendSlides(${generatorName}, { layout: ${printString(layout.name)} })`;
2897
+ scopeNotes(collector, first.number).note("slide.layout", "approximated", "unsupported", `more than one layout in the source deck is named ${JSON.stringify(layout.name)}, so this slide binds to gallery position ${layout.index} instead; re-pointing the script at a different template will not track the name`);
2898
+ const identifier = `layout${layout.index}`;
2899
+ if (!declared.has(layout.index)) {
2900
+ declared.add(layout.index);
2901
+ declarations.push(`const ${identifier} = deck.layouts()[${layout.index}] // ${JSON.stringify(layout.name)}`, `if (!${identifier}) throw new Error('the template has no layout at position ${layout.index}')`);
2902
+ }
2903
+ return `await deck.appendSlides(${generatorName}, { layout: ${identifier} })`;
2904
+ }
2905
+ /** One authored slide, plus the layout-binding note this tier is responsible for. */
2906
+ function printAuthoredSlide(slide, generatorName, collector, printAsset) {
2907
+ if (slide.layout === null) scopeNotes(collector, slide.number).note("slide.layout", "approximated", "unsupported", "this slide resolves no layout of its own, so it binds to the template's first layout; that governs theme and clrMap resolution, which may differ from the source");
2908
+ if (slide.transition?.sound?.data !== void 0) scopeNotes(collector, slide.number).note("slide.transitionSound", "dropped", "unsupported", "this transition's start sound is dropped: the append path this tier rides does not register a transition's embedded audio part, so the sound does not reach the template. The assignment below still spells it out, and the WAV still ships beside the script, because both describe the source deck faithfully and would take effect the day the append path carries them — but this output is silent. Print the standalone variant to keep the sound");
2909
+ const layout = slide.layout === null ? "no layout" : `layout ${JSON.stringify(slide.layout.name)}`;
2910
+ return printSlide(slide, {
2911
+ comment: `Slide ${slide.number} — ${layout}`,
2912
+ construction: `${generatorName}.addSlide()`
2913
+ }, collector, printAsset);
2914
+ }
2915
+ //#endregion
2916
+ //#region src/script/print/standalone.ts
2917
+ /**
2918
+ * Notes the IR declares that do not describe *this* output.
2919
+ *
2920
+ * Only one, and it is the mirror of the other tier's list. `slide.carried` says a slide will
2921
+ * be copied from the source package rather than transcribed; there is no source package here,
2922
+ * so the slide is transcribed and the construct that made it uncarryable has already recorded
2923
+ * its own note. Reporting both would double-count one loss and describe a behaviour this
2924
+ * script does not have.
2925
+ */
2926
+ const NOT_APPLICABLE = /* @__PURE__ */ new Set(["slide.carried"]);
2927
+ /** Turn a deck IR into a runnable TypeScript module that needs no template. */
2928
+ function printStandaloneScript(ir, options = {}) {
2929
+ const { outputPath, assetDir, assetMode, packageName } = resolvePrintOptions(options);
2930
+ const collector = new NoteCollector();
2931
+ const assetNames = assetIdentifiers(ir);
2932
+ const printAsset = assetPrinter(assetNames);
2933
+ const masters = printMasters(ir, collector, printAsset);
2934
+ const body = printSlides(ir, collector, printAsset);
2935
+ const notes = [...ir.fidelity.filter((note) => !NOT_APPLICABLE.has(note.construct)), ...collector.notes];
2936
+ const needsReadFile = assetMode === "file" && ir.assets.length > 0;
2937
+ const lines = [
2938
+ header([
2939
+ "Generated from a .pptx by ts-pptx/script.",
2940
+ "",
2941
+ `Standalone: this script needs nothing but ${packageName}${needsReadFile ? ` and ${ir.assets.length} media file(s) in ${assetDir}` : ""}.`,
2942
+ "The deck's theme, layouts and slide content are all re-authored through the public write",
2943
+ "API, so every one of them is editable here — and the parts of the original design the read",
2944
+ "model cannot see are gone. They are listed below. For a byte-identical design at the cost",
2945
+ "of shipping the source deck alongside the script, print the template-anchored variant",
2946
+ "instead.",
2947
+ "",
2948
+ `Writes ${outputPath}. Needs an ESM context — it uses top-level await.`
2949
+ ], notes),
2950
+ "",
2951
+ ...needsReadFile ? ["import { readFile } from 'node:fs/promises'"] : [],
2952
+ "import { fileURLToPath } from 'node:url'",
2953
+ `import TsPptx from ${printString(packageName)}`,
2954
+ "",
2955
+ "/** Resolve a path against this script rather than against the working directory. */",
2956
+ "const here = (name: string): string => fileURLToPath(new URL(name, import.meta.url))",
2957
+ "",
2958
+ "const pptx = new TsPptx()",
2959
+ `pptx.defineLayout({ name: 'source', width: ${inches(ir.slideSize.widthEmu)}, height: ${inches(ir.slideSize.heightEmu)} })`,
2960
+ "pptx.layout = 'source'"
2961
+ ];
2962
+ const theme = ir.chrome.theme;
2963
+ if (Object.keys(ir.chrome.theme).length > 0) lines.push("", "// The theme's colour scheme and font faces. Its format scheme — the fill, line and effect", "// style lists a shape references through p:style — has no counterpart on either side of the", "// library, so the output carries Office's.", `pptx.theme = ${printValue(theme, 0, printAsset)}`);
2964
+ const props = printDocProps(ir, collector);
2965
+ if (props.length > 0) lines.push("", ...props);
2966
+ const assetLines = printAssetBindings(ir, assetNames, assetDir, assetMode, "// Media bytes, inlined so this script is a single self-contained file.");
2967
+ if (assetLines.length > 0) lines.push("", ...assetLines);
2968
+ if (masters.length > 0) lines.push("", ...masters);
2969
+ lines.push(...body, "", `await pptx.writeFile({ fileName: here(${printString(outputPath)}) })`, "");
2970
+ return printedScript(lines, ir, assetMode, notes);
2971
+ }
2972
+ /**
2973
+ * The document properties the write API can set.
2974
+ *
2975
+ * Four of the twelve the read model exposes, which is the loss `deck.docProps` already
2976
+ * declares — and unlike the template-anchored tier, here it is a real one, because nothing
2977
+ * else carries them.
2978
+ */
2979
+ function printDocProps(ir, collector) {
2980
+ const entries = Object.entries(ir.props).filter(([, value]) => typeof value === "string");
2981
+ const stamped = [
2982
+ "title",
2983
+ "author",
2984
+ "company",
2985
+ "subject",
2986
+ "revision"
2987
+ ].filter((key) => !(key in ir.props));
2988
+ if (stamped.length > 0) scopeNotes(collector, null).note("deck.docPropsDefault", "approximated", "unwritable", `the source deck declares no ${stamped.join(", ")}, and a deck built through the write API is stamped with the library's own value for each in its constructor with no way to unset it, so the output declares ${stamped.length === 1 ? "one" : "these"} the source did not`);
2989
+ if (entries.length === 0) return [];
2990
+ return ["// Document properties. Only these have write-API setters; see the fidelity notes.", ...entries.map(([key, value]) => `pptx.${key} = ${printString(value)}`)];
2991
+ }
2992
+ /**
2993
+ * One `defineSlideMaster` per source layout.
2994
+ *
2995
+ * The naming is the write API's rather than OOXML's: a `defineSlideMaster` call creates a
2996
+ * *layout* under the single shared master, which is the right granularity for a source layout.
2997
+ * It carries the layout's name, its background, and its decoration — the bands, rules and
2998
+ * wordmarks that make the gallery recognisable rather than a list of blank rectangles. What it
2999
+ * still cannot carry is the layout's *placeholder definitions*, and that is a write-path
3000
+ * consequence rather than a reading one (see `from-read/chrome.ts`). The name earns its place
3001
+ * twice over: it is also what `addSlide({ masterTitle })` binds on.
3002
+ */
3003
+ function printMasters(ir, collector, printAsset) {
3004
+ scopeNotes(collector, null, "DEFAULT").note("master.default", "approximated", "unsupported", "the write path seeds every presentation with a blank layout of its own named DEFAULT, and there is no way to remove it, so the output deck's layout gallery carries one extra entry ahead of the source's layouts; nothing binds to it, but it is visible in PowerPoint's layout picker");
3005
+ if (ir.chrome.masters.length === 0) return [];
3006
+ return [
3007
+ "// One master per source layout, carrying its name, background and decoration. What a",
3008
+ "// layout does not bring across is its placeholder definitions; see the fidelity notes.",
3009
+ ...ir.chrome.masters.map((master) => printArguments("pptx.defineSlideMaster", [master.props], 0, printAsset))
3010
+ ];
3011
+ }
3012
+ /** Every slide, in source order, each bound to the master its source layout became. */
3013
+ function printSlides(ir, collector, printAsset) {
3014
+ const byLayout = /* @__PURE__ */ new Map();
3015
+ for (const master of ir.chrome.masters) byLayout.set(master.layoutIndex, master);
3016
+ const lines = [];
3017
+ for (const slide of ir.slides) {
3018
+ const master = slide.layout === null ? void 0 : byLayout.get(slide.layout.index);
3019
+ const title = typeof master?.props["title"] === "string" ? master.props["title"] : null;
3020
+ if (title === null) scopeNotes(collector, slide.number).note("slide.layout", "approximated", "unsupported", slide.layout === null ? "this slide resolves no layout of its own, so it binds to the write path's default blank layout" : `this slide's source layout (gallery position ${slide.layout.index}) produced no master to bind to, so it binds to the write path's default blank layout`);
3021
+ lines.push(...printSlide(slide, {
3022
+ comment: `Slide ${slide.number} — ${title === null ? "no master" : `master ${JSON.stringify(title)}`}`,
3023
+ construction: title === null ? "pptx.addSlide()" : `pptx.addSlide({ masterTitle: ${printString(title)} })`
3024
+ }, collector, printAsset));
3025
+ }
3026
+ return lines;
3027
+ }
3028
+ //#endregion
3029
+ //#region src/script/verify/canonical.ts
3030
+ /**
3031
+ * A {@link DeckIr} reduced to the form a round-trip diff can compare.
3032
+ *
3033
+ * The round trip is `source → IR₁ → script → deck → IR₂`. Comparing those two IRs raw
3034
+ * produces thousands of differences, almost none of which are losses: the write path
3035
+ * spells out defaults that OOXML leaves implicit, and PowerPoint spells out defaults the
3036
+ * write path leaves implicit. Both directions are noise, and drowning the real losses in
3037
+ * it is the same as not checking at all.
3038
+ *
3039
+ * **Every rule here must be an equivalence, not a convenience.** A rule that merely makes
3040
+ * the report shorter hides a defect permanently and silently — the exact failure mode this
3041
+ * subsystem exists to catch. So each one below cites the OOXML default that makes the
3042
+ * explicit and the absent spelling mean the same thing, and none of them drops a value that
3043
+ * a viewer could render differently. When in doubt the value stays in and the difference is
3044
+ * reported: a noisy report is recoverable, a quiet one is not.
3045
+ *
3046
+ * Canonicalising is deliberately *symmetric* — it runs over both IRs, so it cannot favour
3047
+ * the output. A rule that only made sense in one direction would be an exclusion dressed up
3048
+ * as a normalisation.
3049
+ */
3050
+ /**
3051
+ * Option values that mean exactly what their absence means.
3052
+ *
3053
+ * Keyed by option name because the IR's argument objects are the write API's own option
3054
+ * objects, where a name identifies a construct uniquely — there is no `bold` that means
3055
+ * something other than `a:rPr/@b`.
3056
+ *
3057
+ * Each entry is an OOXML default:
3058
+ *
3059
+ * - `bold`, `italic` — `a:rPr/@b` / `@i` are `ST_OnOff`, default `false`.
3060
+ * - `baseline` — `a:rPr/@baseline` is a percentage, default `0` (no super/subscript).
3061
+ * - `breakLine` — not OOXML but the IR's own flat encoding: a run that does not end its
3062
+ * paragraph. The final run of a text frame never carries one, so absent and `false` are
3063
+ * the same statement.
3064
+ * - `wrap` — `a:bodyPr/@wrap` defaults to `square`, which the write API spells `true`.
3065
+ * - `rotate` — `a:xfrm/@rot` defaults to `0`.
3066
+ * - `flipH`, `flipV` — `a:xfrm/@flipH` / `@flipV` default to `false`.
3067
+ * - `transparency` — an alpha of 0% removed, i.e. fully opaque, which is also the default
3068
+ * when no `a:alpha` transform is present.
3069
+ * - `indentLevel` — `a:p/@lvl` defaults to `0`.
3070
+ * - `dashType` — `a:ln`'s dash defaults to `solid` when no `a:prstDash` is present.
3071
+ * - `textDirection` — `a:bodyPr/@vert` and `a:tcPr/@vert` both default to `horz`.
3072
+ *
3073
+ * Line width is deliberately **absent**: `a:ln/@w` defaults to 0 (hairline) while the write
3074
+ * path defaults it to 1pt, so those two are not the same line and the difference is real.
3075
+ */
3076
+ const IMPLIED_DEFAULTS = {
3077
+ bold: false,
3078
+ italic: false,
3079
+ baseline: 0,
3080
+ breakLine: false,
3081
+ wrap: true,
3082
+ rotate: 0,
3083
+ flipH: false,
3084
+ flipV: false,
3085
+ transparency: 0,
3086
+ indentLevel: 0,
3087
+ dashType: "solid",
3088
+ textDirection: "horz"
3089
+ };
3090
+ /**
3091
+ * `a:bodyPr`'s default insets in inches, in the write API's `[top, right, bottom, left]`
3092
+ * order: 45720 EMU (0.05in, 3.6pt) top and bottom, 91440 EMU (0.1in, 7.2pt) left and right.
3093
+ * A body that spells them out is inset exactly as one that omits them.
3094
+ */
3095
+ const DEFAULT_MARGIN = [
3096
+ .05,
3097
+ .1,
3098
+ .05,
3099
+ .1
3100
+ ];
3101
+ /** Reduce a deck IR to the comparable form. */
3102
+ function canonicalDeckIr(ir) {
3103
+ const digests = assetDigests(ir.assets);
3104
+ return {
3105
+ slideSize: ir.slideSize,
3106
+ props: canonicalValue(ir.props, digests),
3107
+ chrome: {
3108
+ theme: canonicalValue(ir.chrome.theme, digests),
3109
+ masters: ir.chrome.masters.map((master) => canonicalValue(master.props, digests))
3110
+ },
3111
+ slides: ir.slides.map((slide) => canonicalSlide(slide, digests))
3112
+ };
3113
+ }
3114
+ function canonicalSlide(slide, digests) {
3115
+ return {
3116
+ number: slide.number,
3117
+ hidden: slide.hidden,
3118
+ layoutName: slide.layout?.name ?? null,
3119
+ background: slide.background === void 0 ? null : canonicalValue(slide.background, digests),
3120
+ notesText: slide.notesText ?? null,
3121
+ transition: slide.transition === void 0 ? null : canonicalValue(asIrValue(slide.transition), digests),
3122
+ calls: slide.calls.map((call) => {
3123
+ const args = call.args.map((arg) => canonicalValue(arg, digests));
3124
+ return {
3125
+ method: call.method,
3126
+ shapeName: call.sourceName ?? null,
3127
+ containedNames: [...collectObjectNames(args, /* @__PURE__ */ new Set())],
3128
+ args
3129
+ };
3130
+ })
3131
+ };
3132
+ }
3133
+ /**
3134
+ * Every `objectName` value reachable in `value`, at any depth.
3135
+ *
3136
+ * Exported for the chrome half of the diff, which needs the same list for the same reason: a
3137
+ * layout's decoration is an argument to one `defineSlideMaster` call, so a note scoped to one
3138
+ * of those shapes has no call of its own to be matched against.
3139
+ */
3140
+ function collectObjectNames(value, out) {
3141
+ if (Array.isArray(value)) {
3142
+ for (const item of value) collectObjectNames(item, out);
3143
+ return out;
3144
+ }
3145
+ if (value === null || typeof value !== "object") return out;
3146
+ for (const [key, item] of Object.entries(value)) if (key === "objectName" && typeof item === "string") out.add(item);
3147
+ else collectObjectNames(item, out);
3148
+ return out;
3149
+ }
3150
+ /**
3151
+ * Recursively drop implied defaults and resolve asset references to a content digest.
3152
+ *
3153
+ * The digest matters more than it looks: assets are named `image1.png`, `image2.png`, … in
3154
+ * the order the walk meets them, and the output deck can meet the same bytes in a different
3155
+ * order or under a different extension. Comparing names would report a swap that did not
3156
+ * happen and, worse, would *miss* one where two images traded places.
3157
+ */
3158
+ function canonicalValue(value, digests) {
3159
+ if (isAssetRef(value)) return { $asset: digests.get(value.$asset) ?? `unknown:${value.$asset}` };
3160
+ if (Array.isArray(value)) return value.map((item) => canonicalValue(item, digests));
3161
+ if (value === null || typeof value !== "object") return value;
3162
+ const blankRun = value["text"] === "" && typeof value["options"] === "object" && value["options"] !== null;
3163
+ const out = {};
3164
+ for (const [key, item] of Object.entries(value)) {
3165
+ if (key in IMPLIED_DEFAULTS && item === IMPLIED_DEFAULTS[key]) continue;
3166
+ if (key === "margin" && isDefaultMargin(item)) continue;
3167
+ if (blankRun && key === "options") {
3168
+ const visible = { ...item };
3169
+ delete visible["color"];
3170
+ out[key] = canonicalValue(visible, digests);
3171
+ continue;
3172
+ }
3173
+ out[key] = canonicalValue(item, digests);
3174
+ }
3175
+ return out;
3176
+ }
3177
+ function isDefaultMargin(value) {
3178
+ return Array.isArray(value) && value.length === DEFAULT_MARGIN.length && value.every((item, index) => item === DEFAULT_MARGIN[index]);
3179
+ }
3180
+ /**
3181
+ * Content digests keyed by asset name, so the same bytes compare equal under any name.
3182
+ *
3183
+ * FNV-1a rather than a real hash: this identifies bytes for a comparison both sides of
3184
+ * which are in hand, so collision resistance against an adversary is not a property it
3185
+ * needs, and the library stays free of `node:crypto` and of the isomorphism problem
3186
+ * importing it would create.
3187
+ */
3188
+ function assetDigests(assets) {
3189
+ const out = /* @__PURE__ */ new Map();
3190
+ for (const asset of assets) {
3191
+ let hash = 2166136261;
3192
+ for (const byte of asset.bytes) {
3193
+ hash ^= byte;
3194
+ hash = Math.imul(hash, 16777619) >>> 0;
3195
+ }
3196
+ out.set(asset.name, `${asset.bytes.length}:${hash.toString(16)}`);
3197
+ }
3198
+ return out;
3199
+ }
3200
+ //#endregion
3201
+ //#region src/script/verify/diff.ts
3202
+ /**
3203
+ * The round-trip oracle: does the deck a printed script builds match the deck it was read
3204
+ * from, everywhere the fidelity notes did not say it would not?
3205
+ *
3206
+ * **Why a projection diff and not byte identity.** The output package is not the input
3207
+ * package and never will be — different rel ids, different shape ids, a regenerated shape
3208
+ * tree, `dirty="0"` on every run. Comparing bytes would report a total mismatch for every
3209
+ * deck and prove nothing. So the comparison happens one level up, on the IR: run the same
3210
+ * `readModelToIr` over the source deck and over the deck the script produced, and compare
3211
+ * those. Two decks that produce the same IR make the same write-API calls, which is exactly
3212
+ * the claim a generated script makes.
3213
+ *
3214
+ * **What a note is worth here.** A {@link FidelityNote} says a named construct will not
3215
+ * survive. That makes it an *exclusion with a signature*: the difference it predicts is
3216
+ * allowed, and every other difference is a defect. Without the notes there would be no way
3217
+ * to tell a known cost from a regression, and the check could only ever be a snapshot.
3218
+ *
3219
+ * **Two blind spots, both stated plainly, because a clean report is worth exactly what its
3220
+ * limits allow.**
3221
+ *
3222
+ * 1. *The reader is shared.* Both IRs come from `readModelToIr`, so a construct the read
3223
+ * model cannot see is missing from both and compares equal. This check cannot certify
3224
+ * anything the read path does not read; `pnpm run read:census` measures that surface, and
3225
+ * the two are complementary rather than overlapping.
3226
+ * 2. *The converter need not be injective.* Two different source constructs can map to the
3227
+ * same call, and then the round trip agrees while the deck has changed. Measured, not
3228
+ * hypothetical: the mapper once read an explicit `a:buNone` as the glyph `n`, so a
3229
+ * bulletless paragraph gained a bullet — and because the output's real `n` bullet read
3230
+ * back through the same wrong mapping, the diff was clean. Mutation testing is what
3231
+ * catches that class, and a mutation that survives here is a statement about this
3232
+ * check's reach rather than a mistake in it.
3233
+ *
3234
+ * A clean report means "nothing the converter can distinguish was lost", never "nothing was
3235
+ * lost".
3236
+ */
3237
+ /**
3238
+ * Options the write path materialises that the source deck left implicit, with the reason
3239
+ * each is not judgeable from here.
3240
+ *
3241
+ * This list exists because `added` differences cannot simply be waved through. Mutation
3242
+ * testing settled that: dropping the converter's explicit `valign` left the write path to
3243
+ * centre every text body, which showed up as an `added` and nothing failed. So `added` is
3244
+ * held to the same standard as the rest — a note must declare it, or its field must appear
3245
+ * here with a reason.
3246
+ *
3247
+ * Every entry names a write-path default whose *correctness* the round trip has no way to
3248
+ * assess, because the source's counterpart was inherited and the read model cannot resolve
3249
+ * what it was inheriting. That is an honest "unknown", not an "ignore" — each one names a
3250
+ * gap in the reader that could be closed later, and teaching the reader to resolve one turns
3251
+ * its entry here into a real comparison.
3252
+ */
3253
+ const WRITER_DEFAULTS = {
3254
+ fontSize: "runs inherit size from an unread list style; the write path copies the shape size onto each run",
3255
+ options: "a run or cell that carried no options at all in the source now carries the ones below",
3256
+ border: "table cell borders are resolved through the style graph, which the read model cannot separate from the cell’s own",
3257
+ width: "an outline with no explicit width takes the write path’s 1pt default; the source’s came from the unread theme line style",
3258
+ shape: "a shape that inherits its geometry from a layout is emitted as a plain rectangle",
3259
+ type: "a fill or line with no explicit kind takes the write path’s solid default",
3260
+ chartColors: "the write path assigns a series palette; the source’s came from the theme",
3261
+ dataLabelFormatCode: "a data label with no explicit number format takes the write path’s default",
3262
+ showLeaderLines: "leader lines are on by default in the write path",
3263
+ title: "a deck that declares no title gets the write path’s own",
3264
+ author: "a deck that declares no author gets the write path’s own",
3265
+ subject: "a deck that declares no subject gets the write path’s own",
3266
+ company: "a deck that declares no company gets the write path’s own",
3267
+ revision: "a deck that declares no revision gets the write path’s own"
3268
+ };
3269
+ /**
3270
+ * Option names each note construct is a promise about.
3271
+ *
3272
+ * This table is the fidelity catalogue made mechanical. A note claims a construct will not
3273
+ * survive; without a mapping from that claim to the fields it covers, the claim cannot
3274
+ * exclude anything and the round trip degenerates into a snapshot.
3275
+ *
3276
+ * `'*'` covers every difference inside the call it is scoped to — correct only where the
3277
+ * note says the whole shape is gone or was copied wholesale, never as a shortcut for a
3278
+ * construct whose fields are merely tedious to enumerate.
3279
+ *
3280
+ * An empty list is meaningful and common: the construct is invisible to the IR on both
3281
+ * sides (a slide's build animations, a paragraph's `a:pPr/@marL`, a connector's shape
3282
+ * binding), so the note is a caveat for a human and there is nothing here to exclude. Saying
3283
+ * so explicitly is what keeps it from looking like an oversight. A construct can leave that
3284
+ * state — `slide.transition` was an empty entry until the converter learned to transcribe
3285
+ * transitions, and the IR gaining a field is exactly when its note gains a mapping.
3286
+ */
3287
+ const NOTE_FIELDS = {
3288
+ "chart.blanks": ["values"],
3289
+ "chart.combo": ["type"],
3290
+ "chart.type": ["type"],
3291
+ "chart.workbook": ["*"],
3292
+ "chartEx.all": ["*"],
3293
+ "connector.binding": [],
3294
+ "connector.line": [
3295
+ "color",
3296
+ "width",
3297
+ "dashType",
3298
+ "beginArrowType",
3299
+ "endArrowType"
3300
+ ],
3301
+ "connector.rotation": ["rotate"],
3302
+ "deck.docProps": [],
3303
+ "deck.docPropsDefault": [],
3304
+ "deck.slideSize": ["widthEmu", "heightEmu"],
3305
+ "diagram.all": ["*"],
3306
+ "fill.gradient.path": ["gradient", "fill"],
3307
+ "fill.picture": ["fill", "color"],
3308
+ "fill.picture.geometry": [],
3309
+ "fill.schemeToken": ["fill", "color"],
3310
+ "graphicFrame.unknown": ["*"],
3311
+ "group.child": ["*"],
3312
+ "group.childSpace": [
3313
+ "x",
3314
+ "y",
3315
+ "w",
3316
+ "h",
3317
+ "rotate",
3318
+ "flipH",
3319
+ "flipV"
3320
+ ],
3321
+ "group.empty": ["*"],
3322
+ "group.transform": [
3323
+ "rotate",
3324
+ "flipH",
3325
+ "flipV"
3326
+ ],
3327
+ "image.data": ["data", "$asset"],
3328
+ "image.recolor": [
3329
+ "duotone",
3330
+ "grayscale",
3331
+ "biLevel",
3332
+ "clrChange"
3333
+ ],
3334
+ "image.svg": [
3335
+ "svg",
3336
+ "data",
3337
+ "$asset"
3338
+ ],
3339
+ "line.arrowSize": ["beginArrowType", "endArrowType"],
3340
+ "master.background": [
3341
+ "background",
3342
+ "color",
3343
+ "data",
3344
+ "master"
3345
+ ],
3346
+ "master.colorMap": [],
3347
+ "master.decoration": [],
3348
+ "master.default": ["master"],
3349
+ "master.multiple": [],
3350
+ "master.name": ["title", "layoutName"],
3351
+ "master.nameCollision": ["title", "layoutName"],
3352
+ "master.placeholders": [],
3353
+ "master.txStyles": [],
3354
+ "theme.fmtScheme": [],
3355
+ "layout.decoration": [],
3356
+ "layout.group": [],
3357
+ "line.dash": ["dashType"],
3358
+ "line.width": ["width"],
3359
+ "media.audioVideo": ["*"],
3360
+ "notes.formatting": ["notesText"],
3361
+ "shape.custGeom.guides": ["points"],
3362
+ "shape.effects": ["shadow", "glow"],
3363
+ "shape.empty": ["*"],
3364
+ "shape.frameInherited": [
3365
+ "x",
3366
+ "y",
3367
+ "w",
3368
+ "h"
3369
+ ],
3370
+ "shape.hidden": ["*"],
3371
+ "shape.placeholder": ["placeholder"],
3372
+ "slide.animation": [],
3373
+ "slide.background": [
3374
+ "background",
3375
+ "color",
3376
+ "transparency",
3377
+ "image"
3378
+ ],
3379
+ "slide.carried": ["*"],
3380
+ "slide.layout": ["layoutName"],
3381
+ "slide.name": [],
3382
+ "slide.transition": ["transition"],
3383
+ "slide.transitionSound": [
3384
+ "sound",
3385
+ "data",
3386
+ "$asset"
3387
+ ],
3388
+ "table.cell.borders.dash": ["border", "diagonal"],
3389
+ "table.cell.headers": [],
3390
+ "table.cell.fill.picture": ["fill"],
3391
+ "table.cell.fill.picture.geometry": [],
3392
+ "table.fill.picture": ["tableFill"],
3393
+ "table.fill.picture.geometry": [],
3394
+ "table.fill.gradient": ["tableFill"],
3395
+ "table.fill.gradient.path": ["tableFill"],
3396
+ "table.cell.fill.gradient": ["fill"],
3397
+ "table.cell.fill.gradient.path": ["fill"],
3398
+ "table.cell.vert": ["textDirection", "vert"],
3399
+ "table.rowAuto": ["rowH"],
3400
+ "table.style": ["tableStyle"],
3401
+ "text.align": ["align"],
3402
+ "text.bullet.numberType": ["bullet"],
3403
+ "text.bullet.sizePt": ["bullet"],
3404
+ "text.bullet.sizePct": ["bullet"],
3405
+ "text.bullet.schemeToken": ["bullet"],
3406
+ "text.bullet.picture": ["bullet"],
3407
+ "text.color.default": ["color"],
3408
+ "text.color.inherited": ["color"],
3409
+ "text.color.schemeToken": ["color"],
3410
+ "text.equation": ["*"],
3411
+ "text.field": ["*"],
3412
+ "text.bullet.glyph": ["bullet"],
3413
+ "text.paraSpaceZero": ["paraSpaceBefore", "paraSpaceAfter"],
3414
+ "text.vert": ["vert"]
3415
+ };
3416
+ /**
3417
+ * Every note construct this table knows how to match, for a test to check against. The
3418
+ * `layout.` spellings are not listed — they resolve through {@link isKnownNoteConstruct},
3419
+ * which is what a corpus check should use.
3420
+ */
3421
+ function knownNoteConstructs() {
3422
+ return Object.keys(NOTE_FIELDS).sort();
3423
+ }
3424
+ /**
3425
+ * Compare the deck a script was generated from against the deck it produced.
3426
+ *
3427
+ * `notes` must be the set that actually applies to the printed output — `printScript`'s
3428
+ * returned `notes`, not `DeckIr.fidelity`. The two differ in both directions: the printer
3429
+ * suppresses notes its tier rescues and adds ones its tier causes, so diffing against the
3430
+ * IR's own list would both over- and under-exclude.
3431
+ */
3432
+ function diffDeckIr(expected, actual, notes) {
3433
+ const differences = [];
3434
+ if (expected.slideSize.widthEmu !== actual.slideSize.widthEmu) differences.push(scalarDifference(0, null, [], "slideSize.widthEmu", "widthEmu", expected.slideSize.widthEmu, actual.slideSize.widthEmu));
3435
+ if (expected.slideSize.heightEmu !== actual.slideSize.heightEmu) differences.push(scalarDifference(0, null, [], "slideSize.heightEmu", "heightEmu", expected.slideSize.heightEmu, actual.slideSize.heightEmu));
3436
+ diffValue(expected.props, actual.props, "props", "props", 0, null, [], differences);
3437
+ diffChrome(expected.chrome, actual.chrome, differences);
3438
+ const slideCount = Math.max(expected.slides.length, actual.slides.length);
3439
+ for (let index = 0; index < slideCount; index++) {
3440
+ const before = expected.slides[index];
3441
+ const after = actual.slides[index];
3442
+ const number = before?.number ?? after?.number ?? index + 1;
3443
+ if (!before || !after) {
3444
+ differences.push({
3445
+ slideNumber: number,
3446
+ shapeName: null,
3447
+ nestedNames: [],
3448
+ path: `slides[${index}]`,
3449
+ field: "*",
3450
+ kind: before ? "lost" : "added",
3451
+ expected: before ? "slide" : "—",
3452
+ actual: after ? "slide" : "—",
3453
+ declaredBy: null
3454
+ });
3455
+ continue;
3456
+ }
3457
+ diffSlide(before, after, differences);
3458
+ }
3459
+ for (const difference of differences) difference.declaredBy = declaringNote(difference, notes);
3460
+ const matched = new Set(differences.map((difference) => difference.declaredBy).filter(Boolean));
3461
+ const accounted = (difference) => difference.declaredBy !== null || difference.kind === "added" && difference.field in WRITER_DEFAULTS;
3462
+ return {
3463
+ slideCount: expected.slides.length,
3464
+ differences,
3465
+ undeclared: differences.filter((difference) => !accounted(difference)),
3466
+ declared: differences.filter((difference) => difference.declaredBy !== null),
3467
+ added: differences.filter((difference) => difference.kind === "added"),
3468
+ unmatchedNotes: notes.filter((note) => !matched.has(note))
3469
+ };
3470
+ }
3471
+ /**
3472
+ * The theme and the layout gallery.
3473
+ *
3474
+ * Reported at `slideNumber: 0`, the same deck-level bucket the slide size uses, so a
3475
+ * deck-scoped note (which carries `slideNumber: null` and matches any slide) can declare one.
3476
+ * Masters align by **title**, not by position, for the same reason calls align by shape name:
3477
+ * the write path always emits a blank layout of its own at gallery position 0, so positional
3478
+ * alignment reports every layout in the deck as renamed and buries the one real difference —
3479
+ * that extra layout — under a shift.
3480
+ *
3481
+ * And, exactly as with shape names, a title is only usable as a key where it is *unique on
3482
+ * both sides*. Uniqueness is supposed to be an invariant here — the converter deduplicates
3483
+ * titles because a title is also what `addSlide({ masterTitle })` binds on — but keying on it
3484
+ * unconditionally makes the check assume the thing it should be testing: a mutation that
3485
+ * removed the deduplication left two layouts sharing a title, both expected entries matched
3486
+ * the same actual one, and the round trip came back clean. Falling back to position when the
3487
+ * key repeats costs nothing when the invariant holds and catches it when it does not.
3488
+ */
3489
+ function diffChrome(expected, actual, out) {
3490
+ diffValue(expected.theme, actual.theme, "chrome.theme", "theme", 0, null, [], out);
3491
+ const report = (path, kind, before, after) => {
3492
+ out.push({
3493
+ slideNumber: 0,
3494
+ shapeName: titleOf(before ?? after ?? null),
3495
+ nestedNames: decorationNames(before, after),
3496
+ path,
3497
+ field: "master",
3498
+ kind,
3499
+ expected: brief(before),
3500
+ actual: brief(after),
3501
+ declaredBy: null
3502
+ });
3503
+ };
3504
+ const byTitle = uniqueByTitle(actual.masters);
3505
+ const expectedTitles = uniqueByTitle(expected.masters);
3506
+ const claimed = /* @__PURE__ */ new Set();
3507
+ let cursor = 0;
3508
+ const nextUnclaimed = () => {
3509
+ while (cursor < actual.masters.length) {
3510
+ const candidate = actual.masters[cursor++];
3511
+ if (candidate !== void 0 && !claimed.has(candidate)) return candidate;
3512
+ }
3513
+ };
3514
+ expected.masters.forEach((before, index) => {
3515
+ const title = titleOf(before);
3516
+ const named = title !== null && expectedTitles.has(title) ? byTitle.get(title) : void 0;
3517
+ const after = named !== void 0 && !claimed.has(named) ? named : nextUnclaimed();
3518
+ if (after === void 0) {
3519
+ report(`chrome.masters[${index}]`, "lost", before, void 0);
3520
+ return;
3521
+ }
3522
+ claimed.add(after);
3523
+ diffValue(before, after, `chrome.masters[${index}]`, "master", 0, null, decorationNames(before, after), out);
3524
+ });
3525
+ actual.masters.forEach((after, index) => {
3526
+ if (!claimed.has(after)) report(`chrome.masters[${index}]`, "added", void 0, after);
3527
+ });
3528
+ }
3529
+ /** Masters keyed by title, keeping only titles that identify exactly one of them. */
3530
+ function uniqueByTitle(masters) {
3531
+ const counts = /* @__PURE__ */ new Map();
3532
+ for (const master of masters) {
3533
+ const title = titleOf(master);
3534
+ if (title !== null) counts.set(title, (counts.get(title) ?? 0) + 1);
3535
+ }
3536
+ const out = /* @__PURE__ */ new Map();
3537
+ for (const master of masters) {
3538
+ const title = titleOf(master);
3539
+ if (title !== null && counts.get(title) === 1) out.set(title, master);
3540
+ }
3541
+ return out;
3542
+ }
3543
+ /**
3544
+ * The `objectName`s of every decorative shape in a master's props, taking both sides so a
3545
+ * shape that dropped out is still named by the source's.
3546
+ */
3547
+ function decorationNames(before, after) {
3548
+ const names = /* @__PURE__ */ new Set();
3549
+ for (const master of [before, after]) if (master !== void 0) collectObjectNames(master, names);
3550
+ return [...names];
3551
+ }
3552
+ /** A canonical master's `title`, which is its identity across the round trip. */
3553
+ function titleOf(master) {
3554
+ if (master === null || typeof master !== "object" || Array.isArray(master)) return null;
3555
+ const title = master["title"];
3556
+ return typeof title === "string" ? title : null;
3557
+ }
3558
+ function diffSlide(expected, actual, out) {
3559
+ const number = expected.number;
3560
+ const at = (path, field, a, b) => {
3561
+ if (JSON.stringify(a) !== JSON.stringify(b)) out.push(scalarDifference(number, null, [], path, field, a, b));
3562
+ };
3563
+ at("hidden", "hidden", expected.hidden, actual.hidden);
3564
+ at("layoutName", "layoutName", expected.layoutName, actual.layoutName);
3565
+ at("background", "background", expected.background, actual.background);
3566
+ at("notesText", "notesText", expected.notesText, actual.notesText);
3567
+ diffValue(expected.transition, actual.transition, "transition", "transition", number, null, [], out);
3568
+ for (const [before, after] of alignCalls(expected.calls, actual.calls)) {
3569
+ const name = before?.shapeName ?? after?.shapeName ?? null;
3570
+ const nested = [.../* @__PURE__ */ new Set([...before?.containedNames ?? [], ...after?.containedNames ?? []])];
3571
+ const path = `calls[${before ? expected.calls.indexOf(before) : actual.calls.indexOf(after)}]`;
3572
+ if (!before || !after) {
3573
+ out.push({
3574
+ slideNumber: number,
3575
+ shapeName: name,
3576
+ nestedNames: nested,
3577
+ path,
3578
+ field: "*",
3579
+ kind: before ? "lost" : "added",
3580
+ expected: before ? before.method : "—",
3581
+ actual: after ? after.method : "—",
3582
+ declaredBy: null
3583
+ });
3584
+ continue;
3585
+ }
3586
+ if (before.method !== after.method) out.push(scalarDifference(number, name, nested, `${path}.method`, "method", before.method, after.method));
3587
+ diffValue(before.args, after.args, `${path}.args`, "args", number, name, nested, out);
3588
+ }
3589
+ }
3590
+ /**
3591
+ * Pair up the two slides' calls.
3592
+ *
3593
+ * Position alone is wrong the moment one shape drops out: every later call shifts by one
3594
+ * and the report fills with mismatches that are really one missing shape. Shape names
3595
+ * survive the round trip (the converter passes `objectName` through), so they are the
3596
+ * alignment key wherever they are present and unambiguous, with position as the fallback
3597
+ * for unnamed or duplicate-named shapes.
3598
+ */
3599
+ function alignCalls(expected, actual) {
3600
+ const usable = (calls) => {
3601
+ const counts = /* @__PURE__ */ new Map();
3602
+ for (const call of calls) {
3603
+ if (call.shapeName === null) continue;
3604
+ counts.set(call.shapeName, (counts.get(call.shapeName) ?? 0) + 1);
3605
+ }
3606
+ const out = /* @__PURE__ */ new Map();
3607
+ for (const call of calls) if (call.shapeName !== null && counts.get(call.shapeName) === 1) out.set(call.shapeName, call);
3608
+ return out;
3609
+ };
3610
+ const byName = usable(actual);
3611
+ const claimed = /* @__PURE__ */ new Set();
3612
+ const pairs = [];
3613
+ let cursor = 0;
3614
+ const nextUnclaimed = () => {
3615
+ while (cursor < actual.length) {
3616
+ const candidate = actual[cursor++];
3617
+ if (candidate && !claimed.has(candidate)) return candidate;
3618
+ }
3619
+ return null;
3620
+ };
3621
+ for (const call of expected) {
3622
+ const named = call.shapeName === null ? void 0 : byName.get(call.shapeName);
3623
+ if (named && !claimed.has(named)) {
3624
+ claimed.add(named);
3625
+ pairs.push([call, named]);
3626
+ continue;
3627
+ }
3628
+ const positional = nextUnclaimed();
3629
+ if (positional) claimed.add(positional);
3630
+ pairs.push([call, positional]);
3631
+ }
3632
+ for (const call of actual) if (!claimed.has(call)) pairs.push([null, call]);
3633
+ return pairs;
3634
+ }
3635
+ function diffValue(expected, actual, path, field, slideNumber, shapeName, nestedNames, out) {
3636
+ if (expected === actual) return;
3637
+ const kindOf = (value) => value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
3638
+ if (kindOf(expected) !== kindOf(actual)) {
3639
+ out.push(scalarDifference(slideNumber, shapeName, nestedNames, path, field, expected, actual));
3640
+ return;
3641
+ }
3642
+ if (Array.isArray(expected) && Array.isArray(actual)) {
3643
+ const length = Math.max(expected.length, actual.length);
3644
+ for (let index = 0; index < length; index++) {
3645
+ const before = expected[index];
3646
+ const after = actual[index];
3647
+ if (before === void 0 || after === void 0) {
3648
+ out.push({
3649
+ slideNumber,
3650
+ shapeName,
3651
+ nestedNames,
3652
+ path: `${path}[${index}]`,
3653
+ field,
3654
+ kind: before === void 0 ? "added" : "lost",
3655
+ expected: brief(before),
3656
+ actual: brief(after),
3657
+ declaredBy: null
3658
+ });
3659
+ continue;
3660
+ }
3661
+ diffValue(before, after, `${path}[${index}]`, field, slideNumber, shapeName, nestedNames, out);
3662
+ }
3663
+ return;
3664
+ }
3665
+ if (expected !== null && typeof expected === "object" && actual !== null && typeof actual === "object") {
3666
+ const record = expected;
3667
+ const other = actual;
3668
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(record), ...Object.keys(other)])) {
3669
+ const before = record[key];
3670
+ const after = other[key];
3671
+ if (before === void 0 || after === void 0) {
3672
+ out.push({
3673
+ slideNumber,
3674
+ shapeName,
3675
+ nestedNames,
3676
+ path: `${path}.${key}`,
3677
+ field: key,
3678
+ kind: before === void 0 ? "added" : "lost",
3679
+ expected: brief(before),
3680
+ actual: brief(after),
3681
+ declaredBy: null
3682
+ });
3683
+ continue;
3684
+ }
3685
+ diffValue(before, after, `${path}.${key}`, key, slideNumber, shapeName, nestedNames, out);
3686
+ }
3687
+ return;
3688
+ }
3689
+ out.push(scalarDifference(slideNumber, shapeName, nestedNames, path, field, expected, actual));
3690
+ }
3691
+ function scalarDifference(slideNumber, shapeName, nestedNames, path, field, expected, actual) {
3692
+ return {
3693
+ slideNumber,
3694
+ shapeName,
3695
+ nestedNames,
3696
+ path,
3697
+ field,
3698
+ kind: "changed",
3699
+ expected: brief(expected),
3700
+ actual: brief(actual),
3701
+ declaredBy: null
3702
+ };
3703
+ }
3704
+ /** The first note that covers this difference, or `null` if none does. */
3705
+ function declaringNote(difference, notes) {
3706
+ for (const note of notes) {
3707
+ if (note.slideNumber !== null && note.slideNumber !== difference.slideNumber) continue;
3708
+ if (note.construct.startsWith("layout.") && difference.slideNumber !== 0) continue;
3709
+ if (note.shapeName !== null && note.shapeName !== difference.shapeName && !difference.nestedNames.includes(note.shapeName)) continue;
3710
+ const fields = noteFields(note.construct);
3711
+ if (!fields) continue;
3712
+ if (fields.includes("*") || fields.includes(difference.field)) return note;
3713
+ }
3714
+ return null;
3715
+ }
3716
+ /**
3717
+ * The fields a construct is a promise about.
3718
+ *
3719
+ * A layout-shape construct falls back to its slide counterpart, which is the whole point of
3720
+ * borrowing the vocabulary: `layout.line.width` is `line.width` on a layout and covers exactly
3721
+ * the same field. Only the two constructs with no slide twin need entries of their own.
3722
+ */
3723
+ function noteFields(construct) {
3724
+ return NOTE_FIELDS[construct] ?? (construct.startsWith("layout.") ? NOTE_FIELDS[construct.slice(7)] : void 0);
3725
+ }
3726
+ /**
3727
+ * `true` when the coverage table can match this construct — directly or through the
3728
+ * layout-shape fallback. The check a corpus test runs over every note both printers emit.
3729
+ */
3730
+ function isKnownNoteConstruct(construct) {
3731
+ return noteFields(construct) !== void 0;
3732
+ }
3733
+ function brief(value) {
3734
+ if (value === void 0) return "—";
3735
+ const text = JSON.stringify(value);
3736
+ return text.length > 80 ? `${text.slice(0, 80)}…` : text;
3737
+ }
3738
+ //#endregion
3739
+ export { InternalError, InvalidOptionError, LAYOUT_NOTE_PREFIX, MediaError, PackageReadError, TsPptxError, UnsupportedFeatureError, canonicalDeckIr, diffDeckIr, isAssetRef, isKnownNoteConstruct, knownNoteConstructs, printScript, printStandaloneScript, readModelToIr };
3740
+
3741
+ //# sourceMappingURL=script.js.map