pptx-ts 3.7.0 → 4.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 (136) hide show
  1. package/README.md +116 -269
  2. package/dist/autopage-odPz7zZO.js +536 -0
  3. package/dist/autopage-odPz7zZO.js.map +1 -0
  4. package/dist/binary-string-CKVAYI3M.js +24 -0
  5. package/dist/binary-string-CKVAYI3M.js.map +1 -0
  6. package/dist/body-insets-Dw_BeWD_.js +54 -0
  7. package/dist/body-insets-Dw_BeWD_.js.map +1 -0
  8. package/dist/browser.d.ts +29 -9
  9. package/dist/browser.d.ts.map +1 -1
  10. package/dist/browser.js +41 -14
  11. package/dist/browser.js.map +1 -1
  12. package/dist/clip-Bl01BPcN.d.ts +67 -0
  13. package/dist/clip-Bl01BPcN.d.ts.map +1 -0
  14. package/dist/diagnostics-D8ot4Ei7.d.ts +244 -0
  15. package/dist/diagnostics-D8ot4Ei7.d.ts.map +1 -0
  16. package/dist/embedded-fonts-DarT9aoa.d.ts +20 -0
  17. package/dist/embedded-fonts-DarT9aoa.d.ts.map +1 -0
  18. package/dist/entry-compose-DCnvBZz2.d.ts +1046 -0
  19. package/dist/entry-compose-DCnvBZz2.d.ts.map +1 -0
  20. package/dist/{enums-CvRYniUA.d.ts → enums-BUKTUB6V.d.ts} +21 -13
  21. package/dist/{enums-CvRYniUA.d.ts.map → enums-BUKTUB6V.d.ts.map} +1 -1
  22. package/dist/{errors-Bvln0H-7.js → errors-C4oENPcu.js} +92 -2
  23. package/dist/errors-C4oENPcu.js.map +1 -0
  24. package/dist/families.d.ts +260 -0
  25. package/dist/families.d.ts.map +1 -0
  26. package/dist/families.js +57 -0
  27. package/dist/families.js.map +1 -0
  28. package/dist/{fit-BKs0zxMI.js → fit-Cb7naFj6.js} +249 -167
  29. package/dist/fit-Cb7naFj6.js.map +1 -0
  30. package/dist/font-metrics-v5fn7s7e.d.ts +613 -0
  31. package/dist/font-metrics-v5fn7s7e.d.ts.map +1 -0
  32. package/dist/grid-DikTjFey.js +187 -0
  33. package/dist/grid-DikTjFey.js.map +1 -0
  34. package/dist/{html-dom-B0I0QSbU.js → html-dom-Iuog2wdK.js} +121 -91
  35. package/dist/html-dom-Iuog2wdK.js.map +1 -0
  36. package/dist/html.d.ts +6 -6
  37. package/dist/html.d.ts.map +1 -1
  38. package/dist/html.js +7 -6
  39. package/dist/html.js.map +1 -1
  40. package/dist/image-size-CvN2iCA4.js +1611 -0
  41. package/dist/image-size-CvN2iCA4.js.map +1 -0
  42. package/dist/index.d.ts +24 -10
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +31 -11
  45. package/dist/index.js.map +1 -1
  46. package/dist/inspect.d.ts +40 -31
  47. package/dist/inspect.d.ts.map +1 -1
  48. package/dist/inspect.js +40 -48
  49. package/dist/inspect.js.map +1 -1
  50. package/dist/math.d.ts +7 -5
  51. package/dist/math.d.ts.map +1 -1
  52. package/dist/math.js +16 -7
  53. package/dist/math.js.map +1 -1
  54. package/dist/measure.d.ts +12 -225
  55. package/dist/measure.d.ts.map +1 -1
  56. package/dist/measure.js +3 -3
  57. package/dist/node.d.ts +22 -8
  58. package/dist/node.d.ts.map +1 -1
  59. package/dist/node.js +27 -7
  60. package/dist/node.js.map +1 -1
  61. package/dist/notes-Dh12JAbn.js +3264 -0
  62. package/dist/notes-Dh12JAbn.js.map +1 -0
  63. package/dist/options-internal-WqgMf720.js +43 -0
  64. package/dist/options-internal-WqgMf720.js.map +1 -0
  65. package/dist/{package-CaOLXSNi.d.ts → package-J49Ck6S2.d.ts} +27 -12
  66. package/dist/package-J49Ck6S2.d.ts.map +1 -0
  67. package/dist/playbtn-CUqQdoDf.js +22 -0
  68. package/dist/playbtn-CUqQdoDf.js.map +1 -0
  69. package/dist/{slide-ZqAdVvHV.d.ts → pres-C6qgOFAa.d.ts} +1021 -1423
  70. package/dist/pres-C6qgOFAa.d.ts.map +1 -0
  71. package/dist/{presentation-DrJtj3-C.js → presentation-DGYlAWhl.js} +3208 -2727
  72. package/dist/presentation-DGYlAWhl.js.map +1 -0
  73. package/dist/{presentation-types-CDjScsev.d.ts → presentation-types-8jLQHI4M.d.ts} +1070 -683
  74. package/dist/presentation-types-8jLQHI4M.d.ts.map +1 -0
  75. package/dist/read.d.ts +9 -9
  76. package/dist/read.d.ts.map +1 -1
  77. package/dist/read.js +4 -4
  78. package/dist/script.d.ts +80 -66
  79. package/dist/script.d.ts.map +1 -1
  80. package/dist/script.js +2399 -1139
  81. package/dist/script.js.map +1 -1
  82. package/dist/{shapes-BemroU-z.js → shapes-re0KyQk_.js} +2807 -1609
  83. package/dist/shapes-re0KyQk_.js.map +1 -0
  84. package/dist/slide-Dx2ZeVZ6.d.ts +1256 -0
  85. package/dist/slide-Dx2ZeVZ6.d.ts.map +1 -0
  86. package/dist/slide-size-BalTjFuv.js +88 -0
  87. package/dist/slide-size-BalTjFuv.js.map +1 -0
  88. package/dist/{units-internal-Dg95w_YD.js → table-cell-margins-EVg0EwxT.js} +235 -273
  89. package/dist/table-cell-margins-EVg0EwxT.js.map +1 -0
  90. package/dist/table-dom-BTDP3lEP.js +21 -0
  91. package/dist/table-dom-BTDP3lEP.js.map +1 -0
  92. package/dist/text-anchor-CrYXAfUu.js +176 -0
  93. package/dist/text-anchor-CrYXAfUu.js.map +1 -0
  94. package/dist/text-sN64Otf_.js +3980 -0
  95. package/dist/text-sN64Otf_.js.map +1 -0
  96. package/dist/transition-speed-kUEWDWTx.js +14 -0
  97. package/dist/transition-speed-kUEWDWTx.js.map +1 -0
  98. package/dist/units-internal-XJlf-hOl.js +649 -0
  99. package/dist/units-internal-XJlf-hOl.js.map +1 -0
  100. package/dist/zip-C0leZZMj.d.ts +67 -0
  101. package/dist/zip-C0leZZMj.d.ts.map +1 -0
  102. package/dist/zip-CKd8ihcf.js +177 -0
  103. package/dist/zip-CKd8ihcf.js.map +1 -0
  104. package/dist/zip.d.ts +3 -68
  105. package/dist/zip.js +3 -163
  106. package/dist/zoom-DPUHh85d.js +8865 -0
  107. package/dist/zoom-DPUHh85d.js.map +1 -0
  108. package/package.json +106 -19
  109. package/skills/ts-pptx-upstream/SKILL.md +58 -57
  110. package/dist/autopage-DkjYZUFD.js +0 -423
  111. package/dist/autopage-DkjYZUFD.js.map +0 -1
  112. package/dist/diagnostics-DfBdDHre.d.ts +0 -829
  113. package/dist/diagnostics-DfBdDHre.d.ts.map +0 -1
  114. package/dist/errors-Bvln0H-7.js.map +0 -1
  115. package/dist/errors-PflMPWBn.d.ts +0 -182
  116. package/dist/errors-PflMPWBn.d.ts.map +0 -1
  117. package/dist/fit-BKs0zxMI.js.map +0 -1
  118. package/dist/html-dom-B0I0QSbU.js.map +0 -1
  119. package/dist/namespaces-BVnNu7sg.js +0 -666
  120. package/dist/namespaces-BVnNu7sg.js.map +0 -1
  121. package/dist/notes-CJFJaG6M.js +0 -1724
  122. package/dist/notes-CJFJaG6M.js.map +0 -1
  123. package/dist/package-CaOLXSNi.d.ts.map +0 -1
  124. package/dist/pres-BcBR3CSM.d.ts +0 -202
  125. package/dist/pres-BcBR3CSM.d.ts.map +0 -1
  126. package/dist/presentation-DrJtj3-C.js.map +0 -1
  127. package/dist/presentation-types-CDjScsev.d.ts.map +0 -1
  128. package/dist/shapes-BemroU-z.js.map +0 -1
  129. package/dist/slide-ZqAdVvHV.d.ts.map +0 -1
  130. package/dist/text-B8SLEaZx.js +0 -11327
  131. package/dist/text-B8SLEaZx.js.map +0 -1
  132. package/dist/units-1kRzYShk.js +0 -231
  133. package/dist/units-1kRzYShk.js.map +0 -1
  134. package/dist/units-internal-Dg95w_YD.js.map +0 -1
  135. package/dist/zip.d.ts.map +0 -1
  136. package/dist/zip.js.map +0 -1
package/dist/script.js CHANGED
@@ -1,139 +1,40 @@
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
- }
1
+ import { a as TsPptxError, c as setDiagnosticHandler, i as PackageReadError, n as InvalidOptionError, o as UnsupportedFeatureError, r as MediaError, s as resetDiagnosticState, t as InternalError } from "./errors-C4oENPcu.js";
2
+ import { F as EMU_PER_INCH, b as FIXED_PCT_PER_PERCENT, c as SchemeColor, t as TABLE_CELL_MARGIN_DEFAULTS_EMU, w as PERCENT_SCALE } from "./table-cell-margins-EVg0EwxT.js";
3
+ import { F as LINE_END_TYPES, I as PRESET_LINE_DASHES, U as TEXT_AUTONUM_SCHEMES, X as TEXT_VERTICAL, h as stripHash, i as HALIGN_BY_TEXT_ALIGN, in as OOXML_NS, o as LINE_CAP_BY_TOKEN, sn as bytesToBase64, tt as TRANSITION_TYPES, u as boolValue, w as DEFAULT_COLOR_MAP } from "./image-size-CvN2iCA4.js";
4
+ import { n as VALIGN_BY_ANCHOR, r as assetFilenameExtension } from "./text-anchor-CrYXAfUu.js";
5
+ import { n as BODY_INSET_DEFAULTS_IN, r as BODY_INSET_DEFAULTS_PT } from "./body-insets-Dw_BeWD_.js";
6
+ import { t as ABSENT_SLIDE_SIZE_EMU } from "./slide-size-BalTjFuv.js";
7
+ import { a as isConnector, c as isPicture, i as isAutoShape, o as isGraphicFrame, s as isGroupShape } from "./shapes-re0KyQk_.js";
69
8
  /**
70
- * Wrap a scope so every construct recorded through it is namespaced under
71
- * {@link LAYOUT_NOTE_PREFIX} — see there for why.
9
+ * Round a length already in inches to {@link INCH_DECIMALS}, which is EMU-exact on the way back.
72
10
  *
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.
11
+ * The one rounding: `inches` below and `pointsToInches` in the mapper each carried it, and the
12
+ * second spelled the divisor as a bare `72`.
77
13
  */
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";
14
+ function toInches(value) {
15
+ return Number(value.toFixed(6));
93
16
  }
94
17
  /**
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.
18
+ * Geometry as inches, for the options that reject a raw-EMU string. Rounded to
19
+ * {@link INCH_DECIMALS}, which is EMU-exact on the way back.
105
20
  */
106
- function asIrValue(value) {
107
- return value;
21
+ function inches(emuValue) {
22
+ return toInches(emuValue / EMU_PER_INCH);
108
23
  }
109
24
  //#endregion
110
25
  //#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
26
  /** Geometry as a `Coord` the write API takes verbatim, preserving the exact EMU. */
119
27
  function emu(value) {
120
28
  return `${Math.round(value)}emu`;
121
29
  }
122
30
  /**
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
31
  * Points → inches, at the same proven precision as {@link inches}. Needed because the read
131
32
  * model reports text-body insets in points while the write API's `margin` takes inches —
132
33
  * and warns on a value `>= 1`, on the assumption that it is a points value passed by
133
34
  * mistake, so handing it points would both mis-scale the text and produce a warning.
134
35
  */
135
36
  function pointsToInches(pt) {
136
- return Number((pt / 72).toFixed(INCH_DECIMALS));
37
+ return toInches(pt / 72);
137
38
  }
138
39
  /**
139
40
  * A colour for a write-API `color` field. The read model splits a colour into a raw
@@ -141,30 +42,117 @@ function pointsToInches(pt) {
141
42
  * re-resolves against the destination theme and so keeps the deck recolourable, which is
142
43
  * the whole reason a theme colour was authored.
143
44
  *
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
- ]);
45
+ * Only the tokens the write path's `clrMap` covers survive as tokens — the other seven
46
+ * `ST_SchemeColorVal` values degrade to a hex literal there anyway, so passing them through
47
+ * would produce a silently different colour. Callers hand those to {@link literalColor}
48
+ * instead after recording a note.
49
+ *
50
+ * Derived from `SchemeColor` rather than transcribed from it. A hand-written copy of the ten
51
+ * meant that a member added to the enum would keep being treated as unwritable here: baked to
52
+ * a hex literal under an "approximated" note that had stopped being true, which is worse than
53
+ * a loud failure. The write side already asks the same question of the same enum
54
+ * (`gen/define/chart.ts`).
55
+ */
56
+ const WRITABLE_SCHEME_TOKENS = new Set(Object.values(SchemeColor));
161
57
  /** `true` when a `schemeClr` token survives as a token rather than degrading to hex. */
162
58
  function isWritableSchemeToken(token) {
163
59
  return token !== null && WRITABLE_SCHEME_TOKENS.has(token);
164
60
  }
61
+ /**
62
+ * A source's `schemeClr` token as the write API's `color`, baked to a literal hex (with a
63
+ * note) when the token is one of the seven the write path cannot carry, or `undefined` when
64
+ * the source names no token at all.
65
+ *
66
+ * `undefined` means "this leg has nothing to say" — the caller's own-colour ladder decides
67
+ * from there. Seven sites make this decision and four of them made it correctly: the table
68
+ * fill, the cell fill and the cell border all returned an unwritable token *raw*, so a cell
69
+ * filled `<a:schemeClr val="dk1"/>` emitted `fill: { color: 'dk1' }` and the generated script
70
+ * warned and painted the cell the default text colour, with no note; the gradient stop baked
71
+ * without recording one.
72
+ *
73
+ * @param scheme - the raw `a:schemeClr/@val`, or `null` when the source names none
74
+ * @param resolvedHex - the literal the token resolves to, for the baking leg
75
+ * @param notes - the scope the approximation is recorded on
76
+ * @param construct - the note's construct id, e.g. `table.cell.fill.schemeToken`
77
+ * @param label - how the note's prose names the thing, e.g. `cell fill`
78
+ */
79
+ function schemeColorOption(scheme, resolvedHex, notes, construct, label) {
80
+ if (isWritableSchemeToken(scheme)) return scheme;
81
+ if (scheme === null) return void 0;
82
+ notes.note(construct, "approximated", "unwritable", `${label} 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`);
83
+ return resolvedHex === null ? void 0 : literalColor(resolvedHex);
84
+ }
85
+ /**
86
+ * One colour, as the read model splits it, resolved into the write API's single `color` field.
87
+ *
88
+ * The ladder, in order:
89
+ *
90
+ * 1. a **writable scheme token** — kept as a token, so the copy keeps tracking its theme;
91
+ * 2. the surface's **own literal**, where the read model separates it from the resolved one;
92
+ * 3. an **unwritable token**, noted and baked to the literal it resolves to;
93
+ * 4. the **resolved literal**, for the colour models the write API's hex option cannot name;
94
+ * 5. `undefined` — nothing to say, and the caller's own arm decides from there.
95
+ *
96
+ * Steps 2 and 3 cannot both apply: `a:solidFill` holds exactly one colour child, so a surface
97
+ * has either an `a:srgbClr` or an `a:schemeClr` and never both. That is what makes one order
98
+ * safe for all six sites, which is worth stating because the six had three orders between them
99
+ * — one put the own literal first, one never consulted it at all, and one tested the token for
100
+ * `null` before calling. {@link schemeColorOption} extracted the middle step and the ladder
101
+ * around it stayed copied.
102
+ *
103
+ * @param color - the three spellings the read model reports
104
+ * @param notes - the scope an unwritable token is noted on
105
+ * @param construct - the note's construct id, e.g. `table.cell.fill.schemeToken`
106
+ * @param label - how the note's prose names the thing, e.g. `cell fill`
107
+ */
108
+ function colorOption(color, notes, construct, label) {
109
+ if (isWritableSchemeToken(color.scheme)) return color.scheme;
110
+ if (color.ownHex != null) return literalColor(color.ownHex);
111
+ return schemeColorOption(color.scheme, color.resolvedHex, notes, construct, label) ?? (color.resolvedHex === null ? void 0 : literalColor(color.resolvedHex));
112
+ }
113
+ /**
114
+ * `a:bodyPr/@anchor` and `a:tcPr/@anchor` -> the write API's `valign`.
115
+ *
116
+ * Derived from the same table `resolveTextAnchor` reads, rather than written out again here:
117
+ * this side cannot import `gen/`, so the two directions had no way to stay in step.
118
+ */
119
+ const ANCHOR_TO_VALIGN = VALIGN_BY_ANCHOR;
120
+ /**
121
+ * The `a:prstDash` values the write API's `dashType` can spell.
122
+ *
123
+ * A dash outside `ST_PresetLineDashVal` cannot have come from a conformant deck; both mappers
124
+ * that read one drop it to a plain dashed rule and note the loss, and both built this set.
125
+ */
126
+ const WRITABLE_DASHES = new Set(PRESET_LINE_DASHES);
127
+ /**
128
+ * A shape's or frame's Selection Pane name as the IR's `sourceName`, or nothing when it has none.
129
+ *
130
+ * `sourceName` is how a call in the emitted script says which shape it came from, so it is
131
+ * omitted rather than written empty. Three mappers spelled the ternary; this is it.
132
+ */
133
+ function nameOf(subject) {
134
+ return subject.name ? { sourceName: subject.name } : {};
135
+ }
136
+ /**
137
+ * The write options that carry a source object's identity onto the object a call authors: its
138
+ * Selection Pane name and its alt text.
139
+ *
140
+ * Seven call builders spelled `objectName: shape.name || undefined` beside {@link nameOf}, so what a
141
+ * source's identity becomes on the write side was a decision made seven times over, and none of the
142
+ * seven made it for the description: `p:cNvPr/@descr` was read and never emitted, so every picture's
143
+ * and text box's alt text was lost with no note. An empty description is no description, which is
144
+ * how the write side spells one it was not given.
145
+ * @param subject - the source shape or graphic frame
146
+ */
147
+ function identityOptions(subject) {
148
+ return {
149
+ objectName: subject.name || void 0,
150
+ altText: subject.description || void 0
151
+ };
152
+ }
165
153
  /** Normalize a hex to the bare 6-digit uppercase form the write API expects. */
166
154
  function literalColor(hex) {
167
- return hex.replace(/^#/, "").toUpperCase();
155
+ return stripHash(hex).toUpperCase();
168
156
  }
169
157
  /**
170
158
  * Build an IR object from possibly-absent parts, dropping every `undefined`.
@@ -198,25 +186,69 @@ function orUndefined(value) {
198
186
  return value === null ? void 0 : value;
199
187
  }
200
188
  /**
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.
189
+ * A 0-1 fraction the read model took off a fixed-point percentage, as the write API's 0-100
190
+ * percent.
191
+ *
192
+ * `parsePercent` divided the source's thousandths of a percent by `PERCENT_SCALE`; this undoes
193
+ * exactly that, so the integer the writer re-derives (`Math.round(percent × 1000)`) is the one
194
+ * the source held. A stop at `pos="33333"` is `33.333`, not `33`: whole-percent rounding moved
195
+ * it by up to half a percent and the re-read deck agreed with the rounded value, so the round
196
+ * trip could not see it.
197
+ */
198
+ function fractionToPercent(fraction) {
199
+ return Math.round(fraction * PERCENT_SCALE) / FIXED_PCT_PER_PERCENT;
200
+ }
201
+ /**
202
+ * A 0-1 opacity as the write API's 0-100 transparency, at the source's own precision;
203
+ * `undefined` when unset **or fully opaque**.
204
+ *
205
+ * Fully opaque is not `0`, and that is this function's rule rather than each caller's:
206
+ * emitting `transparency: 0` for an `a:alphaModFix amt="100000"` produces a key the output
207
+ * cannot have, because the write path emits no `a:alphaModFix` for a zero transparency, so
208
+ * the re-read deck reports no alpha and the round trip sees a difference where there is no
209
+ * visible one. One of five callers implemented that; the other four passed the value straight
210
+ * through, and it stayed invisible only because the canonicaliser drops `transparency: 0` as
211
+ * an implied default.
208
212
  */
209
213
  function alphaToTransparency(alpha) {
210
214
  if (alpha === void 0 || alpha === null) return void 0;
211
- return Math.round((1 - alpha) * 100);
215
+ const transparency = fractionToPercent(1 - alpha);
216
+ return transparency > 0 ? transparency : void 0;
212
217
  }
213
218
  /**
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.
219
+ * Where a shape sits on the slide, or `null` when nothing places it — in which case a
220
+ * `shape.frameUnresolved` note has been recorded and the caller drops the shape.
221
+ *
222
+ * `absoluteFrame` rather than the raw `left`/`top`: a shape inside a group is positioned in
223
+ * its group's child coordinate space, which a flattened call list cannot express, so the
224
+ * group transform has to be composed in here. For a top-level shape the two are identical.
225
+ *
226
+ * Neither frame resolves for a shape with no `a:xfrm` of its own that is not a placeholder, or a
227
+ * placeholder whose layout and master define no geometry either. The mappers used to part ways
228
+ * there: a connector returned `null` with no note, breaking the rule that a dropped shape is
229
+ * never silent, and every other kind was emitted with no `x/y/w/h`, which the writer places at
230
+ * its default position. Neither is where the source put it, and the second looks deliberate, so
231
+ * every kind now drops the shape and says so.
232
+ *
233
+ * Takes the `Shape` base rather than `AnyShape` so a `p:graphicFrame` — a table or a chart —
234
+ * gets the same fallback and the same note. It had its own `positionOfFrame`, which returned
235
+ * `{}` in exactly the case this fallback exists for, with no note: `absoluteFrame` is `null`
236
+ * whenever an enclosing group lacks a usable transform, so a table inside such a group emitted
237
+ * `addTable(rows, { objectName, tableStyle })` with no geometry at all.
216
238
  */
217
- function positionOfFrame(frame) {
218
- const box = frame.absoluteFrame;
219
- if (!box) return {};
239
+ function frameOf(shape, notes) {
240
+ const frame = shape.absoluteFrame;
241
+ if (frame) return frame;
242
+ const resolved = shape.resolvedFrame;
243
+ if (!resolved) {
244
+ notes.note("shape.frameUnresolved", "dropped", "unread", "this shape has no transform of its own (a:xfrm) and no placeholder geometry to inherit, so nothing places it; it is omitted rather than emitted at the default position");
245
+ return null;
246
+ }
247
+ notes.note("shape.frameInherited", "flattened", "unsupported", resolved.source === "own" ? "this shape sits in a group whose transform could not be composed, so its own position is baked in as a slide-absolute one" : `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}`);
248
+ return resolved;
249
+ }
250
+ /** A box from {@link frameOf} as `Coord`-typed EMU strings, so the source geometry survives exactly. */
251
+ function positionOptions(box) {
220
252
  return {
221
253
  x: emu(box.left),
222
254
  y: emu(box.top),
@@ -225,7 +257,107 @@ function positionOfFrame(frame) {
225
257
  };
226
258
  }
227
259
  //#endregion
260
+ //#region src/script/from-read/picture-fill.ts
261
+ /**
262
+ * A picture fill as `ShapeFillProps`, or `undefined` when its bytes cannot be carried —
263
+ * in which case a `dropped` note has been recorded and the surface comes out unfilled.
264
+ */
265
+ function pictureFillOption(picture, assets, notes, subject) {
266
+ const asset = pictureAsset(picture, assets, notes, subject);
267
+ if (!asset) return void 0;
268
+ const crop = cropOption(picture.srcRect);
269
+ noteUncarriedGeometry(picture, notes, subject, crop !== void 0);
270
+ return compactRequired({
271
+ type: "image",
272
+ image: compactRequired({
273
+ crop,
274
+ data: asset
275
+ }),
276
+ transparency: alphaToTransparency(picture.alpha)
277
+ });
278
+ }
279
+ /** The fill's image as an asset, or `null` with a note saying why it could not be one. */
280
+ function pictureAsset(picture, assets, notes, subject) {
281
+ const partName = picture.partName;
282
+ if (partName === null) {
283
+ 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`);
284
+ return null;
285
+ }
286
+ if (assets.contentTypeOf(partName) === "image/svg+xml") {
287
+ 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`);
288
+ return null;
289
+ }
290
+ const asset = assets.assetFor(partName);
291
+ if (!asset) {
292
+ 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`);
293
+ return null;
294
+ }
295
+ return asset;
296
+ }
297
+ /** `true` when a rect actually insets an edge; an explicit but empty rect reports zeros. */
298
+ function isRectSet(rect) {
299
+ return rect !== null && (rect.left !== 0 || rect.top !== 0 || rect.right !== 0 || rect.bottom !== 0);
300
+ }
301
+ /**
302
+ * The source crop as the write API's `crop`, or `undefined` when this rect cannot be one — in which
303
+ * case the caller notes it as uncarried instead. A picture fill's `image.crop` and a picture's own
304
+ * `crop` take the same percentage insets, so both go through here.
305
+ *
306
+ * An all-zero rect is `undefined` rather than four zeros: `<a:srcRect/>` is what the write
307
+ * path emits with no `crop` at all, so carrying zeros would add an option that changes
308
+ * nothing and take the fill off its byte-identical path for no reason.
309
+ *
310
+ * Two shapes of rect are legal in a source and not expressible as `crop`, and both belong
311
+ * to the write path rather than to this mapper (`gen/drawingml/src-rect.ts` throws on
312
+ * each): a **negative** inset, which is how a `contain`-style fill bleeds the source past
313
+ * the surface, and a pair of opposite insets summing to 100% or more, which leaves no
314
+ * source area. Carrying either would emit a script that throws when it is run, so they
315
+ * stay uncarried and keep their note.
316
+ */
317
+ function cropOption(srcRect) {
318
+ if (!isRectSet(srcRect) || srcRect === null) return void 0;
319
+ const edges = {
320
+ b: fractionToPercent(srcRect.bottom),
321
+ l: fractionToPercent(srcRect.left),
322
+ r: fractionToPercent(srcRect.right),
323
+ t: fractionToPercent(srcRect.top)
324
+ };
325
+ if (Object.values(edges).some((v) => v < 0 || v > 100)) return void 0;
326
+ if (edges.l + edges.r >= 100 || edges.t + edges.b >= 100) return void 0;
327
+ return compact({
328
+ b: edges.b || void 0,
329
+ l: edges.l || void 0,
330
+ r: edges.r || void 0,
331
+ t: edges.t || void 0
332
+ });
333
+ }
334
+ /**
335
+ * Note the parts of the fill's geometry the write path's fixed stretch cannot express.
336
+ *
337
+ * Only the ones the source actually uses: a note on every picture fill would say nothing,
338
+ * and the round-trip check reads an unmatched note as a candidate for review. `cropCarried`
339
+ * is the one that is conditional on more than the source — the crop is expressible as
340
+ * `image.crop` unless its insets fall outside what that option accepts, so whether it is
341
+ * lost is the caller's answer to give.
342
+ */
343
+ function noteUncarriedGeometry(picture, notes, subject, cropCarried) {
344
+ const lost = [];
345
+ if (picture.mode === "tile" || picture.tile !== null) lost.push("tiling (a:tile)");
346
+ if (isRectSet(picture.srcRect) && !cropCarried) lost.push("the source crop (a:srcRect), whose insets fall outside the 0–100% `image.crop` accepts");
347
+ if (isRectSet(picture.fillRect)) lost.push("the destination inset (a:stretch/a:fillRect)");
348
+ if (picture.dpi !== null && picture.dpi !== 0) lost.push(`the render resolution (@dpi="${picture.dpi}")`);
349
+ if (picture.rotWithShape === false) lost.push("not rotating with the shape (@rotWithShape=\"0\")");
350
+ if (lost.length === 0) return;
351
+ 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 stretched blip at a fixed dpi="0" rotWithShape="1", with only its <a:srcRect> under the caller's control, so ${lost.join(", ")} ${lost.length === 1 ? "does" : "do"} not carry`);
352
+ }
353
+ //#endregion
228
354
  //#region src/script/from-read/surface-fill.ts
355
+ /** How a note's prose names a gradient stop's surface. */
356
+ function stopLabel(where) {
357
+ if (where === "line") return "line gradient stop";
358
+ if (where === "slide.background" || where === "master.background") return "background gradient stop";
359
+ return "fill gradient stop";
360
+ }
229
361
  /**
230
362
  * A read gradient as `GradientFillProps`. Stop positions convert from the read model's
231
363
  * 0–1 fraction to the write API's 0–100 percentage; the angle needs no conversion, since
@@ -233,12 +365,15 @@ function positionOfFrame(frame) {
233
365
  */
234
366
  function gradientStops(gradient, notes, where) {
235
367
  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;
368
+ const color = colorOption({
369
+ scheme: stop.colorRef.scheme,
370
+ resolvedHex: stop.colorRef.resolved?.effectiveHex ?? null
371
+ }, notes, `${where}.gradient.schemeToken`, stopLabel(where));
372
+ if (color === void 0) return null;
238
373
  return compact({
239
374
  color,
240
- position: Math.round((stop.position ?? 0) * 100),
241
- transparency: alphaToTransparency(stop.alpha)
375
+ position: fractionToPercent(stop.position ?? 0),
376
+ transparency: alphaToTransparency(stop.colorRef.resolved?.alpha)
242
377
  });
243
378
  }).filter((stop) => stop !== null);
244
379
  if (stops.length < 2) {
@@ -263,74 +398,952 @@ function gradientStops(gradient, notes, where) {
263
398
  * names no preset — `a:pattFill/@prst` is what selects the hatch, so without it there is
264
399
  * nothing to reproduce.
265
400
  *
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.
401
+ * Each colour takes the ladder every other colour does. `PatternFillProps.fgColor`/`bgColor` are
402
+ * `Color`, and the pattern emitter writes a token as `a:schemeClr`, so a writable token stays a
403
+ * token. Both used to be baked to hex, and a theme-coloured hatch could not be told from a literal
404
+ * one, so the copy stopped tracking its theme with nothing to say so.
268
405
  */
269
- function patternOption(pattern) {
406
+ function patternOption(pattern, notes, where) {
270
407
  if (!pattern?.preset) return void 0;
408
+ const color = (scheme, resolved, label) => colorOption({
409
+ scheme,
410
+ resolvedHex: resolved?.effectiveHex ?? null
411
+ }, notes, `${where}.pattern.schemeToken`, label);
271
412
  return {
272
413
  type: "pattern",
273
414
  pattern: compact({
274
415
  preset: pattern.preset,
275
- fgColor: pattern.foreground ? literalColor(pattern.foreground.effectiveHex) : void 0,
276
- bgColor: pattern.background ? literalColor(pattern.background.effectiveHex) : void 0
416
+ fgColor: color(pattern.foreground.scheme, pattern.foreground.resolved, "pattern foreground"),
417
+ bgColor: color(pattern.background.scheme, pattern.background.resolved, "pattern background")
277
418
  }) ?? { preset: pattern.preset }
278
419
  };
279
420
  }
280
- //#endregion
281
- //#region src/script/from-read/picture-fill.ts
282
421
  /**
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.
422
+ * The three surfaces a fill can sit on, and how a note names each. One record rather than three
423
+ * constants in three modules, because every field of it is decided by `where`.
285
424
  */
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)
425
+ const FILL_SURFACES = {
426
+ fill: {
427
+ label: "fill",
428
+ picture: {
429
+ construct: "fill.picture",
430
+ subject: "this shape's surface",
431
+ element: "a:blipFill"
432
+ }
433
+ },
434
+ "table.fill": {
435
+ label: "table fill",
436
+ picture: {
437
+ construct: "table.fill.picture",
438
+ subject: "this table",
439
+ element: "a:tblPr/a:blipFill"
440
+ }
441
+ },
442
+ "table.cell.fill": {
443
+ label: "cell fill",
444
+ picture: {
445
+ construct: "table.cell.fill.picture",
446
+ subject: "this table cell",
447
+ element: "a:tcPr/a:blipFill"
448
+ }
449
+ }
450
+ };
451
+ /**
452
+ * A surface's `EG_FillProperties` as the write API's fill option, or `undefined` when the
453
+ * surface states none.
454
+ *
455
+ * The order is the whole of it, and it is not arbitrary: an explicit `a:noFill` is a statement
456
+ * and comes first; then the non-solid members, because a surface carrying one holds no
457
+ * `a:solidFill` for the colour legs to find and `resolvedFill` would answer with a style's
458
+ * banding colour the source never showed; then the colour ladder — a writable scheme token, the
459
+ * surface's own literal, the resolved literal.
460
+ *
461
+ * Three copies of this walked three subjects that expose the same accessor names, and they had
462
+ * drifted: only the shape's read `alphaModFix`, so a table or cell whose `a:solidFill` carried
463
+ * one lost its transparency with nothing declaring the loss.
464
+ *
465
+ * @param subject - the shape, table or cell
466
+ * @param ctx - the mapping context, for notes and the asset resolver
467
+ * @param where - which surface this is, deciding the note constructs and prose
468
+ * @param opts - `styled` short-circuits a styled surface with no fill of its own
469
+ */
470
+ function surfaceFill(subject, ctx, where, opts = {}) {
471
+ const { notes, assets } = ctx;
472
+ const surface = FILL_SURFACES[where];
473
+ if (subject.fillNoFill) return { type: "none" };
474
+ const gradient = subject.gradientFill;
475
+ if (gradient) {
476
+ const stops = gradientStops(gradient, notes, where);
477
+ if (stops) return {
478
+ type: "gradient",
479
+ gradient: stops
480
+ };
481
+ }
482
+ const pattern = patternOption(subject.patternFill, notes, where);
483
+ if (pattern) return pattern;
484
+ const picture = subject.pictureFill;
485
+ if (picture) return pictureFillOption(picture, assets, notes, surface.picture);
486
+ const scheme = subject.fillSchemeColor;
487
+ if (isWritableSchemeToken(scheme)) return { color: scheme };
488
+ if (subject.fillColor != null) return { color: literalColor(subject.fillColor) };
489
+ if (opts.styled && subject.hasOwnFill === false) return void 0;
490
+ const resolved = subject.resolvedFill;
491
+ if (!resolved) return void 0;
492
+ return compact({
493
+ color: colorOption({
494
+ scheme,
495
+ resolvedHex: resolved.effectiveHex
496
+ }, notes, `${where}.schemeToken`, surface.label),
497
+ transparency: alphaToTransparency(resolved.alpha)
294
498
  });
295
499
  }
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;
500
+ //#endregion
501
+ //#region src/script/from-read/background.ts
502
+ /** The slide tier: a slide's own `p:bg`, which a slide falls back from to its layout's. */
503
+ const SLIDE_BACKGROUND = {
504
+ construct: "slide.background",
505
+ subject: "this slide's background",
506
+ fallback: "the slide takes its layout's background"
507
+ };
508
+ /** The layout/master tier, which has nothing below it to inherit from. */
509
+ const MASTER_BACKGROUND = {
510
+ construct: "master.background",
511
+ subject: "this layout's background",
512
+ fallback: "the layout is emitted with no background"
513
+ };
514
+ /**
515
+ * Map one tier's background onto the write API's `background` option.
516
+ *
517
+ * `undefined` means *say nothing* — the caller omits the key, and the slide or layout takes
518
+ * whatever it would have inherited. That is the right answer for an absent background and for
519
+ * an explicit `a:noFill`, and it is also what a recorded loss falls back to.
520
+ * @param background - the background the read model resolved, if any
521
+ * @param notes - where a loss this mapper cannot avoid is recorded
522
+ * @param assets - how a picture background's part becomes bytes the script can carry
523
+ * @param tier - which tier is asking, for the note it records
524
+ * @returns the IR background, or `undefined`
525
+ */
526
+ function backgroundIr(background, notes, assets, tier) {
527
+ if (!background) return void 0;
528
+ if (background.type !== "themeRef") return fillIr(background, notes, assets, tier);
529
+ const fill = background.resolvedFill;
530
+ const ir = fill ? fillIr(fill, notes, assets, tier) : void 0;
531
+ if (fill && ir) {
532
+ notes.note(tier.construct, "flattened", "unwritable", `${tier.subject} is a theme reference (p:bgRef into the theme's background fill list), which has no write-API option; the ${fill.type === "solid" ? "colour" : fill.type} it currently resolves to is baked in and stops following the theme`);
533
+ return ir;
534
+ }
535
+ notes.note(tier.construct, "dropped", "unwritable", `${tier.subject} is a theme reference whose fill does not resolve to one the write API can express, and a reference itself has no write-API option; ${tier.fallback}`);
536
+ }
537
+ /** An explicit background fill, or `undefined` for none or for one with nothing to carry. */
538
+ function fillIr(fill, notes, assets, tier) {
539
+ switch (fill.type) {
540
+ case "none": return;
541
+ case "solid": return solidIr(fill.colorRef, notes, tier);
542
+ case "image": {
543
+ const asset = fill.partName === null ? null : assets.assetFor(fill.partName);
544
+ return asset ? { data: asset } : void 0;
545
+ }
546
+ case "gradient": {
547
+ const gradient = gradientStops(fill.gradient, notes, tier.construct);
548
+ return gradient ? {
549
+ type: "gradient",
550
+ gradient
551
+ } : void 0;
552
+ }
553
+ case "pattern": {
554
+ const pattern = patternOption(fill, notes, tier.construct);
555
+ return pattern ? pattern : void 0;
556
+ }
557
+ }
558
+ }
559
+ /**
560
+ * A solid colour, with the transparency its `a:alpha` states.
561
+ *
562
+ * Through the shared colour ladder, not the resolved hex alone. This read the resolved colour
563
+ * and only the resolved colour, so a background painted with a theme token came out as the
564
+ * literal that token happened to resolve to and stopped following the theme -- silently, where
565
+ * every other surface that bakes a token says so. A writable token is kept as a token; an
566
+ * unwritable one is baked and noted, under this tier's own `…schemeToken` construct.
567
+ */
568
+ function solidIr(colorRef, notes, tier) {
569
+ const resolved = colorRef.resolved;
570
+ const color = colorOption({
571
+ scheme: colorRef.scheme,
572
+ ownHex: colorRef.srgb,
573
+ resolvedHex: resolved?.effectiveHex ?? null
574
+ }, notes, `${tier.construct}.schemeToken`, "background");
575
+ if (color === void 0) return void 0;
576
+ const transparency = alphaToTransparency(resolved?.alpha);
577
+ return transparency === void 0 ? { color } : {
578
+ color,
579
+ transparency
580
+ };
581
+ }
582
+ //#endregion
583
+ //#region src/script/fidelity.ts
584
+ /** A loss both printers have. */
585
+ const BOTH_TIERS = ["template", "standalone"];
586
+ /**
587
+ * A loss only the standalone printer has, because the template-anchored one keeps the construct in
588
+ * its template.
589
+ *
590
+ * Document properties are the plain case: the read half notes the ones with no write-API setter,
591
+ * which matters for a script that rebuilds the deck, but the template-anchored tier never authors
592
+ * them. They ride in the template, every one. The chrome is the same story on
593
+ * a larger scale: the theme's format scheme, the master's text styles and colour map, the layouts'
594
+ * decoration, names and placeholder definitions are genuine losses for a script that rebuilds the
595
+ * design, and untouched by one whose template *is* the design. A caveat that does not apply to the
596
+ * output in front of you is worse than none: it teaches the reader to skim the ones that do.
597
+ */
598
+ const STANDALONE_ONLY = ["standalone"];
599
+ /**
600
+ * Option names each note construct is a promise about.
601
+ *
602
+ * This table is the fidelity catalogue made mechanical. A note claims a construct will not
603
+ * survive; without a mapping from that claim to the fields it covers, the claim cannot
604
+ * exclude anything and the round trip degenerates into a snapshot.
605
+ *
606
+ * Each entry is a dotted option PATH, matched one of two ways depending on the note that names it:
607
+ *
608
+ * - **A note scoped to a shape** is already confined to that shape's call, so its entries are
609
+ * matched as a SUFFIX of where the difference sits inside it: a bare `fill` means "the fill
610
+ * option wherever it appears in the call", and `line.width` means that width and not a table
611
+ * cell border's. Write the bare form where the word is unambiguous and qualify it the moment
612
+ * the same word names two different things. Matching the terminal key alone is how `type`,
613
+ * written about a fill's solid default, came to excuse a character bullet that returned as a
614
+ * numbered list, and how a bare `color` in a run-colour note excused the shape's fill and
615
+ * outline colours, and in a cell note every colour in the table.
616
+ * - **A note with no shape** (a slide or deck loss) has nothing confining it to one call, so its
617
+ * entries are anchored at the root of the diff. Each names one node by its full path
618
+ * (`background`, `chrome.masters.title`) and covers that node only, and a trailing `.*` extends
619
+ * it to everything beneath (`transition.sound.*`). As suffixes they matched inside every call in
620
+ * scope, and a deck's `master.background` excused every run colour and every lost image in the
621
+ * deck.
622
+ *
623
+ * `'*'` covers every difference inside the note's scope — correct only where the note says the
624
+ * whole shape or slide is gone or was copied wholesale, never as a shortcut for a construct
625
+ * whose fields are merely tedious to enumerate.
626
+ *
627
+ * An empty list is meaningful and common: the construct is invisible to the IR on both
628
+ * sides (a slide's build animations, a paragraph's `a:pPr/@marL`, a connector's shape
629
+ * binding), so the note is a caveat for a human and there is nothing here to exclude. Saying
630
+ * so explicitly is what keeps it from looking like an oversight. A construct can leave that
631
+ * state — `slide.transition` was an empty entry until the converter learned to transcribe
632
+ * transitions, and the IR gaining a field is exactly when its note gains a mapping.
633
+ *
634
+ * Each entry also names the printers whose output the note describes (`tiers`), and each printer
635
+ * reports only the notes that apply to it. Two hand-kept sets used to decide that, one per printer,
636
+ * so a new chrome construct was reported by the template-anchored tier unless someone remembered
637
+ * to list it. An entry without `tiers` no longer satisfies the catalogue's type.
638
+ */
639
+ const NOTE_CONSTRUCTS = {
640
+ "chart.blanks": {
641
+ fields: ["values"],
642
+ tiers: BOTH_TIERS
643
+ },
644
+ "chart.combo": {
645
+ fields: ["type"],
646
+ tiers: BOTH_TIERS
647
+ },
648
+ "chart.data": {
649
+ fields: ["*"],
650
+ tiers: BOTH_TIERS
651
+ },
652
+ "chart.labels": {
653
+ fields: [],
654
+ tiers: BOTH_TIERS
655
+ },
656
+ "chart.type": {
657
+ fields: ["type"],
658
+ tiers: BOTH_TIERS
659
+ },
660
+ "chart.type3D": {
661
+ fields: [],
662
+ tiers: BOTH_TIERS
663
+ },
664
+ "chart.xValues": {
665
+ fields: [],
666
+ tiers: BOTH_TIERS
667
+ },
668
+ "chart.xLabels": {
669
+ fields: [],
670
+ tiers: BOTH_TIERS
671
+ },
672
+ "chart.workbook": {
673
+ fields: ["*"],
674
+ tiers: BOTH_TIERS
675
+ },
676
+ "chartEx.all": {
677
+ fields: ["*"],
678
+ tiers: BOTH_TIERS
679
+ },
680
+ "connector.binding": {
681
+ fields: [],
682
+ tiers: BOTH_TIERS
683
+ },
684
+ "connector.line": {
685
+ fields: [
686
+ "color",
687
+ "width",
688
+ "dashType",
689
+ "beginArrowType",
690
+ "endArrowType"
691
+ ],
692
+ tiers: BOTH_TIERS
693
+ },
694
+ "connector.rotation": {
695
+ fields: ["rotate"],
696
+ tiers: BOTH_TIERS
697
+ },
698
+ "deck.docProps": {
699
+ fields: [],
700
+ tiers: STANDALONE_ONLY
701
+ },
702
+ "deck.docPropsDefault": {
703
+ fields: [],
704
+ tiers: STANDALONE_ONLY
705
+ },
706
+ "deck.slideSize": {
707
+ fields: ["slideSize.*"],
708
+ tiers: BOTH_TIERS
709
+ },
710
+ "diagram.all": {
711
+ fields: ["*"],
712
+ tiers: BOTH_TIERS
713
+ },
714
+ "fill.gradient": {
715
+ fields: ["gradient", "fill"],
716
+ tiers: BOTH_TIERS
717
+ },
718
+ "fill.gradient.path": {
719
+ fields: ["gradient", "fill"],
720
+ tiers: BOTH_TIERS
721
+ },
722
+ "line.gradient": {
723
+ fields: ["gradient", "line"],
724
+ tiers: BOTH_TIERS
725
+ },
726
+ "line.gradient.path": {
727
+ fields: ["gradient", "line"],
728
+ tiers: BOTH_TIERS
729
+ },
730
+ "line.gradient.schemeToken": {
731
+ fields: ["gradient", "line"],
732
+ tiers: BOTH_TIERS
733
+ },
734
+ "fill.picture": {
735
+ fields: ["fill"],
736
+ tiers: BOTH_TIERS
737
+ },
738
+ "fill.picture.geometry": {
739
+ fields: [],
740
+ tiers: BOTH_TIERS
741
+ },
742
+ "fill.gradient.schemeToken": {
743
+ fields: ["gradient", "fill"],
744
+ tiers: BOTH_TIERS
745
+ },
746
+ "fill.pattern.schemeToken": {
747
+ fields: ["fill.pattern.fgColor", "fill.pattern.bgColor"],
748
+ tiers: BOTH_TIERS
749
+ },
750
+ "fill.schemeToken": {
751
+ fields: ["fill", "fill.color"],
752
+ tiers: BOTH_TIERS
753
+ },
754
+ "glow.schemeToken": {
755
+ fields: ["glow.color"],
756
+ tiers: BOTH_TIERS
757
+ },
758
+ "graphicFrame.unknown": {
759
+ fields: ["*"],
760
+ tiers: BOTH_TIERS
761
+ },
762
+ "group.child": {
763
+ fields: ["*"],
764
+ tiers: BOTH_TIERS
765
+ },
766
+ "group.childSpace": {
767
+ fields: [
768
+ "x",
769
+ "y",
770
+ "w",
771
+ "h",
772
+ "rotate",
773
+ "flipH",
774
+ "flipV"
775
+ ],
776
+ tiers: BOTH_TIERS
777
+ },
778
+ "group.empty": {
779
+ fields: ["*"],
780
+ tiers: BOTH_TIERS
781
+ },
782
+ "group.transform": {
783
+ fields: [
784
+ "rotate",
785
+ "flipH",
786
+ "flipV"
787
+ ],
788
+ tiers: BOTH_TIERS
789
+ },
790
+ "image.crop": {
791
+ fields: ["crop"],
792
+ tiers: BOTH_TIERS
793
+ },
794
+ "image.data": {
795
+ fields: ["data", "$asset"],
796
+ tiers: BOTH_TIERS
797
+ },
798
+ "image.recolor": {
799
+ fields: [
800
+ "duotone",
801
+ "grayscale",
802
+ "biLevel",
803
+ "clrChange"
804
+ ],
805
+ tiers: BOTH_TIERS
806
+ },
807
+ "image.svg": {
808
+ fields: [
809
+ "svg",
810
+ "data",
811
+ "$asset"
812
+ ],
813
+ tiers: BOTH_TIERS
814
+ },
815
+ "line.arrowSize": {
816
+ fields: ["beginArrowType", "endArrowType"],
817
+ tiers: BOTH_TIERS
818
+ },
819
+ "master.background": {
820
+ fields: ["chrome.masters.background.*"],
821
+ tiers: STANDALONE_ONLY
822
+ },
823
+ "master.background.gradient": {
824
+ fields: ["chrome.masters.background.*"],
825
+ tiers: STANDALONE_ONLY
826
+ },
827
+ "master.background.gradient.path": {
828
+ fields: ["chrome.masters.background.*"],
829
+ tiers: STANDALONE_ONLY
830
+ },
831
+ "master.background.gradient.schemeToken": {
832
+ fields: ["chrome.masters.background.*"],
833
+ tiers: STANDALONE_ONLY
834
+ },
835
+ "master.background.pattern.schemeToken": {
836
+ fields: ["chrome.masters.background.*"],
837
+ tiers: STANDALONE_ONLY
838
+ },
839
+ "master.background.schemeToken": {
840
+ fields: ["chrome.masters.background.*"],
841
+ tiers: STANDALONE_ONLY
842
+ },
843
+ "master.colorMap": {
844
+ fields: [],
845
+ tiers: STANDALONE_ONLY
846
+ },
847
+ "master.decoration": {
848
+ fields: [],
849
+ tiers: STANDALONE_ONLY
850
+ },
851
+ "master.default": {
852
+ fields: ["master"],
853
+ tiers: STANDALONE_ONLY
854
+ },
855
+ "master.multiple": {
856
+ fields: [],
857
+ tiers: STANDALONE_ONLY
858
+ },
859
+ "master.name": {
860
+ fields: ["chrome.masters.title", "layoutName"],
861
+ tiers: STANDALONE_ONLY
862
+ },
863
+ "master.nameCollision": {
864
+ fields: ["chrome.masters.title", "layoutName"],
865
+ tiers: STANDALONE_ONLY
866
+ },
867
+ "master.placeholders": {
868
+ fields: [],
869
+ tiers: STANDALONE_ONLY
870
+ },
871
+ "master.txStyles": {
872
+ fields: [],
873
+ tiers: STANDALONE_ONLY
874
+ },
875
+ "theme.fmtScheme": {
876
+ fields: [],
877
+ tiers: STANDALONE_ONLY
878
+ },
879
+ "layout.decoration": {
880
+ fields: [],
881
+ tiers: STANDALONE_ONLY
882
+ },
883
+ "layout.group": {
884
+ fields: [],
885
+ tiers: STANDALONE_ONLY
886
+ },
887
+ "line.align": {
888
+ fields: [],
889
+ tiers: BOTH_TIERS
890
+ },
891
+ "line.dash": {
892
+ fields: ["line.dashType"],
893
+ tiers: BOTH_TIERS
894
+ },
895
+ "line.schemeToken": {
896
+ fields: ["line.color"],
897
+ tiers: BOTH_TIERS
898
+ },
899
+ "line.width": {
900
+ fields: ["line.width"],
901
+ tiers: BOTH_TIERS
902
+ },
903
+ "media.audioVideo": {
904
+ fields: ["*"],
905
+ tiers: BOTH_TIERS
906
+ },
907
+ "notes.formatting": {
908
+ fields: ["notesText"],
909
+ tiers: BOTH_TIERS
910
+ },
911
+ "shadow.schemeToken": {
912
+ fields: ["shadow.color"],
913
+ tiers: BOTH_TIERS
914
+ },
915
+ "shape.custGeom.guides": {
916
+ fields: ["points"],
917
+ tiers: BOTH_TIERS
918
+ },
919
+ "shape.effects": {
920
+ fields: ["shadow", "glow"],
921
+ tiers: BOTH_TIERS
922
+ },
923
+ "shape.empty": {
924
+ fields: ["*"],
925
+ tiers: BOTH_TIERS
926
+ },
927
+ "shape.frameInherited": {
928
+ fields: [
929
+ "x",
930
+ "y",
931
+ "w",
932
+ "h"
933
+ ],
934
+ tiers: BOTH_TIERS
935
+ },
936
+ "shape.frameUnresolved": {
937
+ fields: ["*"],
938
+ tiers: BOTH_TIERS
939
+ },
940
+ "shape.hidden": {
941
+ fields: ["*"],
942
+ tiers: BOTH_TIERS
943
+ },
944
+ "shape.hyperlink": {
945
+ fields: ["hyperlink"],
946
+ tiers: BOTH_TIERS
947
+ },
948
+ "shape.placeholder": {
949
+ fields: ["placeholder"],
950
+ tiers: BOTH_TIERS
951
+ },
952
+ "slide.animation": {
953
+ fields: [],
954
+ tiers: BOTH_TIERS
955
+ },
956
+ "slide.background": {
957
+ fields: ["background"],
958
+ tiers: BOTH_TIERS
959
+ },
960
+ "slide.background.gradient": {
961
+ fields: ["background"],
962
+ tiers: BOTH_TIERS
963
+ },
964
+ "slide.background.gradient.path": {
965
+ fields: ["background"],
966
+ tiers: BOTH_TIERS
967
+ },
968
+ "slide.background.gradient.schemeToken": {
969
+ fields: ["background"],
970
+ tiers: BOTH_TIERS
971
+ },
972
+ "slide.background.pattern.schemeToken": {
973
+ fields: ["background"],
974
+ tiers: BOTH_TIERS
975
+ },
976
+ "slide.background.schemeToken": {
977
+ fields: ["background"],
978
+ tiers: BOTH_TIERS
979
+ },
980
+ "slide.carried": {
981
+ fields: ["*"],
982
+ tiers: ["template"]
983
+ },
984
+ "slide.layout": {
985
+ fields: ["layoutName"],
986
+ tiers: BOTH_TIERS
987
+ },
988
+ "slide.name": {
989
+ fields: [],
990
+ tiers: BOTH_TIERS
991
+ },
992
+ "slide.transition": {
993
+ fields: ["transition"],
994
+ tiers: BOTH_TIERS
995
+ },
996
+ "slide.transitionSound": {
997
+ fields: ["transition.sound.*"],
998
+ tiers: BOTH_TIERS
999
+ },
1000
+ "table.cell.borders.dash": {
1001
+ fields: ["border", "diagonal"],
1002
+ tiers: BOTH_TIERS
1003
+ },
1004
+ "table.cell.headers": {
1005
+ fields: [],
1006
+ tiers: BOTH_TIERS
1007
+ },
1008
+ "table.cell.field": {
1009
+ fields: [],
1010
+ tiers: BOTH_TIERS
1011
+ },
1012
+ "table.cell.equation": {
1013
+ fields: [],
1014
+ tiers: BOTH_TIERS
1015
+ },
1016
+ "table.cell.fill.picture": {
1017
+ fields: ["fill"],
1018
+ tiers: BOTH_TIERS
1019
+ },
1020
+ "table.cell.fill.picture.geometry": {
1021
+ fields: [],
1022
+ tiers: BOTH_TIERS
1023
+ },
1024
+ "table.cell.fill.schemeToken": {
1025
+ fields: ["fill", "fill.color"],
1026
+ tiers: BOTH_TIERS
1027
+ },
1028
+ "table.cell.borders.schemeToken": {
1029
+ fields: ["border", "diagonal"],
1030
+ tiers: BOTH_TIERS
1031
+ },
1032
+ "table.fill.schemeToken": {
1033
+ fields: ["tableFill", "tableFill.color"],
1034
+ tiers: BOTH_TIERS
1035
+ },
1036
+ "table.fill.picture": {
1037
+ fields: ["tableFill"],
1038
+ tiers: BOTH_TIERS
1039
+ },
1040
+ "table.fill.picture.geometry": {
1041
+ fields: [],
1042
+ tiers: BOTH_TIERS
1043
+ },
1044
+ "table.fill.gradient": {
1045
+ fields: ["tableFill"],
1046
+ tiers: BOTH_TIERS
1047
+ },
1048
+ "table.fill.gradient.path": {
1049
+ fields: ["tableFill"],
1050
+ tiers: BOTH_TIERS
1051
+ },
1052
+ "table.fill.gradient.schemeToken": {
1053
+ fields: ["tableFill"],
1054
+ tiers: BOTH_TIERS
1055
+ },
1056
+ "table.fill.pattern.schemeToken": {
1057
+ fields: ["tableFill.pattern.fgColor", "tableFill.pattern.bgColor"],
1058
+ tiers: BOTH_TIERS
1059
+ },
1060
+ "table.cell.fill.gradient": {
1061
+ fields: ["fill"],
1062
+ tiers: BOTH_TIERS
1063
+ },
1064
+ "table.cell.fill.gradient.path": {
1065
+ fields: ["fill"],
1066
+ tiers: BOTH_TIERS
1067
+ },
1068
+ "table.cell.fill.gradient.schemeToken": {
1069
+ fields: ["fill"],
1070
+ tiers: BOTH_TIERS
1071
+ },
1072
+ "table.cell.fill.pattern.schemeToken": {
1073
+ fields: ["fill.pattern.fgColor", "fill.pattern.bgColor"],
1074
+ tiers: BOTH_TIERS
1075
+ },
1076
+ "table.cell.vert": {
1077
+ fields: ["textDirection", "vert"],
1078
+ tiers: BOTH_TIERS
1079
+ },
1080
+ "table.rowAuto": {
1081
+ fields: ["rowH"],
1082
+ tiers: BOTH_TIERS
1083
+ },
1084
+ "table.style": {
1085
+ fields: ["tableStyle"],
1086
+ tiers: BOTH_TIERS
1087
+ },
1088
+ "text.align": {
1089
+ fields: ["align"],
1090
+ tiers: BOTH_TIERS
1091
+ },
1092
+ "text.bullet.numberType": {
1093
+ fields: ["bullet"],
1094
+ tiers: BOTH_TIERS
1095
+ },
1096
+ "text.bullet.sizePt": {
1097
+ fields: ["bullet"],
1098
+ tiers: BOTH_TIERS
1099
+ },
1100
+ "text.bullet.sizePct": {
1101
+ fields: ["bullet"],
1102
+ tiers: BOTH_TIERS
1103
+ },
1104
+ "text.bullet.schemeToken": {
1105
+ fields: ["bullet"],
1106
+ tiers: BOTH_TIERS
1107
+ },
1108
+ "text.bullet.picture": {
1109
+ fields: ["bullet"],
1110
+ tiers: BOTH_TIERS
1111
+ },
1112
+ "text.color.default": {
1113
+ fields: ["options.color"],
1114
+ tiers: BOTH_TIERS
1115
+ },
1116
+ "text.color.inherited": {
1117
+ fields: ["options.color"],
1118
+ tiers: BOTH_TIERS
1119
+ },
1120
+ "text.color.schemeToken": {
1121
+ fields: ["options.color"],
1122
+ tiers: BOTH_TIERS
1123
+ },
1124
+ "text.equation": {
1125
+ fields: ["*"],
1126
+ tiers: BOTH_TIERS
1127
+ },
1128
+ "text.field": {
1129
+ fields: ["*"],
1130
+ tiers: BOTH_TIERS
1131
+ },
1132
+ "text.hyperlink": {
1133
+ fields: ["hyperlink"],
1134
+ tiers: BOTH_TIERS
1135
+ },
1136
+ "text.hyperlink.underline": {
1137
+ fields: ["options.underline"],
1138
+ tiers: BOTH_TIERS
1139
+ },
1140
+ "text.bullet.glyph": {
1141
+ fields: ["bullet"],
1142
+ tiers: BOTH_TIERS
1143
+ },
1144
+ "text.autofit.fontScale": {
1145
+ fields: ["fit"],
1146
+ tiers: BOTH_TIERS
1147
+ },
1148
+ "text.autofit.lnSpcReduction": {
1149
+ fields: ["fit"],
1150
+ tiers: BOTH_TIERS
1151
+ },
1152
+ "text.paraSpaceZero": {
1153
+ fields: ["paraSpaceBefore", "paraSpaceAfter"],
1154
+ tiers: BOTH_TIERS
1155
+ },
1156
+ "text.vert": {
1157
+ fields: ["vert"],
1158
+ tiers: BOTH_TIERS
1159
+ }
1160
+ };
1161
+ /**
1162
+ * Accumulates notes while a deck is walked, so the walk itself stays a plain
1163
+ * read-model → IR mapping and does not thread a growing array through every function.
1164
+ *
1165
+ * Deduplicates on the full note identity. Without that, a construct that is lost on every
1166
+ * run of a 400-run text body would produce 400 identical notes and bury the one-off losses
1167
+ * that actually need reading.
1168
+ */
1169
+ var NoteCollector = class {
1170
+ #seen = /* @__PURE__ */ new Set();
1171
+ #notes = [];
1172
+ /** Record a loss. A repeat of an identical note is ignored. */
1173
+ add(note) {
1174
+ const key = `${note.slideNumber}\0${note.shapeName}\0${note.construct}\0${note.disposition}\0${note.cause}\0${note.detail}`;
1175
+ if (this.#seen.has(key)) return;
1176
+ this.#seen.add(key);
1177
+ this.#notes.push(note);
1178
+ }
1179
+ /**
1180
+ * The notes, in the order first recorded — which is deck order, since the walk is
1181
+ * ordered, so a reader meets them the way they meet the slides.
1182
+ */
1183
+ get notes() {
1184
+ return this.#notes;
1185
+ }
1186
+ };
1187
+ /**
1188
+ * Namespace for a loss recorded against a shape on a **slide layout** rather than on a slide,
1189
+ * prepended to the construct by `layoutShapeScope`.
1190
+ *
1191
+ * The chrome mapper reuses the slide shape mapper wholesale — a rectangle on a layout is
1192
+ * transcribed by the same code that transcribes one on a slide, which is what keeps the two
1193
+ * from drifting. That sharing brings the slide vocabulary with it: a themed outline on a
1194
+ * layout records `line.width` exactly as a slide's would. Left unmarked, those notes would be
1195
+ * wrong twice over. A template-anchored script would report them, though it never rebuilds a
1196
+ * layout and loses nothing; and the round trip would let one excuse a *slide* difference,
1197
+ * because a layout note carries no slide number and a name like "Rectangle 7" repeats between
1198
+ * the tiers. The prefix keeps the shared vocabulary and states which tier it is about.
1199
+ *
1200
+ * `layout.` rather than `master.`, which is how the other chrome constructs are spelled: those
1201
+ * are named after the `defineSlideMaster` call that authors them, while these name the source
1202
+ * tier the loss is on — and a master's own decoration is not re-authored at all, so a note
1203
+ * under this prefix is always about a layout.
1204
+ */
1205
+ const LAYOUT_NOTE_PREFIX = "layout.";
1206
+ /**
1207
+ * `true` when a note describes the output of the printer for `tier`.
1208
+ *
1209
+ * A construct's catalogue entry decides. A `layout.`-prefixed construct with no entry of its own is
1210
+ * a slide construct borrowed for a loss in re-authoring a layout's decoration, so whatever its
1211
+ * slide twin says, it applies only to the standalone tier: the template-anchored one does not
1212
+ * rebuild layouts.
1213
+ */
1214
+ function noteAppliesTo(construct, tier) {
1215
+ const entry = NOTE_CONSTRUCTS[construct];
1216
+ if (entry) return entry.tiers.includes(tier);
1217
+ if (construct.startsWith("layout.")) return tier === "standalone";
1218
+ return true;
1219
+ }
1220
+ /**
1221
+ * `a`, `a and b`, `a, b and c` — a note is a sentence a human reads, and a bare
1222
+ * `join(', ')` made a two-item one read as a list that had lost its last member.
1223
+ */
1224
+ function andList(items) {
1225
+ if (items.length < 2) return items.join("");
1226
+ return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
1227
+ }
1228
+ /**
1229
+ * The note each {@link UnwritableFramePayload} records.
1230
+ *
1231
+ * Here rather than beside the shape mapper that records them, because the printer reads it too
1232
+ * and the printer does not reach into `from-read/`. `graphicFrameCall` picks its note from it. The
1233
+ * template-anchored printer keeps these on a slide it copies whole and drops every other note
1234
+ * recorded there: these say which construct forced the copy, and the rest describe a
1235
+ * transcription that tier never prints.
1236
+ */
1237
+ const UNWRITABLE_FRAME_CONSTRUCTS = {
1238
+ chartEx: "chartEx.all",
1239
+ diagram: "diagram.all",
1240
+ unknown: "graphicFrame.unknown"
1241
+ };
1242
+ /** Bind a collector to a slide (and optionally a shape). */
1243
+ function scopeNotes(collector, slideNumber, shapeName = null) {
1244
+ return {
1245
+ note(construct, disposition, cause, detail) {
1246
+ collector.add({
1247
+ slideNumber,
1248
+ shapeName,
1249
+ construct,
1250
+ disposition,
1251
+ cause,
1252
+ detail
1253
+ });
1254
+ },
1255
+ forShape(name) {
1256
+ return scopeNotes(collector, slideNumber, name);
1257
+ }
1258
+ };
1259
+ }
1260
+ /**
1261
+ * Wrap a scope so every construct recorded through it is namespaced under
1262
+ * {@link LAYOUT_NOTE_PREFIX} — see there for why.
1263
+ *
1264
+ * Constructs written at a call site inside the layout walk are therefore *relative*: the
1265
+ * decoration mapper records `decoration`, and what lands in the note list is
1266
+ * `layout.decoration`. The wrapper survives `forShape`, since a layout loss is scoped to a
1267
+ * shape name the same way a slide loss is.
1268
+ */
1269
+ function layoutShapeScope(notes) {
1270
+ return {
1271
+ note(construct, disposition, cause, detail) {
1272
+ notes.note(`${LAYOUT_NOTE_PREFIX}${construct}`, disposition, cause, detail);
1273
+ },
1274
+ forShape(name) {
1275
+ return layoutShapeScope(notes.forShape(name));
1276
+ }
1277
+ };
1278
+ }
1279
+ //#endregion
1280
+ //#region src/script/ir.ts
1281
+ /** `true` when `value` is an {@link AssetRef} rather than a plain IR object. */
1282
+ function isAssetRef(value) {
1283
+ return typeof value === "object" && value !== null && typeof value.$asset === "string";
313
1284
  }
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);
1285
+ /**
1286
+ * Widen one of the typed IR records below to {@link IrValue}, for the printer and the
1287
+ * canonicaliser, which walk the IR generically.
1288
+ *
1289
+ * A cast, and unavoidably so. An optional key on an interface types as `T | undefined`,
1290
+ * while `IrValue` deliberately excludes `undefined` so that "absent" has exactly one
1291
+ * spelling — which makes the two mutually unassignable even though every value a mapper
1292
+ * actually produces satisfies both. The invariant that closes the gap is enforced at the
1293
+ * source (`compact` and the conditional spreads in `from-read/`, pinned by the "no value is
1294
+ * `undefined`" corpus test) rather than by the type system, so it is stated once here
1295
+ * instead of re-argued at each call site.
1296
+ */
1297
+ function asIrValue(value) {
1298
+ return value;
317
1299
  }
318
1300
  /**
319
- * Note the parts of the fill's geometry the write path's fixed stretch cannot express.
1301
+ * `base`, or `base (2)`, `base (3)`, … — the first spelling `used` does not already hold.
320
1302
  *
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.
1303
+ * The rule {@link MasterIr}'s `props.title` is made unique by. Shared because the standalone
1304
+ * printer has to predict the title the reader gives the write path's own `DEFAULT` layout when a
1305
+ * source layout already carries that name.
323
1306
  */
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`);
1307
+ function uniqueTitle(base, used) {
1308
+ let title = base;
1309
+ for (let suffix = 2; used.has(title); suffix++) title = `${base} (${suffix})`;
1310
+ return title;
333
1311
  }
1312
+ /**
1313
+ * The `docProps` fields that round-trip, each keyed by its write-API setter and naming the read
1314
+ * accessor it comes from: four are `docProps/core.xml`, read through `Presentation.coreProperties`
1315
+ * (`author` is `dc:creator`), and `company` is `<Company>` in `docProps/app.xml`, read through
1316
+ * `Presentation.appProperties`.
1317
+ *
1318
+ * The one spelling of the list. {@link DeckPropsIr}, the mapping, the core properties that are not
1319
+ * carried, the ones a written deck is stamped with and the round trip's writer defaults all derive
1320
+ * from it. They used to be spelled out at each of those sites, and `company`, added late, was
1321
+ * missing from one of them.
1322
+ */
1323
+ const DECK_PROPS = {
1324
+ title: {
1325
+ part: "core",
1326
+ key: "title"
1327
+ },
1328
+ author: {
1329
+ part: "core",
1330
+ key: "creator"
1331
+ },
1332
+ subject: {
1333
+ part: "core",
1334
+ key: "subject"
1335
+ },
1336
+ revision: {
1337
+ part: "core",
1338
+ key: "revision"
1339
+ },
1340
+ company: {
1341
+ part: "app",
1342
+ key: "company"
1343
+ }
1344
+ };
1345
+ /** {@link DECK_PROPS}' keys, in its order. */
1346
+ const DECK_PROP_KEYS = Object.keys(DECK_PROPS);
334
1347
  //#endregion
335
1348
  //#region src/script/from-read/detect.ts
336
1349
  /**
@@ -352,8 +1365,6 @@ function noteUncarriedGeometry(picture, notes, subject) {
352
1365
  * Each check here is a standing argument for a real accessor. When one lands, the
353
1366
  * corresponding function should be deleted, not kept in sync.
354
1367
  */
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
1368
  /** Shape-tree children that carry drawing content, as opposed to the tree's own properties. */
358
1369
  const SHAPE_ELEMENTS = /* @__PURE__ */ new Set([
359
1370
  "sp",
@@ -409,11 +1420,11 @@ function isAudioVideo(element) {
409
1420
  *
410
1421
  * An equation lives in `m:oMath` inside an `a14:m` run, which contributes nothing to
411
1422
  * `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
1423
+ * *can* author one (`TextProps.math`, plus the `pptx-ts/math` subpath for LaTeX/MathML
413
1424
  * input), which makes this a read-side gap rather than a hard limit.
414
1425
  */
415
1426
  function hasEquation(element) {
416
- return has(element, MATH_NS, "oMath");
1427
+ return has(element, OOXML_NS.m, "oMath");
417
1428
  }
418
1429
  /**
419
1430
  * `true` when this `p:sp` is a text box rather than an auto shape.
@@ -427,9 +1438,15 @@ function hasEquation(element) {
427
1438
  *
428
1439
  * Direct child rather than a subtree search: `p:cNvSpPr` sits under `p:nvSpPr`, and a group
429
1440
  * would otherwise answer for its first child.
1441
+ *
1442
+ * Parsed through {@link boolValue} rather than compared to `'1'`: `txBox` is `xsd:boolean`, and
1443
+ * this module's whole job is reading decks this library did not write. PowerPoint and `gen/` both
1444
+ * emit `1`, so a bare comparison is right for every fixture here and wrong for a producer that
1445
+ * spells it `true` — which would convert that deck's text boxes as auto shapes, silently.
430
1446
  */
431
1447
  function isTextBox(element) {
432
- return child(child(element, OOXML_NS.p, "nvSpPr"), OOXML_NS.p, "cNvSpPr")?.getAttribute("txBox") === "1";
1448
+ const nvSpPr = child(element, OOXML_NS.p, "nvSpPr");
1449
+ return boolValue(child(nvSpPr, OOXML_NS.p, "cNvSpPr")?.getAttribute("txBox")) === true;
433
1450
  }
434
1451
  /**
435
1452
  * `true` when a group's child coordinate space equals its own frame, so its children are
@@ -513,59 +1530,30 @@ function hasDecorativeShapes(element) {
513
1530
  * deliberately absent: the write API has no spelling for them, and mapping them onto
514
1531
  * `justify` would be a silent visual change rather than a declared loss.
515
1532
  */
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
- };
1533
+ const ALIGN = HALIGN_BY_TEXT_ALIGN;
528
1534
  /** `AutofitMode` → the write API's `fit`. */
529
1535
  const FIT = {
530
1536
  none: "none",
531
1537
  normAutofit: "shrink",
532
1538
  spAutoFit: "resize"
533
1539
  };
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
- ]);
1540
+ /**
1541
+ * `a:bodyPr/@vert` values the write API's `vert` accepts. `TextVertType` is the whole
1542
+ * `ST_TextVerticalType`, so this is `TEXT_VERTICAL` itself, and `text.vert` fires only for a token
1543
+ * outside the schema.
1544
+ */
1545
+ const WRITABLE_VERT = new Set(TEXT_VERTICAL);
544
1546
  /**
545
1547
  * The `a:buAutoNum/@type` values the write API's `numberType` names.
546
1548
  *
547
1549
  * This used to be the *discriminator* between a numbered and a character bullet, back when
548
1550
  * 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
- ]);
1551
+ * the lookup has one job left: telling a scheme the write path can spell from one it cannot.
1552
+ *
1553
+ * Built from the tuple the write API's `numberType` is derived from and its emitter checks, so
1554
+ * the two cannot disagree about a scheme.
1555
+ */
1556
+ const AUTO_NUMBER_TYPES = new Set(TEXT_AUTONUM_SCHEMES);
569
1557
  /**
570
1558
  * `Paragraph.bulletDetail` → the write API's `bullet` option.
571
1559
  *
@@ -644,10 +1632,11 @@ function startAtOption(startAt) {
644
1632
  function bulletStyle(bullet, notes) {
645
1633
  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
1634
  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`);
1635
+ const clampedPct = sizePct === null ? null : Math.min(400, Math.max(25, sizePct));
1636
+ if (sizePct !== null && clampedPct !== sizePct) notes.note("text.bullet.sizePct", "approximated", "unwritable", `bullet glyph size ${sizePct}% is outside the 25–400% range the write API accepts, so it is printed as ${clampedPct}% — the bound the write path clamps it to`);
648
1637
  return compact({
649
1638
  fontFace: orUndefined(bullet.font),
650
- size: sizePct === null || sizePct < 25 || sizePct > 400 ? void 0 : sizePct,
1639
+ size: clampedPct ?? void 0,
651
1640
  color: bulletColor(bullet, notes)
652
1641
  }) ?? {};
653
1642
  }
@@ -656,13 +1645,11 @@ function bulletStyle(bullet, notes) {
656
1645
  * same reason {@link runColor} does: a token keeps tracking the destination theme.
657
1646
  */
658
1647
  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);
1648
+ return colorOption({
1649
+ scheme: bullet.colorRef.scheme,
1650
+ ownHex: bullet.colorRef.srgb,
1651
+ resolvedHex: bullet.colorRef.resolved?.effectiveHex ?? null
1652
+ }, notes, "text.bullet.schemeToken", "bullet");
666
1653
  }
667
1654
  /**
668
1655
  * Per-run character formatting, shared by shape text and table-cell text.
@@ -676,8 +1663,11 @@ function bulletColor(bullet, notes) {
676
1663
  * enumeration in its own right (ECMA-376 §20.1.10.81 / §20.1.10.78) and would be redundant
677
1664
  * with omission otherwise.
678
1665
  */
679
- function runOptions(run, notes) {
1666
+ function runOptions(run, ctx) {
1667
+ const { notes } = ctx;
680
1668
  const underline = run.underline;
1669
+ const hyperlink = hyperlinkOption(run, ctx);
1670
+ if (hyperlink !== void 0 && underline === null) notes.note("text.hyperlink.underline", "flattened", "unwritable", "this run links somewhere and states no underline, which PowerPoint paints underlined all the same; the write path writes u=\"sng\" on a link that sets none, so the text looks the same but states an underline it used to leave to the link");
681
1671
  return compact({
682
1672
  bold: orUndefined(run.bold),
683
1673
  italic: orUndefined(run.italic),
@@ -689,7 +1679,7 @@ function runOptions(run, notes) {
689
1679
  highlight: run.highlight ? literalColor(run.highlight.effectiveHex) : void 0,
690
1680
  caps: orUndefined(run.caps),
691
1681
  baseline: orUndefined(run.baselinePct),
692
- hyperlink: hyperlinkOption(run)
1682
+ hyperlink
693
1683
  });
694
1684
  }
695
1685
  /**
@@ -700,33 +1690,128 @@ function runOptions(run, notes) {
700
1690
  */
701
1691
  function runColor(run, notes) {
702
1692
  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
- }
1693
+ if (scheme !== null) return schemeColorOption(scheme, run.resolvedColor?.effectiveHex ?? null, notes, "text.color.schemeToken", "run");
709
1694
  if (run.color !== null) return literalColor(run.color);
710
1695
  const inherited = run.resolvedColor;
711
1696
  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");
1697
+ notes.note("text.color.default", "approximated", "unread", "this run inherits its colour and nothing resolves what it inherits (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
1698
  return;
714
1699
  }
715
1700
  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
1701
  return literalColor(inherited.effectiveHex);
717
1702
  }
1703
+ /** The `@action` of a jump to another slide of the deck. */
1704
+ const SLIDE_JUMP = "ppaction://hlinksldjump";
718
1705
  /**
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.
1706
+ * A run's hyperlink: a URL, or a jump to another slide of this deck by its number.
1707
+ *
1708
+ * The read model reports a slide jump as the target's part name, and the write API takes a slide
1709
+ * number, which {@link MapContext.slideNumberOf} resolves. Anything else a run can link to has no
1710
+ * run-level spelling (a show jump such as "next slide", which the write side emits at shape level
1711
+ * only; a custom show; another file; a slide jump from a layout's shape) and is noted. A slide
1712
+ * jump used to be dropped with a comment saying the deck-level walk handled it, which it did not,
1713
+ * so the text kept its link formatting and went nowhere.
722
1714
  */
723
- function hyperlinkOption(run) {
724
- const link = run.hyperlink;
725
- if (!link?.url) return void 0;
726
- return compact({
1715
+ function hyperlinkOption(run, ctx) {
1716
+ return linkOption(run.hyperlink, ctx, RUN_LINK);
1717
+ }
1718
+ /** The show-jump actions `HyperlinkProps.action` spells, by the `@action` each one is written as. */
1719
+ const SHOW_JUMP_ACTIONS = new Map([
1720
+ "firstslide",
1721
+ "previousslide",
1722
+ "nextslide",
1723
+ "lastslide",
1724
+ "lastslideviewed",
1725
+ "endshow"
1726
+ ].map((jump) => [`ppaction://hlinkshowjump?jump=${jump}`, jump]));
1727
+ const RUN_LINK = {
1728
+ construct: "text.hyperlink",
1729
+ takesAction: false,
1730
+ subject: "this run's hyperlink",
1731
+ spells: "all a run's hyperlink option spells (a show jump, a custom show, another file, or a slide jump from a layout's shape)",
1732
+ loss: "the text keeps its formatting and links nowhere"
1733
+ };
1734
+ const SHAPE_LINK = {
1735
+ construct: "shape.hyperlink",
1736
+ takesAction: false,
1737
+ subject: "this shape's hyperlink",
1738
+ spells: "all a shape's hyperlink option spells (a custom show, another file, or a slide jump from a layout's shape)",
1739
+ loss: "the shape is emitted without it and clicking it does nothing"
1740
+ };
1741
+ /**
1742
+ * The `@action` PowerPoint puts on an audio or video picture's `p:cNvPr`. It is structural rather
1743
+ * than a link anyone set -- the media emitter writes it itself (`gen/slide/objects/media.ts`), and
1744
+ * `addMedia` writes it again -- so it is not a hyperlink to carry and not a loss to note.
1745
+ */
1746
+ const MEDIA_ACTION = "ppaction://media";
1747
+ /** A shape's own click hyperlink, as `addShape`/`addText`/`addImage` take it. */
1748
+ function shapeHyperlinkOption(shape, ctx) {
1749
+ const link = shape.hyperlink;
1750
+ if (link?.action === MEDIA_ACTION) return void 0;
1751
+ return linkOption(link, ctx, {
1752
+ ...SHAPE_LINK,
1753
+ takesAction: true
1754
+ });
1755
+ }
1756
+ /**
1757
+ * One reading of a click hyperlink for both sites that carry one. A run and a shape take the same
1758
+ * `HyperlinkProps` apart from `action`, which only a shape's is written as, so the difference is a
1759
+ * flag rather than a second copy of the mapping.
1760
+ */
1761
+ function linkOption(link, ctx, site) {
1762
+ if (!link) return void 0;
1763
+ const tooltip = link.tooltip ?? void 0;
1764
+ if (link.url) return compact({
727
1765
  url: link.url,
728
- tooltip: link.tooltip ?? void 0
1766
+ tooltip
729
1767
  });
1768
+ const slide = link.action === SLIDE_JUMP && link.targetPartName ? ctx.slideNumberOf(link.targetPartName) : null;
1769
+ if (slide !== null) return compact({
1770
+ slide,
1771
+ tooltip
1772
+ });
1773
+ const jump = link.action === null ? void 0 : SHOW_JUMP_ACTIONS.get(link.action);
1774
+ if (site.takesAction && jump !== void 0) return compact({
1775
+ action: jump,
1776
+ tooltip
1777
+ });
1778
+ ctx.notes.note(site.construct, "dropped", "unwritable", `${site.subject} (${link.action ?? "an internal target"}) is neither a URL nor a jump to another slide of the deck, which is ${site.spells}, so ${site.loss}`);
1779
+ }
1780
+ /** The constructs and wording {@link noteUnreadText} records a shape's or a table cell's losses in. */
1781
+ const UNREAD_TEXT = {
1782
+ shape: {
1783
+ field: "text.field",
1784
+ equation: "text.equation",
1785
+ subject: "this shape",
1786
+ call: "addText",
1787
+ equationLoss: "the shape is emitted without it - even though TextProps.math (and the ts-pptx/math subpath) could author one"
1788
+ },
1789
+ cell: {
1790
+ field: "table.cell.field",
1791
+ equation: "table.cell.equation",
1792
+ subject: "this table cell",
1793
+ call: "addTable",
1794
+ equationLoss: "the cell is emitted without it"
1795
+ }
1796
+ };
1797
+ /**
1798
+ * Note the text a frame holds that no run carries, and return the text its runs do.
1799
+ *
1800
+ * Authorable text is *runs*. `TextFrame.text` also counts `a:fld` field text (a slide number, date
1801
+ * or footer placeholder), which has no run behind it, and an OMML equation contributes to neither.
1802
+ * Both were checked for an auto shape only, so the same field in a table cell was lost with no note.
1803
+ * @param frame - the text frame
1804
+ * @param element - the element holding it, searched for an equation
1805
+ * @param notes - the scope the losses are recorded on
1806
+ * @param holder - whether the frame belongs to a shape or a table cell
1807
+ * @returns the frame's run text
1808
+ */
1809
+ function noteUnreadText(frame, element, notes, holder) {
1810
+ const wording = UNREAD_TEXT[holder];
1811
+ const runText = frame.paragraphs.flatMap((p) => p.runs.map((run) => run.text)).join("");
1812
+ if (frame.text.replaceAll("\n", "").length > runText.length) notes.note(wording.field, "dropped", "unread", `${wording.subject} holds an automatic field (a:fld - a slide number, date or footer), which has no accessor and no ${wording.call} expression, so its text is not reproduced`);
1813
+ if (hasEquation(element)) notes.note(wording.equation, "dropped", "unread", `${wording.subject} holds an OMML equation, which no accessor exposes, so ${wording.equationLoss}`);
1814
+ return runText;
730
1815
  }
731
1816
  /** Paragraph-level properties, replicated onto each of the paragraph's runs. */
732
1817
  function paragraphOptions(paragraph, notes) {
@@ -755,11 +1840,11 @@ function paragraphOptions(paragraph, notes) {
755
1840
  * blank line survives — dropping it would silently close up vertical space the author put
756
1841
  * there deliberately.
757
1842
  */
758
- function textRuns(frame, notes) {
1843
+ function textRuns(frame, ctx) {
759
1844
  const paragraphs = frame.paragraphs;
760
1845
  const items = [];
761
1846
  paragraphs.forEach((paragraph, paragraphIndex) => {
762
- const paraOpts = paragraphOptions(paragraph, notes);
1847
+ const paraOpts = paragraphOptions(paragraph, ctx.notes);
763
1848
  const runs = paragraph.runs;
764
1849
  const breaks = paragraphIndex < paragraphs.length - 1;
765
1850
  if (runs.length === 0) {
@@ -778,7 +1863,7 @@ function textRuns(frame, notes) {
778
1863
  delete continuation["bullet"];
779
1864
  const options = compact({
780
1865
  ...runIndex === 0 ? paraOpts : continuation,
781
- ...runOptions(run, notes),
1866
+ ...runOptions(run, ctx),
782
1867
  ...isLastRun && breaks ? { breakLine: true } : {}
783
1868
  });
784
1869
  items.push(compact({
@@ -803,7 +1888,7 @@ function textFrameOptions(frame, notes) {
803
1888
  const vert = body?.vert ?? null;
804
1889
  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
1890
  return compact({
806
- valign: anchor === null ? "top" : ANCHOR$1[anchor],
1891
+ valign: anchor === null ? "top" : ANCHOR_TO_VALIGN[anchor],
807
1892
  wrap: body?.wrap === null || body?.wrap === void 0 ? void 0 : body.wrap !== "none",
808
1893
  vert: vert !== null && WRITABLE_VERT.has(vert) ? vert : void 0,
809
1894
  fit: fitOption(frame, notes),
@@ -856,45 +1941,24 @@ function bakedPct(value, name, notes) {
856
1941
  * expressible; an unset side takes PowerPoint's own default rather than zero, so those are
857
1942
  * spelled out — defaulting them to 0 would visibly reflow every body that sets one side.
858
1943
  */
859
- const DEFAULT_INSETS_PT = {
860
- left: 7.2,
861
- right: 7.2,
862
- top: 3.6,
863
- bottom: 3.6
864
- };
865
1944
  function marginOption(body) {
866
1945
  const insets = body?.insetsPt;
867
1946
  if (!insets) return void 0;
868
1947
  const { left, right, top, bottom } = insets;
869
1948
  if (left === void 0 && right === void 0 && top === void 0 && bottom === void 0) return void 0;
870
1949
  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)
1950
+ pointsToInches(top ?? BODY_INSET_DEFAULTS_PT.top),
1951
+ pointsToInches(right ?? BODY_INSET_DEFAULTS_PT.right),
1952
+ pointsToInches(bottom ?? BODY_INSET_DEFAULTS_PT.bottom),
1953
+ pointsToInches(left ?? BODY_INSET_DEFAULTS_PT.left)
875
1954
  ];
876
1955
  }
877
1956
  //#endregion
878
1957
  //#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) {
1958
+ function tableCall(frame, table, ctx) {
1959
+ const { notes } = ctx;
1960
+ const box = frameOf(frame, notes);
1961
+ if (!box) return null;
898
1962
  const styleId = table.styleId;
899
1963
  const hasStyle = table.resolvedStyle !== null;
900
1964
  const rows = [];
@@ -903,29 +1967,33 @@ function tableCall(frame, table, notes, assets) {
903
1967
  const cells = [];
904
1968
  for (const cell of row.cells) {
905
1969
  if (cell.isMergeContinuation) continue;
906
- cells.push(cellIr(cell, hasStyle, notes, assets));
1970
+ cells.push(cellIr(cell, hasStyle, ctx));
907
1971
  }
908
1972
  rows.push(cells);
909
1973
  rowHeights.push(row.heightEmu ?? 0);
910
1974
  }
911
1975
  const autoRows = rowHeights.filter((height) => height === 0).length;
912
1976
  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");
1977
+ 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; the auto rows are emitted as null in rowH, which pins nothing, so addTable gives each of them an even share of the table height rather than sizing it to its content");
914
1978
  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
1979
  const columnWidths = table.columnWidths;
916
1980
  return {
917
1981
  method: "addTable",
918
1982
  args: [rows, compact({
919
- ...positionOfFrame(frame),
920
- objectName: frame.name || void 0,
1983
+ ...positionOptions(box),
1984
+ ...identityOptions(frame),
921
1985
  tableStyle: orUndefined(styleId),
922
- tableFill: tableFill(table, notes, assets),
1986
+ tableFill: tableFill(table, ctx),
923
1987
  hasHeader: table.firstRowHeader ? true : void 0,
1988
+ hasFooter: table.lastRowFooter ? true : void 0,
924
1989
  hasBandedRows: table.bandedRows ? true : void 0,
1990
+ hasBandedColumns: table.bandedColumns ? true : void 0,
1991
+ hasFirstColumn: table.firstColumnHeader ? true : void 0,
1992
+ hasLastColumn: table.lastColumnFooter ? true : void 0,
925
1993
  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)
1994
+ rowH: rowHeights.every((h) => h === 0) ? void 0 : rowHeights.map((h) => h === 0 ? null : inches(h))
927
1995
  }) ?? {}],
928
- ...frame.name ? { sourceName: frame.name } : {}
1996
+ ...nameOf(frame)
929
1997
  };
930
1998
  }
931
1999
  /**
@@ -937,23 +2005,8 @@ function tableCall(frame, table, notes, assets) {
937
2005
  * directly. A scheme token still wins over the literal when there is one, so the replica
938
2006
  * keeps tracking its theme.
939
2007
  */
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;
2008
+ function tableFill(table, ctx) {
2009
+ return surfaceFill(table, ctx, "table.fill");
957
2010
  }
958
2011
  /**
959
2012
  * One cell as a `TableCell`.
@@ -962,32 +2015,34 @@ function tableFill(table, notes, assets) {
962
2015
  * identically to one on a shape — the read model already shares `TextFrame` between the
963
2016
  * two, and diverging here would be a difference with no cause.
964
2017
  */
965
- function cellIr(cell, hasStyle, notes, assets) {
2018
+ function cellIr(cell, hasStyle, ctx) {
2019
+ const { notes } = ctx;
966
2020
  const frame = cell.textFrame;
967
2021
  const anchor = cell.anchor;
968
2022
  const margins = cell.marginsEmu;
969
2023
  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");
2024
+ if (frame) noteUnreadText(frame, cell.element_, notes, "cell");
970
2025
  const options = compact({
971
- fill: cellFill(cell, hasStyle, notes, assets),
2026
+ fill: cellFill(cell, hasStyle, ctx),
972
2027
  border: cellBorders(cell, notes),
973
2028
  diagonal: cellDiagonals(cell, notes),
974
2029
  anchorCtr: cell.anchorCtr ? true : void 0,
975
2030
  cell3D: cellThreeD(cell),
976
- valign: anchor === null ? void 0 : ANCHOR[anchor],
2031
+ valign: anchor === null ? void 0 : ANCHOR_TO_VALIGN[anchor],
977
2032
  textDirection: cellTextDirection(cell, notes),
978
2033
  horzOverflow: cell.horzOverflow === "clip" || cell.horzOverflow === "overflow" ? cell.horzOverflow : void 0,
979
2034
  colspan: cell.gridSpan > 1 ? cell.gridSpan : void 0,
980
2035
  rowspan: cell.rowSpan > 1 ? cell.rowSpan : void 0,
981
2036
  margin: margins ? [
982
- inches(margins.top ?? 0),
983
- inches(margins.right ?? 0),
984
- inches(margins.bottom ?? 0),
985
- inches(margins.left ?? 0)
2037
+ inches(margins.top ?? TABLE_CELL_MARGIN_DEFAULTS_EMU.top),
2038
+ inches(margins.right ?? TABLE_CELL_MARGIN_DEFAULTS_EMU.right),
2039
+ inches(margins.bottom ?? TABLE_CELL_MARGIN_DEFAULTS_EMU.bottom),
2040
+ inches(margins.left ?? TABLE_CELL_MARGIN_DEFAULTS_EMU.left)
986
2041
  ] : void 0,
987
- ...frame?.paragraphs[0]?.runs[0] ? runOptions(frame.paragraphs[0].runs[0], notes) ?? {} : {}
2042
+ ...frame?.paragraphs[0]?.runs[0] ? runOptions(frame.paragraphs[0].runs[0], ctx) ?? {} : {}
988
2043
  });
989
2044
  return compact({
990
- text: frame ? textRuns(frame, notes) : cell.text,
2045
+ text: frame ? textRuns(frame, ctx) : cell.text,
991
2046
  options
992
2047
  }) ?? { text: "" };
993
2048
  }
@@ -1029,25 +2084,28 @@ function cellThreeD(cell) {
1029
2084
  } : void 0
1030
2085
  }) ?? {};
1031
2086
  }
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
- ]);
2087
+ /**
2088
+ * The `ST_TextVerticalType` values `TextBaseProps.textDirection` accepts, as a `Record` over that
2089
+ * union so the compiler catches a direction either side gains.
2090
+ */
2091
+ const WRITABLE_TEXT_DIRECTIONS = {
2092
+ horz: true,
2093
+ vert: true,
2094
+ vert270: true,
2095
+ wordArtVert: true
2096
+ };
1039
2097
  /**
1040
2098
  * A cell's `a:tcPr/@vert` as the write API's `textDirection`.
1041
2099
  *
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
2100
+ * `ST_TextVerticalType` has seven values and the write option covers four. The other three
2101
+ * (`eaVert`, `mongolianVert` and `wordArtVertRtl`) are East-Asian and right-to-left layout
1044
2102
  * modes with no `TextBaseProps` spelling, so they are noted rather than written — passing
1045
2103
  * one straight through would put a value the option does not admit into the attribute.
1046
2104
  */
1047
2105
  function cellTextDirection(cell, notes) {
1048
2106
  const vert = cell.verticalText;
1049
2107
  if (vert === null || vert === "horz") return void 0;
1050
- if (WRITABLE_TEXT_DIRECTIONS.has(vert)) return vert;
2108
+ if (Object.hasOwn(WRITABLE_TEXT_DIRECTIONS, vert)) return vert;
1051
2109
  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
2110
  }
1053
2111
  /**
@@ -1066,26 +2124,8 @@ function cellTextDirection(cell, notes) {
1066
2124
  * banding exactly rather than approximately. Neither case loses anything, which is why
1067
2125
  * neither records a note.
1068
2126
  */
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) };
2127
+ function cellFill(cell, hasStyle, ctx) {
2128
+ return surfaceFill(cell, ctx, "table.cell.fill", { styled: hasStyle });
1089
2129
  }
1090
2130
  /**
1091
2131
  * The four edge borders as the write API's `[top, right, bottom, left]` tuple.
@@ -1106,20 +2146,6 @@ function cellBorders(cell, notes) {
1106
2146
  if (edges.every((edge) => edge === null)) return void 0;
1107
2147
  return edges.map((edge) => borderIr(edge, notes));
1108
2148
  }
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
2149
  /**
1124
2150
  * One decoded edge (or diagonal) as `BorderProps`.
1125
2151
  *
@@ -1132,12 +2158,15 @@ function borderIr(edge, notes) {
1132
2158
  if (!edge) return {};
1133
2159
  if (edge.noFill) return { type: "none" };
1134
2160
  const dash = edge.dash;
1135
- const known = dash === null || WRITABLE_DASHES$1.has(dash);
2161
+ const known = dash === null || WRITABLE_DASHES.has(dash);
1136
2162
  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
2163
  return compact({
1138
2164
  type: dash === null || dash === "solid" ? "solid" : "dash",
1139
2165
  dashType: known && dash !== null && dash !== "solid" ? dash : void 0,
1140
- color: edge.schemeColor ?? (edge.color === null ? void 0 : literalColor(edge.color)),
2166
+ color: colorOption({
2167
+ scheme: edge.colorRef.scheme,
2168
+ resolvedHex: edge.colorRef.resolved?.effectiveHex ?? null
2169
+ }, notes, "table.cell.borders.schemeToken", "cell border"),
1141
2170
  width: orUndefined(edge.widthPt)
1142
2171
  }) ?? {};
1143
2172
  }
@@ -1167,6 +2196,30 @@ const CHART_TYPE = {
1167
2196
  surface: "surface",
1168
2197
  surface3D: "surface"
1169
2198
  };
2199
+ /**
2200
+ * The 3-D groups rebuilt flat, because the write API has no 3-D type for them. `bar3D` and
2201
+ * `bubble3D` keep their own, and a 3-D surface is what the write side's `surface` already draws.
2202
+ */
2203
+ const FLATTENED_3D = /* @__PURE__ */ new Set([
2204
+ "area3D",
2205
+ "line3D",
2206
+ "pie3D"
2207
+ ]);
2208
+ /**
2209
+ * The plot types whose `showLabel` writes the category name. A scatter's writes nothing unless a
2210
+ * `dataLabelFormatScatter` is also set, so a scatter is not among them.
2211
+ */
2212
+ const CATEGORY_NAME_TYPES = /* @__PURE__ */ new Set(["doughnut", "pie"]);
2213
+ /**
2214
+ * The plot types that pair every value with an X value of its own (`c:xVal` and `c:yVal`, plus
2215
+ * `c:bubbleSize` on a bubble) rather than with a category. `addChart` spells them as a leading row
2216
+ * of X values followed by one row per series.
2217
+ */
2218
+ const XY_TYPES = /* @__PURE__ */ new Set([
2219
+ "bubble",
2220
+ "bubble3D",
2221
+ "scatter"
2222
+ ]);
1170
2223
  /** `c:legendPos/@val` → the write API's `legendPos`. */
1171
2224
  const LEGEND_POS = {
1172
2225
  r: "r",
@@ -1176,32 +2229,37 @@ const LEGEND_POS = {
1176
2229
  tr: "tr"
1177
2230
  };
1178
2231
  function chartCall(frame, chart, notes) {
2232
+ const box = frameOf(frame, notes);
2233
+ if (!box) return null;
1179
2234
  const sourceType = chart.chartType;
1180
2235
  const type = sourceType === null ? void 0 : CHART_TYPE[sourceType];
1181
2236
  if (!type) {
1182
2237
  notes.note("chart.type", "dropped", "unwritable", `chart type "${sourceType ?? "none"}" has no write-API counterpart, so the chart is omitted`);
1183
2238
  return null;
1184
2239
  }
2240
+ if (sourceType !== null && FLATTENED_3D.has(sourceType)) notes.note("chart.type3D", "flattened", "unwritable", `a 3-D ${type} chart (c:${sourceType}Chart) is rebuilt as a flat ${type} chart: the write API has no 3-D ${type} type, so its depth, rotation and perspective are gone`);
1185
2241
  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) {
2242
+ const xy = XY_TYPES.has(type);
2243
+ const data = xy ? xyData(chart, type, notes) : seriesData(chart, notes);
2244
+ if (data.length === 0 || chart.series.every((series) => (xy ? series.yValues : series.values).length === 0)) {
1189
2245
  notes.note("chart.data", "dropped", "unsupported", "this chart caches no plottable series values");
1190
2246
  return null;
1191
2247
  }
2248
+ 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");
1192
2249
  return {
1193
2250
  method: "addChart",
1194
2251
  args: [data, compact({
1195
2252
  type,
1196
- ...positionOfFrame(frame),
1197
- objectName: frame.name || void 0,
2253
+ surface3D: sourceType === "surface" ? false : void 0,
2254
+ ...positionOptions(box),
2255
+ ...identityOptions(frame),
1198
2256
  ...titleOptions(chart),
1199
2257
  ...legendOptions(chart),
1200
- ...labelOptions(chart),
2258
+ ...labelOptions(chart, type, notes),
1201
2259
  ...axisOptions(chart),
1202
2260
  chartColors: seriesColors(chart)
1203
2261
  }) ?? { type }],
1204
- ...frame.name ? { sourceName: frame.name } : {}
2262
+ ...nameOf(frame)
1205
2263
  };
1206
2264
  }
1207
2265
  /**
@@ -1210,21 +2268,81 @@ function chartCall(frame, chart, notes) {
1210
2268
  * practice.
1211
2269
  */
1212
2270
  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
- });
2271
+ const levels = chart.categoryLevels.map((level) => level.map((label) => label ?? ""));
2272
+ const labels = levels.length > 1 ? levels : levels[0];
2273
+ const dense = blanksToZero(notes);
2274
+ return chart.series.map((series, index) => {
2275
+ const values = dense(series.values);
1220
2276
  return compact({
1221
2277
  name: orUndefined(series.name),
1222
2278
  values,
1223
- labels: index === 0 && categories.length > 0 ? categories : void 0
2279
+ labels: index === 0 && levels[0]?.length ? labels : void 0
1224
2280
  }) ?? { values };
1225
2281
  });
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;
2282
+ }
2283
+ /**
2284
+ * A scatter or bubble chart as `OptsChartData[]`: the X row, then one row per series with its Y
2285
+ * values and, on a bubble, its sizes. The X row is not a series and has no name in the chart part;
2286
+ * its column header lives only in the workbook, which is regenerated anyway.
2287
+ *
2288
+ * `addChart` plots every series against that one X row, while a chart part gives each series a
2289
+ * `c:xVal` of its own. The first series' X values are taken, and a series whose own differ is noted.
2290
+ * @param type - the write-API chart type the chart is rebuilt as
2291
+ */
2292
+ function xyData(chart, type, notes) {
2293
+ const series = chart.series;
2294
+ const first = series[0];
2295
+ if (!first) return [];
2296
+ const dense = blanksToZero(notes);
2297
+ const xValues = plottedX(first);
2298
+ const withSizes = type !== "scatter";
2299
+ const rows = [{ values: dense(xValues) }];
2300
+ let ownX = false;
2301
+ for (const one of series) {
2302
+ const own = plottedX(one);
2303
+ if (own.length !== xValues.length || own.some((value, i) => value !== xValues[i])) ownX = true;
2304
+ const values = dense(one.yValues);
2305
+ rows.push(compact({
2306
+ name: orUndefined(one.name),
2307
+ values,
2308
+ sizes: withSizes ? dense(one.bubbleSizes) : void 0
2309
+ }) ?? { values });
2310
+ }
2311
+ if (ownX) notes.note("chart.xValues", "approximated", "unwritable", `the series of this ${type} chart do not share one set of X values, and addChart plots every series against a single X row, so each is rebuilt against the first series' X values`);
2312
+ if (series.some((one) => one.xLabels !== null)) notes.note("chart.xLabels", "dropped", "unwritable", `this ${type} chart is plotted against text X labels, which PowerPoint places at 1 to n in point order; it is rebuilt against those positions and plots the same, but addChart takes numbers for its X row, so the labels are gone`);
2313
+ return rows;
2314
+ }
2315
+ /**
2316
+ * The X coordinates a series is plotted at. A series plotted against text has none of its own, and
2317
+ * neither has a series with no `c:xVal`: PowerPoint places the points of both at 1 to n, and an X row
2318
+ * of those positions draws the same chart. Taking the X values instead turned every label into a
2319
+ * blank and the blank into a 0, and gave a series with no `c:xVal` an empty X row, against which
2320
+ * `addChart` plots no point at all.
2321
+ */
2322
+ function plottedX(series) {
2323
+ const labels = series.xLabels;
2324
+ if (labels !== null) return positions(labels.length);
2325
+ const xValues = series.xValues;
2326
+ return xValues.length === 0 ? positions(series.yValues.length) : xValues;
2327
+ }
2328
+ /** The positions 1 to `count`. */
2329
+ function positions(count) {
2330
+ return Array.from({ length: count }, (_unused, i) => i + 1);
2331
+ }
2332
+ /**
2333
+ * Densify cached points for `number[]`, noting once per chart when a blank had to become 0.
2334
+ * The returned function is called once per point list, and the note is recorded on the first blank.
2335
+ */
2336
+ function blanksToZero(notes) {
2337
+ let noted = false;
2338
+ return (points) => points.map((value) => {
2339
+ if (value !== null) return value;
2340
+ if (!noted) {
2341
+ noted = true;
2342
+ 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");
2343
+ }
2344
+ return 0;
2345
+ });
1228
2346
  }
1229
2347
  function titleOptions(chart) {
1230
2348
  const title = chart.title;
@@ -1241,17 +2359,32 @@ function legendOptions(chart) {
1241
2359
  legendPos: legend.position === null ? void 0 : LEGEND_POS[legend.position]
1242
2360
  };
1243
2361
  }
1244
- function labelOptions(chart) {
1245
- const labels = chart.dataLabels;
2362
+ /**
2363
+ * Data-label flags as `ChartOpts`.
2364
+ *
2365
+ * Two of the read model's flags have no key of their own on the write side, and they were emitted
2366
+ * as `showCatName` and `showLegendKey` anyway. Neither is a `ChartOpts` key, so the writer ignored
2367
+ * both and nothing said so. `ChartOpts` spells the category name `showLabel`, which a pie and a
2368
+ * doughnut write as one; nothing spells the legend-key swatch.
2369
+ *
2370
+ * A pie and a doughnut are read from the series' own block. PowerPoint writes the flags the user set
2371
+ * there and an all-off block for the group, so reading the group block rebuilt a labelled pie
2372
+ * without its labels.
2373
+ * @param type - the write-API chart type the chart is rebuilt as
2374
+ */
2375
+ function labelOptions(chart, type, notes) {
2376
+ const carriesCategoryName = CATEGORY_NAME_TYPES.has(type);
2377
+ const labels = (carriesCategoryName ? chart.series[0]?.dataLabels : null) ?? chart.dataLabels;
1246
2378
  if (!labels) return {};
2379
+ if (labels.showLegendKey === true) notes.note("chart.labels", "dropped", "unwritable", "the data labels show the legend key swatch (c:showLegendKey), which no ChartOpts option spells, so the labels come back without it");
2380
+ if (labels.showCategoryName === true && !carriesCategoryName) notes.note("chart.labels", "dropped", "unwritable", `the data labels show the category name (c:showCatName); ChartOpts spells that showLabel, which only a pie and a doughnut write as the category name, so on this ${type} chart the labels come back without it`);
1247
2381
  return {
1248
2382
  showValue: labels.showValue ?? void 0,
1249
2383
  showSerName: labels.showSeriesName ?? void 0,
1250
- showCatName: labels.showCategoryName ?? void 0,
2384
+ showLabel: carriesCategoryName ? labels.showCategoryName ?? void 0 : void 0,
1251
2385
  showPercent: labels.showPercent ?? void 0,
1252
- showLegendKey: labels.showLegendKey ?? void 0,
1253
2386
  showLeaderLines: labels.showLeaderLines ?? void 0,
1254
- dataLabelPosition: orUndefined(labels.position),
2387
+ dataLabelPosition: orUndefined(labels.position) ?? (type === "pie" ? "bestFit" : void 0),
1255
2388
  dataLabelFormatCode: labels.numberFormat?.formatCode ?? void 0
1256
2389
  };
1257
2390
  }
@@ -1284,94 +2417,39 @@ function seriesColors(chart) {
1284
2417
  if (series.length === 0) return void 0;
1285
2418
  const colors = [];
1286
2419
  for (const one of series) {
1287
- const hex = one.fill?.color;
2420
+ const hex = one.fill?.colorRef.srgb;
1288
2421
  if (!hex) return void 0;
1289
2422
  colors.push(literalColor(hex));
1290
2423
  }
1291
2424
  return colors;
1292
2425
  }
1293
2426
  //#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
- };
2427
+ //#region src/script/from-read/context.ts
1358
2428
  /**
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.
2429
+ * The same context, with its notes bound to one shape.
2430
+ *
2431
+ * A note recorded through the result names that shape, which is what lets a reader of the
2432
+ * emitted script find the shape a loss belongs to. Callers used to re-derive this inline as
2433
+ * `notes.forShape(shape.name || null)`, sometimes twice in one function and once in a callee
2434
+ * that already had a scoped one.
2435
+ *
2436
+ * An unnamed shape scopes to `''`, not `null`. `null` is how a slide or deck note is scoped, and
2437
+ * the round trip matches such a note's fields from the root of the diff, where a shape's
2438
+ * `line.width` is never found, so a correctly noted loss on an unnamed shape was reported as
2439
+ * undeclared.
2440
+ * @param ctx - the enclosing context
2441
+ * @param shape - the shape to scope to; an unnamed one scopes to `''`
1361
2442
  */
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;
2443
+ function forShape(ctx, shape) {
2444
+ return {
2445
+ ...ctx,
2446
+ notes: ctx.notes.forShape(shape.name || "")
2447
+ };
1374
2448
  }
2449
+ //#endregion
2450
+ //#region src/script/from-read/shape-paint.ts
2451
+ /** Arrowhead types `ShapeLineProps` accepts: every `ST_LineEndType`, the tokens `a:headEnd/@type` uses. */
2452
+ const WRITABLE_ARROWS = new Set(LINE_END_TYPES);
1375
2453
  /**
1376
2454
  * `p:cNvPr/@hidden` has no write-API counterpart, so a hidden shape would come back visible.
1377
2455
  * Omitting it preserves what the deck looks like, which is the lesser loss. Shared with the
@@ -1381,31 +2459,6 @@ function noteHidden(notes) {
1381
2459
  notes.note("shape.hidden", "dropped", "unwritable", "a hidden shape has no write-API expression; it is omitted rather than emitted visible");
1382
2460
  }
1383
2461
  /**
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
2462
  * Rotation and flips. Taken from `absoluteFrame`, which composes enclosing group rotations
1410
2463
  * and XOR-composes group flips — the same reason position is absolute.
1411
2464
  */
@@ -1426,29 +2479,42 @@ function transformOptions(shape) {
1426
2479
  * the only accessor that sees a `p:style/a:fillRef` — a shape styled entirely from the
1427
2480
  * theme has no `a:solidFill` of its own, so without it the shape would come out unfilled.
1428
2481
  */
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
- });
2482
+ function fillOption(shape, ctx) {
2483
+ return surfaceFill({
2484
+ ...fillAccessors(shape),
2485
+ fillColor: shape.fillColor
2486
+ }, ctx, "fill");
2487
+ }
2488
+ /**
2489
+ * The `EG_FillProperties` accessors {@link surfaceFill} reads, projected off a read-model shape.
2490
+ *
2491
+ * A projection rather than passing the shape itself: `AnyShape` is a union whose members carry
2492
+ * far more than this, and naming the six keys is what makes "a shape, a table and a cell answer
2493
+ * the same six questions" a checked statement rather than an observation.
2494
+ */
2495
+ function fillAccessors(shape) {
2496
+ return {
2497
+ gradientFill: shape.gradientFill,
2498
+ patternFill: shape.patternFill,
2499
+ pictureFill: shape.pictureFill,
2500
+ fillSchemeColor: shape.fillSchemeColor,
2501
+ resolvedFill: shape.resolvedFill
2502
+ };
2503
+ }
2504
+ /**
2505
+ * Everything about an outline that does not survive, noted.
2506
+ *
2507
+ * Three independent conditions, none of which decides any of `lineOption`'s emitted values --
2508
+ * they were interleaved with the option build, which is what made that function four
2509
+ * decisions wearing one name.
2510
+ *
2511
+ * @param values - the outline's already-read parts, so this does not re-read the DOM
2512
+ */
2513
+ function noteLineLosses(shape, values, notes) {
2514
+ const { widthPt, dash, scheme, resolved } = values;
2515
+ 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");
2516
+ 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`);
2517
+ 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");
1452
2518
  }
1453
2519
  /**
1454
2520
  * A shape's outline as `ShapeLineProps`.
@@ -1474,18 +2540,22 @@ function lineOption(shape, notes) {
1474
2540
  const cap = shape.lineCap;
1475
2541
  const scheme = shape.lineSchemeColor;
1476
2542
  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");
2543
+ const color = colorOption({
2544
+ scheme,
2545
+ ownHex: shape.lineColor,
2546
+ resolvedHex: resolved?.effectiveHex ?? null
2547
+ }, notes, "line.schemeToken", "outline");
2548
+ noteLineLosses(shape, {
2549
+ widthPt,
2550
+ dash,
2551
+ scheme,
2552
+ resolved
2553
+ }, notes);
1484
2554
  return compact({
1485
2555
  color,
1486
2556
  width: orUndefined(widthPt),
1487
2557
  dashType: dash !== null && WRITABLE_DASHES.has(dash) ? dash : void 0,
1488
- cap: cap === null ? void 0 : CAP_TOKENS[cap],
2558
+ cap: cap === null ? void 0 : LINE_CAP_BY_TOKEN[cap],
1489
2559
  transparency: alphaToTransparency(resolved?.alpha),
1490
2560
  ...arrowOptions(shape, notes)
1491
2561
  });
@@ -1508,64 +2578,145 @@ function shadowOption(shape, notes) {
1508
2578
  if (!source) return void 0;
1509
2579
  return compact({
1510
2580
  type: outer ? "outer" : "inner",
1511
- color: source.color === null ? void 0 : literalColor(source.color),
2581
+ color: colorOption({
2582
+ scheme: source.colorRef.scheme,
2583
+ resolvedHex: source.colorRef.resolved?.effectiveHex ?? null
2584
+ }, notes, "shadow.schemeToken", "shadow"),
1512
2585
  blur: source.blurPt,
1513
2586
  offset: source.offsetPt,
1514
2587
  angle: source.angleDeg,
1515
- transparency: alphaToTransparency(source.alpha)
2588
+ transparency: alphaToTransparency(source.colorRef.resolved?.alpha)
1516
2589
  });
1517
2590
  }
1518
2591
  /** Glow. The read model reports opacity as a 0–1 alpha, which is the write API's unit too. */
1519
- function glowOption(shape) {
2592
+ function glowOption(shape, notes) {
1520
2593
  const glow = shape.glow;
1521
2594
  if (!glow) return void 0;
1522
2595
  return compact({
1523
2596
  size: glow.radiusPt,
1524
- color: glow.color === null ? void 0 : literalColor(glow.color),
1525
- opacity: glow.alpha
2597
+ color: colorOption({
2598
+ scheme: glow.colorRef.scheme,
2599
+ resolvedHex: glow.colorRef.resolved?.effectiveHex ?? null
2600
+ }, notes, "glow.schemeToken", "glow"),
2601
+ opacity: glow.colorRef.resolved?.alpha
1526
2602
  });
1527
2603
  }
1528
2604
  /** The style block every shape kind shares. */
1529
- function styleOptions(shape, notes, assets) {
2605
+ function styleOptions(shape, ctx) {
2606
+ const { notes } = ctx;
1530
2607
  return {
1531
- fill: fillOption(shape, notes, assets),
2608
+ fill: fillOption(shape, ctx),
1532
2609
  line: lineOption(shape, notes),
1533
2610
  shadow: shadowOption(shape, notes),
1534
- glow: glowOption(shape)
2611
+ glow: glowOption(shape, notes)
1535
2612
  };
1536
2613
  }
1537
2614
  /**
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.
2615
+ * An `AutoShape` becomes an `addShape` when it is bare geometry and an `addText` when it
2616
+ * carries text. A shape with both takes the `addText` form, because `addShape` has no text
2617
+ * argument while `addText` accepts a `shape` option — so only that form expresses both.
2618
+ */
2619
+ //#endregion
2620
+ //#region src/script/from-read/shape.ts
2621
+ /**
2622
+ * `AnyShape` → one {@link CallIr}.
2623
+ *
2624
+ * The dispatch is on what the shape *is*, not on what it looks like: an `AutoShape` with a
2625
+ * text frame and no geometry is an `addText`, one with geometry is an `addShape` that may
2626
+ * also carry text, a `Picture` is an `addImage`, and a `GraphicFrame` delegates to the
2627
+ * table or chart mapper. A `GroupShape` recurses.
2628
+ *
2629
+ * The single most important thing this module does is record what it cannot carry.
2630
+ * Measurement against the fixture corpus found the binding constraint is the *read* side,
2631
+ * not the write side — several constructs the write API can express have no accessor at
2632
+ * all — so a converter that mapped only what it could see would produce clean-looking
2633
+ * output with silent visual losses. The clearest is theme-referenced line styling: a shape
2634
+ * whose outline comes from `p:style/a:lnRef` reports a colour through `resolvedLine` but
2635
+ * reports `null` for width and dash, because there is a resolved-colour path and no
2636
+ * resolved-width path. In the corpus that was every such shape, so a themed 2pt
2637
+ * border silently becomes a 1pt default. Each one is noted here rather than quietly thinned.
2638
+ *
2639
+ * The same dispatch serves the deck's *chrome*: {@link masterObject} re-tags a call as an
2640
+ * entry in a `defineSlideMaster({ objects })` array, so a rectangle on a layout is transcribed
2641
+ * by the code that transcribes one on a slide. Sharing it is the point — a second mapper would
2642
+ * be a second set of decisions about the same OOXML, drifting from this one silently.
2643
+ */
2644
+ /**
2645
+ * Map one shape. Returns `null` when the shape produces no call at all — a note is always
2646
+ * recorded in that case, so a dropped shape is never silent.
2647
+ */
2648
+ function shapeCall(shape, ctx) {
2649
+ const scoped = forShape(ctx, shape);
2650
+ if (shape.hidden) {
2651
+ noteHidden(scoped.notes);
2652
+ return null;
2653
+ }
2654
+ if (isGroupShape(shape)) return groupCall(shape, scoped);
2655
+ if (isPicture(shape)) return pictureCall(shape, scoped);
2656
+ if (isConnector(shape)) return connectorCall(shape, scoped.notes);
2657
+ if (isGraphicFrame(shape)) return graphicFrameCall(shape, scoped);
2658
+ if (isAutoShape(shape)) return autoShapeCall(shape, scoped);
2659
+ return null;
2660
+ }
2661
+ /**
2662
+ * The shape's authorable text, and the notes for what it holds that cannot be authored.
2663
+ *
2664
+ * "Has text" means text this converter can author, which is *runs*. `TextFrame.text` also
2665
+ * counts `a:fld` field text -- a slide number, date or footer placeholder -- and there is no
2666
+ * run behind it, so measuring by `text` made every field placeholder emit an `addText`
2667
+ * carrying a single empty string: an invisible empty box where a number used to be.
2668
+ *
2669
+ * Extracted from `autoShapeCall`, which inlined this alongside the placeholder note, the
2670
+ * common-option assembly and three mutually exclusive emission arms.
2671
+ * @returns the frame when it holds authorable runs, else `null`
1541
2672
  */
1542
- function autoShapeCall(shape, notes, assets) {
2673
+ function autoShapeText(shape, notes) {
2674
+ const frame = shape.hasTextFrame ? shape.textFrame : null;
2675
+ if (frame === null) return null;
2676
+ return noteUnreadText(frame, shape.element_, notes, "shape").length > 0 ? frame : null;
2677
+ }
2678
+ function autoShapeCall(shape, ctx) {
2679
+ const { notes } = ctx;
2680
+ const box = frameOf(shape, notes);
2681
+ if (!box) return null;
1543
2682
  const preset = shape.presetGeometry;
1544
2683
  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");
2684
+ const frame = autoShapeText(shape, notes);
1550
2685
  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
2686
  const common = {
1552
- ...positionOptions(shape, notes),
2687
+ ...positionOptions(box),
1553
2688
  ...transformOptions(shape),
1554
- ...styleOptions(shape, notes, assets),
1555
- objectName: shape.name || void 0
2689
+ ...styleOptions(shape, ctx),
2690
+ ...identityOptions(shape),
2691
+ hyperlink: shapeHyperlinkOption(shape, ctx)
1556
2692
  };
1557
2693
  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");
2694
+ noteCustGeomGuides(notes);
2695
+ const points = customGeometryPoints(box, custom);
2696
+ if (frame !== null) {
2697
+ const options = compact({
2698
+ ...common,
2699
+ ...textFrameOptions(frame, notes),
2700
+ ...isTextBox(shape.element_) ? { isTextBox: true } : {},
2701
+ shape: "custGeom",
2702
+ points
2703
+ });
2704
+ return {
2705
+ method: "addText",
2706
+ args: [textRuns(frame, ctx), options ?? {}],
2707
+ ...nameOf(shape)
2708
+ };
2709
+ }
1559
2710
  return {
1560
2711
  method: "addShape",
1561
2712
  args: ["custGeom", compact({
1562
2713
  ...common,
1563
- points: customGeometryPoints(shape, custom)
2714
+ points
1564
2715
  }) ?? {}],
1565
2716
  ...nameOf(shape)
1566
2717
  };
1567
2718
  }
1568
- if (hasText && frame !== null) {
2719
+ if (frame !== null) {
1569
2720
  const options = compact({
1570
2721
  ...common,
1571
2722
  ...textFrameOptions(frame, notes),
@@ -1577,7 +2728,7 @@ function autoShapeCall(shape, notes, assets) {
1577
2728
  });
1578
2729
  return {
1579
2730
  method: "addText",
1580
- args: [textRuns(frame, notes), options ?? {}],
2731
+ args: [textRuns(frame, ctx), options ?? {}],
1581
2732
  ...nameOf(shape)
1582
2733
  };
1583
2734
  }
@@ -1594,6 +2745,29 @@ function autoShapeCall(shape, notes, assets) {
1594
2745
  ...nameOf(shape)
1595
2746
  };
1596
2747
  }
2748
+ /** The note for what a `custGeom` holds beside its path, shared by a freeform shape and a picture clipped to one. */
2749
+ function noteCustGeomGuides(notes) {
2750
+ 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");
2751
+ }
2752
+ /**
2753
+ * A picture's clip as `addImage` spells it: `points` for a freeform, `shape` and `shapeAdjust` for
2754
+ * a preset.
2755
+ *
2756
+ * PowerPoint writes `rect` for a picture that has not been cropped to a shape, and `addImage` writes
2757
+ * `rect` when it is given no `shape`, so a `rect` is left off rather than printed on every picture.
2758
+ */
2759
+ function pictureClipOptions(shape, box, notes) {
2760
+ const custom = shape.customGeometry;
2761
+ if (custom) {
2762
+ noteCustGeomGuides(notes);
2763
+ return { points: customGeometryPoints(box, custom) };
2764
+ }
2765
+ const preset = shape.presetGeometry;
2766
+ return preset === null || preset === "rect" ? {} : {
2767
+ shape: preset,
2768
+ ...adjustOptions(shape)
2769
+ };
2770
+ }
1597
2771
  /**
1598
2772
  * Preset-geometry adjust handles (`a:avLst`) as `shapeAdjust`.
1599
2773
  *
@@ -1619,15 +2793,18 @@ function adjustOptions(shape) {
1619
2793
  * `custGeom` path commands as the write API's `points` array.
1620
2794
  *
1621
2795
  * 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.
2796
+ * is scaled onto the box the call is placed at before being printed as EMU. A path that
2797
+ * declares no viewport (`@w`/`@h` of 0) is already in the shape's own space and passes through.
2798
+ *
2799
+ * The box is the one {@link frameOf} placed the call at, not `absoluteFrame`: in a group whose
2800
+ * transform cannot be composed that is `null`, the shape is placed at its resolved box, and
2801
+ * scaling by 1 would print its points in raw path units.
1624
2802
  */
1625
- function customGeometryPoints(shape, custom) {
1626
- const frame = shape.absoluteFrame;
2803
+ function customGeometryPoints(box, custom) {
1627
2804
  const points = [];
1628
2805
  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;
2806
+ const sx = path.w > 0 ? box.width / path.w : 1;
2807
+ const sy = path.h > 0 ? box.height / path.h : 1;
1631
2808
  const px = (v) => emu(v * sx);
1632
2809
  const py = (v) => emu(v * sy);
1633
2810
  for (const command of path.commands) switch (command.cmd) {
@@ -1692,7 +2869,10 @@ function customGeometryPoints(shape, custom) {
1692
2869
  * ever moved. The write API has `addMedia`, so this is a read-side gap, and it is declared
1693
2870
  * rather than silently flattened.
1694
2871
  */
1695
- function pictureCall(shape, notes, assets) {
2872
+ function pictureCall(shape, ctx) {
2873
+ const { notes, assets } = ctx;
2874
+ const box = frameOf(shape, notes);
2875
+ if (!box) return null;
1696
2876
  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
2877
  const partName = shape.svgPartName ?? shape.imagePartName;
1698
2878
  if (!partName) {
@@ -1706,20 +2886,20 @@ function pictureCall(shape, notes, assets) {
1706
2886
  }
1707
2887
  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
2888
  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;
2889
+ const crop = cropOption(shape.crop);
2890
+ if (crop === void 0 && isRectSet(shape.crop)) notes.note("image.crop", "approximated", "unwritable", "this picture's source crop (a:srcRect) has an inset outside the 0–100% `crop` accepts, or opposite insets that leave no image, so the picture is emitted uncropped");
1710
2891
  return {
1711
2892
  method: "addImage",
1712
2893
  args: [{
1713
2894
  ...compact({
1714
- ...positionOptions(shape, notes),
2895
+ ...positionOptions(box),
1715
2896
  ...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
- }
2897
+ ...identityOptions(shape),
2898
+ ...pictureClipOptions(shape, box, notes),
2899
+ shadow: shadowOption(shape, notes),
2900
+ hyperlink: shapeHyperlinkOption(shape, ctx),
2901
+ line: lineOption(shape, notes),
2902
+ crop
1723
2903
  }),
1724
2904
  data: asset
1725
2905
  }],
@@ -1741,6 +2921,22 @@ const CONNECTOR_LINE_KEYS = [
1741
2921
  "endArrowType"
1742
2922
  ];
1743
2923
  /**
2924
+ * Every other `ShapeLineProps` key: what a connector's flat stroke options cannot express, which
2925
+ * the `connector.line` note declares.
2926
+ *
2927
+ * Typed as the complement of {@link CONNECTOR_LINE_KEYS}, so a stroke option the write API gains
2928
+ * does not compile here until it is placed, carried or noted. Unplaced, it would reach `lineOption`'s
2929
+ * output and the copy loop would drop it without a note. `type` is the fill kind, and gets this far
2930
+ * only as `'gradient'`: a `'none'` outline has already become a `line` shape.
2931
+ */
2932
+ const CONNECTOR_FLATTENED_LINE_KEYS = {
2933
+ type: true,
2934
+ gradient: true,
2935
+ pattern: true,
2936
+ transparency: true,
2937
+ cap: true
2938
+ };
2939
+ /**
1744
2940
  * A `Connector` becomes `addConnector`.
1745
2941
  *
1746
2942
  * The endpoints are the one real translation here. OOXML gives a connector a bounding box
@@ -1752,17 +2948,22 @@ const CONNECTOR_LINE_KEYS = [
1752
2948
  * in the output, so a connector lands unbound and stops following its shapes when they move.
1753
2949
  */
1754
2950
  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");
2951
+ const drawn = connectorLine(shape, notes, "connector endpoint bindings reference source shape ids with no counterpart in the output, so the connector lands unbound and no longer follows its shapes");
2952
+ if (!drawn) return null;
2953
+ if (drawn.line?.["type"] === "none") return {
2954
+ method: "addShape",
2955
+ args: ["line", drawn.options],
2956
+ ...nameOf(shape)
2957
+ };
2958
+ const frame = drawn.box;
1758
2959
  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) ?? {};
2960
+ const line = drawn.line ?? {};
1760
2961
  const stroke = {};
1761
2962
  for (const key of CONNECTOR_LINE_KEYS) {
1762
2963
  const value = line[key];
1763
2964
  if (value !== void 0) stroke[key] = value;
1764
2965
  }
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");
2966
+ if (Object.keys(CONNECTOR_FLATTENED_LINE_KEYS).some((key) => line[key] !== void 0)) notes.note("connector.line", "flattened", "unwritable", "a connector styles its stroke with flat colour, width, dash and arrowhead options, which cannot express a gradient stroke, a stroke transparency or a line cap, so a gradient or a transparency falls back to a plain line and a cap to the default");
1766
2967
  const right = frame.left + frame.width;
1767
2968
  const bottom = frame.top + frame.height;
1768
2969
  return {
@@ -1773,7 +2974,7 @@ function connectorCall(shape, notes) {
1773
2974
  y1: emu(shape.flipV ? bottom : frame.top),
1774
2975
  x2: emu(shape.flipH ? frame.left : right),
1775
2976
  y2: emu(shape.flipV ? frame.top : bottom),
1776
- objectName: shape.name || void 0,
2977
+ ...identityOptions(shape),
1777
2978
  ...stroke
1778
2979
  }) ?? {}],
1779
2980
  ...nameOf(shape)
@@ -1797,20 +2998,21 @@ function unwritableFramePayload(shape) {
1797
2998
  return "unknown";
1798
2999
  }
1799
3000
  /** A `GraphicFrame` hosts a table or a chart; both have their own mapper. */
1800
- function graphicFrameCall(shape, notes, assets) {
3001
+ function graphicFrameCall(shape, ctx) {
3002
+ const { notes } = ctx;
1801
3003
  const table = shape.table;
1802
- if (table) return tableCall(shape, table, notes, assets);
3004
+ if (table) return tableCall(shape, table, ctx);
1803
3005
  const chart = shape.chart;
1804
3006
  if (chart) return chartCall(shape, chart, notes);
1805
3007
  switch (unwritableFramePayload(shape)) {
1806
3008
  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");
3009
+ notes.note(UNWRITABLE_FRAME_CONSTRUCTS.chartEx, "dropped", "unwritable", "an extended chart (waterfall, funnel, box-and-whisker, …) has a full reader but no write-API counterpart");
1808
3010
  return null;
1809
3011
  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");
3012
+ notes.note(UNWRITABLE_FRAME_CONSTRUCTS.diagram, "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
3013
  return null;
1812
3014
  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");
3015
+ notes.note(UNWRITABLE_FRAME_CONSTRUCTS.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
3016
  return null;
1815
3017
  }
1816
3018
  }
@@ -1825,17 +3027,25 @@ function graphicFrameCall(shape, notes, assets) {
1825
3027
  * contents when resized and the emitted one will not — so that case, and only that case,
1826
3028
  * is noted.
1827
3029
  *
1828
- * Charts, tables, media and placeholders are excluded from groups by the write API, so a
1829
- * group holding one loses that child.
3030
+ * Charts, tables and placeholders are excluded from groups by the write API, so a group holding
3031
+ * one loses that child. A connector is kept as the `line` shape it paints as, which a group takes.
1830
3032
  */
1831
- function groupCall(shape, notes, assets) {
3033
+ function groupCall(shape, ctx) {
3034
+ const { notes } = ctx;
3035
+ const box = frameOf(shape, notes);
3036
+ if (!box) return null;
1832
3037
  const children = [];
1833
3038
  for (const child of shape.shapes) {
1834
- const call = shapeCall(child, notes, assets);
3039
+ if (isConnector(child)) {
3040
+ const drawn = connectorLine(child, forShape(ctx, child).notes, "this connector is bound to shapes; it is re-authored as a line shape inside its group, which paints the same stroke but no longer follows them");
3041
+ if (drawn) children.push(lineShape(drawn.options));
3042
+ continue;
3043
+ }
3044
+ const call = shapeCall(child, ctx);
1835
3045
  if (!call) continue;
1836
3046
  const tagged = asGroupChild(call);
1837
3047
  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`);
3048
+ notes.note("group.child", "dropped", "unsupported", `addGroup accepts no ${kindLabel(call.method)} child (a group takes text, shapes, images and other groups), so this child is omitted from the group`);
1839
3049
  continue;
1840
3050
  }
1841
3051
  children.push(tagged);
@@ -1849,12 +3059,16 @@ function groupCall(shape, notes, assets) {
1849
3059
  return {
1850
3060
  method: "addGroup",
1851
3061
  args: [children, compact({
1852
- ...positionOptions(shape, notes),
1853
- objectName: shape.name || void 0
3062
+ ...positionOptions(box),
3063
+ ...identityOptions(shape)
1854
3064
  }) ?? {}],
1855
3065
  ...nameOf(shape)
1856
3066
  };
1857
3067
  }
3068
+ /** A call's object kind as a note's prose names it: `addImage` is `image`. */
3069
+ function kindLabel(method) {
3070
+ return method.replace(/^add/, "").toLowerCase();
3071
+ }
1858
3072
  /**
1859
3073
  * Re-tag a call as a `GroupChildProps` entry, or `null` when the content type is one
1860
3074
  * `addGroup` excludes. The tagged form is a single-key object, not a `{ type, args }` pair.
@@ -1896,10 +3110,6 @@ function commonShapeVariant(call) {
1896
3110
  default: return null;
1897
3111
  }
1898
3112
  }
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
3113
  /**
1904
3114
  * One shape from a **layout's** shape tree as a `SlideMasterObject` — an entry in the
1905
3115
  * `objects` array of a `defineSlideMaster` call.
@@ -1918,16 +3128,12 @@ function nameOf(shape) {
1918
3128
  * coordinates. `addTable` genuinely has nowhere to go, and is the one kind this reports as
1919
3129
  * lost.
1920
3130
  */
1921
- function masterObject(shape, notes, assets) {
3131
+ function masterObject(shape, ctx) {
1922
3132
  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);
3133
+ const drawn = connectorLine(shape, forShape(ctx, shape).notes, "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");
3134
+ return drawn && lineShape(drawn.options);
1929
3135
  }
1930
- const call = shapeCall(shape, notes, assets);
3136
+ const call = shapeCall(shape, ctx);
1931
3137
  if (!call) return null;
1932
3138
  const shared = commonShapeVariant(call);
1933
3139
  if (shared) return shared;
@@ -1936,7 +3142,10 @@ function masterObject(shape, notes, assets) {
1936
3142
  case "addChart": {
1937
3143
  const options = second !== null && typeof second === "object" && !Array.isArray(second) && !isAssetRef(second) ? second : null;
1938
3144
  const type = options?.["type"];
1939
- if (type === void 0 || first === void 0) return null;
3145
+ if (type === void 0 || first === void 0) {
3146
+ forShape(ctx, shape).notes.note("decoration", "dropped", "unwritable", "a chart on a slide layout that names no type or carries no series has no defineSlideMaster({ objects }) variant, so it is dropped from the layout the output rebuilds");
3147
+ return null;
3148
+ }
1940
3149
  return { chart: compact({
1941
3150
  type,
1942
3151
  data: first,
@@ -1944,52 +3153,74 @@ function masterObject(shape, notes, assets) {
1944
3153
  }) ?? {} };
1945
3154
  }
1946
3155
  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`);
3156
+ forShape(ctx, shape).notes.note("decoration", "dropped", "unwritable", `a ${kindLabel(call.method)} 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
3157
  return null;
1949
3158
  }
1950
3159
  }
1951
3160
  /**
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.
3161
+ * A `Connector` as the `line` shape it paints as, or `null` when it produces nothing: it is hidden,
3162
+ * or nothing places it. Either way the note has been recorded.
3163
+ *
3164
+ * A layout reaches a connector only this way. The alternative is dropping every connector a layout
3165
+ * carries, and in the fixture corpus that is 18 of the 45 shapes every layout actually draws,
3166
+ * because PowerPoint's line tool authors a `p:cxnSp`, so a plain rule under a title is usually a
3167
+ * connector rather than a shape. Drawing it as a `line` preset paints the identical stroke, and a
3168
+ * shape carries `rotate`, which `addConnector` does not. What that costs is the endpoint binding,
3169
+ * which on a layout costs nothing that was working: `a:stCxn`/`a:endCxn` reference shapes in the
3170
+ * layout's own tree.
3171
+ *
3172
+ * `addConnector` reads its endpoints and stroke from the same place, so the frame, the binding note
3173
+ * and the stroke are decided once for every way a connector is emitted. The binding note's prose
3174
+ * is the caller's, since what the loss means depends on where the connector lands.
3175
+ * @param shape - the connector
3176
+ * @param notes - the scope bound to this connector
3177
+ * @param bindingDetail - the endpoint-binding note's detail, for where this connector lands
1966
3178
  */
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),
3179
+ function connectorLine(shape, notes, bindingDetail) {
3180
+ if (shape.hidden) {
3181
+ noteHidden(notes);
3182
+ return null;
3183
+ }
3184
+ const box = frameOf(shape, notes);
3185
+ if (!box) return null;
3186
+ if (shape.startConnection || shape.endConnection) notes.note("connector.binding", "dropped", "unsupported", bindingDetail);
3187
+ const line = lineOption(shape, notes);
3188
+ return {
3189
+ box,
3190
+ line,
3191
+ options: compactRequired({
3192
+ ...positionOptions(box),
1974
3193
  ...transformOptions(shape),
1975
- line: lineOption(shape, notes),
3194
+ line,
1976
3195
  shadow: shadowOption(shape, notes),
1977
- glow: glowOption(shape),
1978
- objectName: shape.name || void 0
1979
- }) ?? {}
3196
+ glow: glowOption(shape, notes),
3197
+ ...identityOptions(shape)
3198
+ })
3199
+ };
3200
+ }
3201
+ /**
3202
+ * A connector's `line` shape as a key-tagged descriptor, the form a layout object and a group child
3203
+ * both take. Through `compact` like the other arms, so every emitted descriptor spells its keys in
3204
+ * the same order and the printed script does not read as two different mappers.
3205
+ */
3206
+ function lineShape(options) {
3207
+ return { shape: compact({
3208
+ type: "line",
3209
+ options
1980
3210
  }) ?? {} };
1981
3211
  }
1982
3212
  //#endregion
1983
3213
  //#region src/script/from-read/chrome.ts
1984
3214
  /** Build the chrome IR for a deck. */
1985
- function chromeToIr(pres, notes, assets) {
3215
+ function chromeToIr(pres, ctx) {
3216
+ const { notes } = ctx;
1986
3217
  const masters = pres.masters();
1987
3218
  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
3219
  const theme = masters[0]?.theme ?? null;
1989
3220
  recordMasterLosses(masters, theme, notes);
1990
3221
  return {
1991
3222
  theme: themeToIr(theme),
1992
- masters: layoutsToIr(masters, notes, assets)
3223
+ masters: layoutsToIr(masters, ctx)
1993
3224
  };
1994
3225
  }
1995
3226
  /** The theme's colour scheme and font faces, in `ThemeProps` spelling. */
@@ -2030,7 +3261,8 @@ function recordMasterLosses(masters, theme, notes) {
2030
3261
  * called "Title and Content", and two `defineSlideMaster` calls sharing a title would make
2031
3262
  * every slide bind to whichever won.
2032
3263
  */
2033
- function layoutsToIr(masters, notes, assets) {
3264
+ function layoutsToIr(masters, ctx) {
3265
+ const { notes, assets } = ctx;
2034
3266
  const out = [];
2035
3267
  const used = /* @__PURE__ */ new Set();
2036
3268
  let placeholderCount = 0;
@@ -2042,8 +3274,7 @@ function layoutsToIr(masters, notes, assets) {
2042
3274
  const flattened = flattenAttributeValue(layout.name);
2043
3275
  if (flattened !== layout.name) renamed.push(`${JSON.stringify(layout.name)} → ${JSON.stringify(flattened)}`);
2044
3276
  const base = flattened || `Layout ${index + 1}`;
2045
- let title = base;
2046
- for (let suffix = 2; used.has(title); suffix++) title = `${base} (${suffix})`;
3277
+ const title = uniqueTitle(base, used);
2047
3278
  if (title !== base) collided.push(`${JSON.stringify(base)} → ${JSON.stringify(title)}`);
2048
3279
  used.add(title);
2049
3280
  const placeholders = layout.placeholders.length;
@@ -2051,8 +3282,8 @@ function layoutsToIr(masters, notes, assets) {
2051
3282
  placeholderCount += placeholders;
2052
3283
  placeholderLayouts++;
2053
3284
  }
2054
- const background = backgroundProps(layout.background ?? master.background, notes, assets);
2055
- const objects = layoutObjects(layout, notes, assets);
3285
+ const background = backgroundIr(layout.background ?? master.background, notes, assets, MASTER_BACKGROUND);
3286
+ const objects = layoutObjects(layout, ctx);
2056
3287
  out.push({
2057
3288
  layoutIndex: index,
2058
3289
  props: compact({
@@ -2088,21 +3319,25 @@ function layoutsToIr(masters, notes, assets) {
2088
3319
  * mapper speaks lands under `layout.` and stays attributable to the layout tier. Constructs
2089
3320
  * written below are therefore relative to that prefix.
2090
3321
  */
2091
- function layoutObjects(layout, notes, assets) {
3322
+ function layoutObjects(layout, ctx) {
2092
3323
  const out = [];
2093
- collectObjects(layout.shapes, out, layoutShapeScope(notes), assets);
3324
+ collectObjects(layout.shapes, out, {
3325
+ ...ctx,
3326
+ notes: layoutShapeScope(ctx.notes),
3327
+ slideNumberOf: () => null
3328
+ });
2094
3329
  return out;
2095
3330
  }
2096
3331
  /** {@link layoutObjects}'s walk, recursing through groups. */
2097
- function collectObjects(shapes, out, notes, assets) {
3332
+ function collectObjects(shapes, out, ctx) {
2098
3333
  for (const shape of shapes) {
2099
3334
  if (isPlaceholderShape(shape.element_)) continue;
2100
3335
  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);
3336
+ forShape(ctx, shape).notes.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");
3337
+ collectObjects(shape.shapes, out, ctx);
2103
3338
  continue;
2104
3339
  }
2105
- const object = masterObject(shape, notes, assets);
3340
+ const object = masterObject(shape, ctx);
2106
3341
  if (object !== null) out.push(object);
2107
3342
  }
2108
3343
  }
@@ -2120,63 +3355,17 @@ function collectObjects(shapes, out, notes, assets) {
2120
3355
  function flattenAttributeValue(value) {
2121
3356
  return value.replace(/[\t\r\n]+/g, " ");
2122
3357
  }
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
3358
  //#endregion
2147
3359
  //#region src/script/from-read/transition.ts
2148
3360
  /**
2149
3361
  * The transitions the write API can author, as a lookup.
2150
3362
  *
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
- };
3363
+ * Built from the tuple the write API's `TransitionType` is derived from and its emitter validates
3364
+ * against, so the three cannot disagree. A list kept here by hand would drift out of date the
3365
+ * moment the writer gained a name, and the symptom would be a printed script that does not compile
3366
+ * or a transition dropped with a note it did not deserve.
3367
+ */
3368
+ const WRITABLE_TYPES = new Set(TRANSITION_TYPES);
2180
3369
  /**
2181
3370
  * `true` when the write API has a name for this transition.
2182
3371
  *
@@ -2191,7 +3380,7 @@ const WRITABLE_TYPES = {
2191
3380
  * authors a `p14:fade` to keep this branch honest, since no fixture can.
2192
3381
  */
2193
3382
  function isWritable(info) {
2194
- return info.namespace === "p" && Object.hasOwn(WRITABLE_TYPES, info.type);
3383
+ return info.namespace === "p" && WRITABLE_TYPES.has(info.type);
2195
3384
  }
2196
3385
  /**
2197
3386
  * Map a slide's transition, recording a note for anything that does not survive.
@@ -2199,7 +3388,8 @@ function isWritable(info) {
2199
3388
  * Returns `undefined` both when the slide has no transition and when it has one this
2200
3389
  * library cannot author — the difference between those two is what the note carries.
2201
3390
  */
2202
- function transitionToIr(slide, notes, assets) {
3391
+ function transitionToIr(slide, ctx) {
3392
+ const { notes, assets } = ctx;
2203
3393
  const info = slide.transition;
2204
3394
  if (!info) return void 0;
2205
3395
  if (!isWritable(info)) {
@@ -2251,26 +3441,21 @@ function soundPartName(slide, embedRid) {
2251
3441
  }
2252
3442
  //#endregion
2253
3443
  //#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
- };
3444
+ /** An RFC 7230 token without `$`, a quote or a backtick. */
3445
+ const MEDIA_TOKEN = String.raw`[\w!#%&*+.^|~-]+`;
3446
+ /** `type/subtype` with optional `; name=value` parameters, each part a {@link MEDIA_TOKEN}. */
3447
+ const MEDIA_TYPE = new RegExp(String.raw`^${MEDIA_TOKEN}\/${MEDIA_TOKEN}(?:[ \t]*;[ \t]*${MEDIA_TOKEN}=${MEDIA_TOKEN})*$`);
3448
+ /**
3449
+ * A part's content type as the IR may carry it.
3450
+ *
3451
+ * The value is the deck's own `[Content_Types].xml` attribute, and it reaches a printed script
3452
+ * inside a `data:` URI. Anything outside the media-type grammar is not a type a renderer could
3453
+ * act on anyway, so it becomes `application/octet-stream` rather than text the printer has to
3454
+ * be trusted to neutralise.
3455
+ */
3456
+ function assetContentType(contentType) {
3457
+ return MEDIA_TYPE.test(contentType) ? contentType : "application/octet-stream";
3458
+ }
2274
3459
  /**
2275
3460
  * Collects media as shapes reference it, assigning each part a stable sequential name.
2276
3461
  *
@@ -2296,8 +3481,9 @@ var Assets = class {
2296
3481
  if (existing) return existing;
2297
3482
  const part = this.pres.opc.part(partName);
2298
3483
  if (!part) return null;
2299
- const extension = ASSET_EXTENSIONS[part.contentType] ?? partName.split(".").pop() ?? "bin";
2300
- const kind = part.contentType.startsWith("audio/") ? "audio" : "image";
3484
+ const contentType = assetContentType(part.contentType);
3485
+ const extension = assetFilenameExtension(contentType) ?? partName.split(".").pop() ?? "bin";
3486
+ const kind = contentType.startsWith("audio/") ? "audio" : "image";
2301
3487
  const index = (this.#counts.get(kind) ?? 0) + 1;
2302
3488
  this.#counts.set(kind, index);
2303
3489
  const name = `${kind}${index}.${extension}`;
@@ -2305,8 +3491,8 @@ var Assets = class {
2305
3491
  this.#byPartName.set(partName, ref);
2306
3492
  this.#assets.push({
2307
3493
  name,
2308
- contentType: part.contentType,
2309
- bytes: part.bytes
3494
+ contentType,
3495
+ bytes: part.serialize()
2310
3496
  });
2311
3497
  return ref;
2312
3498
  }
@@ -2323,15 +3509,24 @@ function readModelToIr(pres) {
2323
3509
  const assets = new Assets(pres);
2324
3510
  const size = pres.slideSize;
2325
3511
  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`);
3512
+ if (!size) deckScope.note("deck.slideSize", "approximated", "unsupported", `this deck declares no slide size, so the output uses the ${inches(ABSENT_SLIDE_SIZE_EMU.widthEmu)}in × ${inches(ABSENT_SLIDE_SIZE_EMU.heightEmu)}in PowerPoint opens such a deck at`);
2327
3513
  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));
3514
+ const slideNumbers = new Map(pres.slides.map((slide, index) => [slide.partName, index + 1]));
3515
+ const slideNumberOf = (partName) => slideNumbers.get(partName) ?? null;
3516
+ const chrome = chromeToIr(pres, {
3517
+ notes: deckScope,
3518
+ assets,
3519
+ slideNumberOf
3520
+ });
3521
+ const slides = pres.slides.map((slide, index) => slideToIr(slide, index + 1, collector, {
3522
+ assets,
3523
+ slideNumberOf
3524
+ }, layouts));
2330
3525
  return {
2331
3526
  slideSize: size ? {
2332
3527
  widthEmu: size.widthEmu,
2333
3528
  heightEmu: size.heightEmu
2334
- } : DEFAULT_SLIDE_SIZE,
3529
+ } : { ...ABSENT_SLIDE_SIZE_EMU },
2335
3530
  props: deckProps(pres, deckScope),
2336
3531
  chrome,
2337
3532
  slides,
@@ -2340,26 +3535,37 @@ function readModelToIr(pres) {
2340
3535
  };
2341
3536
  }
2342
3537
  /**
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.
3538
+ * The core properties with no write-API setter, each as a note's prose names it.
3539
+ *
3540
+ * Typed as every `CoreProperties` key {@link DECK_PROPS} does not read, less the three timestamps,
3541
+ * which describe the file rather than the deck. A property the read model gains does not compile
3542
+ * here until it is placed.
3543
+ */
3544
+ const UNCARRIED_CORE_PROPS = {
3545
+ keywords: "keywords",
3546
+ description: "description",
3547
+ category: "category",
3548
+ contentStatus: "content status",
3549
+ lastModifiedBy: "last-modified-by"
3550
+ };
3551
+ /**
3552
+ * Deck properties, reduced to the {@link DECK_PROPS} fields this converter can carry: they have a
3553
+ * write-API setter AND the read model reports them. The core fields in
3554
+ * {@link UNCARRIED_CORE_PROPS} have no setter. All of them survive anyway in a template-anchored
3555
+ * output, since the source deck itself becomes the template; this is only a loss for a standalone
3556
+ * one.
2347
3557
  */
2348
3558
  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
- }) ?? {};
3559
+ const parts = {
3560
+ core: pres.coreProperties,
3561
+ app: pres.appProperties
3562
+ };
3563
+ if (Object.keys(UNCARRIED_CORE_PROPS).some((key) => parts.core[key] !== void 0)) notes.note("deck.docProps", "dropped", "unwritable", `only ${andList(DECK_PROP_KEYS)} round-trip; ${andList(Object.values(UNCARRIED_CORE_PROPS))} have no write-API setter`);
3564
+ const read = (key) => {
3565
+ const source = DECK_PROPS[key];
3566
+ return source.part === "core" ? parts.core[source.key] : parts.app[source.key];
3567
+ };
3568
+ return compact(Object.fromEntries(DECK_PROP_KEYS.map((key) => [key, read(key)]))) ?? {};
2363
3569
  }
2364
3570
  /**
2365
3571
  * The deck's layouts keyed by partname, each resolved to what a printer needs to bind to
@@ -2379,23 +3585,28 @@ function layoutGallery(pres) {
2379
3585
  nameIsUnique: (nameCounts.get(handle.name) ?? 0) === 1
2380
3586
  }]));
2381
3587
  }
2382
- function slideToIr(slide, number, collector, assets, layouts) {
3588
+ function slideToIr(slide, number, collector, deck, layouts) {
2383
3589
  const notes = scopeNotes(collector, number);
3590
+ const ctx = {
3591
+ ...deck,
3592
+ notes
3593
+ };
3594
+ const { assets } = deck;
2384
3595
  const layoutPartName = slide.layout?.partName;
2385
3596
  const base = {
2386
3597
  number,
2387
3598
  layout: (layoutPartName === void 0 ? void 0 : layouts.get(layoutPartName)) ?? null,
2388
3599
  hidden: slide.hidden,
2389
3600
  ...slide.name ? { name: slide.name } : {},
2390
- ...backgroundOf(slide, notes),
3601
+ ...backgroundOf(slide, notes, assets),
2391
3602
  ...notesOf(slide, notes)
2392
3603
  };
2393
- const transition = transitionToIr(slide, notes, assets);
3604
+ const transition = transitionToIr(slide, ctx);
2394
3605
  const carried = hasUnwritableContent(slide);
2395
3606
  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
3607
  const calls = [];
2397
3608
  for (const shape of slide.shapes) {
2398
- const call = shapeCall(shape, notes, assets);
3609
+ const call = shapeCall(shape, ctx);
2399
3610
  if (call) calls.push(call);
2400
3611
  }
2401
3612
  recordAnimationLoss(slide, notes);
@@ -2422,25 +3633,41 @@ function hasUnwritableContent(slide) {
2422
3633
  });
2423
3634
  return check(slide.shapes);
2424
3635
  }
2425
- function backgroundOf(slide, notes) {
3636
+ /**
3637
+ * A slide's own background, as the write API's `background` option.
3638
+ *
3639
+ * Only a slide-scoped background is carried: an inherited one comes with the layout, so
3640
+ * re-authoring it onto the slide would pin a colour that should keep following the layout.
3641
+ */
3642
+ function backgroundOf(slide, notes, assets) {
2426
3643
  const background = slide.background;
2427
3644
  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
- }
3645
+ const ir = backgroundIr(background, notes, assets, SLIDE_BACKGROUND);
3646
+ return ir ? { background: ir } : {};
2435
3647
  }
2436
3648
  function notesOf(slide, notes) {
2437
3649
  const text = slide.notesText;
2438
3650
  if (!text) return {};
2439
3651
  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");
3652
+ if (frame && frame.paragraphs.some(hasNotesFormatting)) notes.note("notes.formatting", "flattened", "unsupported", "speaker notes carry as plain text; per-run formatting, hyperlinks, bullets and indent levels, and the notes slide's own placeholder geometry, do not");
2441
3653
  return { notesText: text };
2442
3654
  }
2443
3655
  /**
3656
+ * `true` when a speaker-notes paragraph states anything plain text cannot carry.
3657
+ *
3658
+ * `addNotes` takes plain text. The note used to fire for bold and italic only, so underline,
3659
+ * strike, colour, size, face, highlight, a hyperlink, a bullet or an indent level went with
3660
+ * nothing said. An explicit off (`u="none"`, `strike="noStrike"`, a suppressed bullet) is how plain
3661
+ * text reads anyway, so it does not count. Reads accessors only: `runOptions` would record colour
3662
+ * notes for text that emits no call.
3663
+ */
3664
+ function hasNotesFormatting(paragraph) {
3665
+ if (paragraph.level > 0) return true;
3666
+ const bullet = paragraph.bulletDetail;
3667
+ if (bullet !== null && bullet.kind !== "none") return true;
3668
+ return paragraph.runs.some((run) => run.bold === true || run.italic === true || run.underline !== null && run.underline !== "none" || run.strike !== null && run.strike !== "noStrike" || run.color !== null || run.schemeColor !== null || run.fontSizePt !== null || run.fontName !== null || run.highlight !== null || run.hyperlink !== null);
3669
+ }
3670
+ /**
2444
3671
  * Build animations, which live in a slide-scoped `p:timing` element rather than in any
2445
3672
  * shape's subtree.
2446
3673
  *
@@ -2510,12 +3737,30 @@ function printString(value) {
2510
3737
  break;
2511
3738
  default: {
2512
3739
  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;
3740
+ out += code < 32 || code >= 127 && code <= 159 || code === 8232 || code === 8233 || code >= 55296 && code <= 57343 ? "\\u" + code.toString(16).padStart(4, "0") : char;
2514
3741
  }
2515
3742
  }
2516
3743
  return `${out}'`;
2517
3744
  }
2518
3745
  /**
3746
+ * What ends a `//` comment: CR, LF, and U+2028 and U+2029, which ECMAScript counts as line
3747
+ * terminators too. Built from code points because those two, written raw, would end a regex
3748
+ * literal just as they end a comment.
3749
+ */
3750
+ const LINE_TERMINATORS = new RegExp(`[\r\n${String.fromCharCode(8232, 8233)}]`, "g");
3751
+ /**
3752
+ * Deck text made safe to sit inside a comment: the `/* … *\/` banner or a `//` line.
3753
+ *
3754
+ * A comment is the one place the printer puts deck text with no literal around it, and two
3755
+ * things end one early, turning the rest into code: `*\/` closes a block comment, and a line
3756
+ * terminator closes a line comment. `JSON.stringify` escapes CR and LF but leaves U+2028 and
3757
+ * U+2029 raw, and ECMAScript counts both as line terminators, so a layout named across a line
3758
+ * separator broke the script it was printed into.
3759
+ */
3760
+ function commentText(text) {
3761
+ return text.replace(/\*\//g, "*\\/").replace(LINE_TERMINATORS, " ");
3762
+ }
3763
+ /**
2519
3764
  * A number literal. `String` gives the shortest text that reads back as the same double,
2520
3765
  * which is exactly the guarantee needed: the printed script must produce the same value the
2521
3766
  * IR held. Non-finite values cannot satisfy that (`NaN` and `Infinity` are identifiers, not
@@ -2579,6 +3824,24 @@ function printArguments(prefix, args, indentLevel, asset) {
2579
3824
  const inner = " ".repeat(indentLevel + 1);
2580
3825
  return `${pad}${prefix}(\n${args.map((arg) => `${inner}${printValue(arg, indentLevel + 1, asset)}`).join(",\n")}\n${pad})`;
2581
3826
  }
3827
+ //#endregion
3828
+ //#region src/script/print/common.ts
3829
+ /**
3830
+ * The parts of printing a script that do not depend on which tier is being printed.
3831
+ *
3832
+ * Two printers target the same IR — one anchored to the source deck as a template, one
3833
+ * standalone — and they differ only in how a deck and its chrome are set up. Everything after
3834
+ * that is identical: a slide's statements, how image bytes reach the script, and how the
3835
+ * fidelity notes are rendered into the banner. Keeping that here is not tidiness. A slide body
3836
+ * printed two slightly different ways would mean the round-trip oracle certifies one tier and
3837
+ * only resembles a check on the other, and the drift would be invisible in review.
3838
+ */
3839
+ /**
3840
+ * Default import specifier for an emitted script — this package's *published* name, which is
3841
+ * not its directory name. Getting it wrong produces a script that prints and typechecks and
3842
+ * then fails at `import`, so it is pinned by a test rather than left to a literal.
3843
+ */
3844
+ const PACKAGE_NAME = "pptx-ts";
2582
3845
  /**
2583
3846
  * Apply the defaults both printers share.
2584
3847
  *
@@ -2591,7 +3854,7 @@ function resolvePrintOptions(options) {
2591
3854
  outputPath: options.outputPath ?? "./output.pptx",
2592
3855
  assetDir: (options.assetDir ?? "./assets").replace(/\/$/, ""),
2593
3856
  assetMode: options.assets ?? "file",
2594
- packageName: options.packageName ?? "@shbernal/ts-pptx"
3857
+ packageName: options.packageName ?? PACKAGE_NAME
2595
3858
  };
2596
3859
  }
2597
3860
  /** Width the header comment's prose wraps at, leaving room for the ` * ` prefix. */
@@ -2608,8 +3871,9 @@ function assetIdentifiers(ir) {
2608
3871
  const out = /* @__PURE__ */ new Map();
2609
3872
  const used = /* @__PURE__ */ new Set();
2610
3873
  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}`;
3874
+ const raw = asset.name.replace(/\.[^.]*$/, "").replace(/[^A-Za-z0-9_$]/g, "_");
3875
+ const base = /^[A-Za-z_$]/.test(raw) ? raw : `_${raw}`;
3876
+ let identifier = base;
2613
3877
  let suffix = 2;
2614
3878
  while (used.has(identifier)) identifier = `${base}_${suffix++}`;
2615
3879
  used.add(identifier);
@@ -2617,42 +3881,56 @@ function assetIdentifiers(ir) {
2617
3881
  }
2618
3882
  return out;
2619
3883
  }
2620
- /** Resolve an {@link AssetRef} to the `const` identifier standing for its bytes. */
3884
+ /**
3885
+ * Resolve an {@link AssetRef} to the `const` identifier standing for its bytes, remembering which
3886
+ * assets were printed.
3887
+ *
3888
+ * `printed`, not `DeckIr.assets`, is what a tier binds and returns. The chrome registers its
3889
+ * pictures through the same resolver as the slides, so a layout's background and logo are in
3890
+ * `DeckIr.assets`, and so are a carried slide's images. The template-anchored tier prints neither
3891
+ * (its template *is* the chrome, and a carried slide is copied), yet it bound every asset with a
3892
+ * `readFile`, returned the bytes, counted them in the banner and, in `'inline'` mode, inlined
3893
+ * them. So each tier prints its statements first and binds what they referenced.
3894
+ */
2621
3895
  function assetPrinter(identifiers) {
2622
- return (ref) => {
3896
+ const printed = /* @__PURE__ */ new Set();
3897
+ const print = (ref) => {
2623
3898
  const identifier = identifiers.get(ref.$asset);
2624
3899
  if (!identifier) throw new InvalidOptionError("script/unresolved-asset-reference", `Asset reference ${ref.$asset} has no entry in DeckIr.assets`);
3900
+ printed.add(ref.$asset);
2625
3901
  return identifier;
2626
3902
  };
3903
+ return {
3904
+ print,
3905
+ printed
3906
+ };
2627
3907
  }
2628
3908
  /**
2629
- * One `const` per asset, each holding a complete `data:` URI.
3909
+ * One `const` per printed asset, each holding a complete `data:` URI.
2630
3910
  *
2631
3911
  * Both modes bind the same *kind* of value — a data URI string — so the call sites are
2632
3912
  * identical and `addImage`'s `data` option is used either way. Only where the bytes come
2633
3913
  * from changes. Rewriting the option key to `path` in file mode would have the printer
2634
3914
  * reshaping an argument, which is precisely the thing that keeps the IR checkable.
3915
+ *
3916
+ * Bound in `DeckIr.assets` order rather than first-printed order, so the output stays a function
3917
+ * of the IR. `printed` comes from {@link assetPrinter}.
2635
3918
  */
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 [];
3919
+ function printAssetBindings(ir, identifiers, assetDir, mode, printed, inlineComment = "// Media bytes, inlined so this script needs no files but its template.") {
3920
+ const bound = printedAssets(ir, printed);
3921
+ if (bound.length === 0) return [];
2638
3922
  const lines = mode === "file" ? ["// Media bytes, loaded from the asset directory beside this script."] : [inlineComment];
2639
- for (const asset of ir.assets) {
3923
+ for (const asset of bound) {
2640
3924
  const identifier = identifiers.get(asset.name);
2641
3925
  if (!identifier) continue;
2642
3926
  if (mode === "file") {
2643
3927
  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)}`)}`);
3928
+ const prefix = printString(`data:${asset.contentType};base64,`);
3929
+ lines.push(`const ${identifier} = ${prefix} + (await readFile(here(${path}))).toString('base64')`);
3930
+ } else lines.push(`const ${identifier} = ${printString(`data:${asset.contentType};base64,${bytesToBase64(asset.bytes)}`)}`);
2646
3931
  }
2647
3932
  return lines;
2648
3933
  }
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
3934
  /**
2657
3935
  * One slide: a comment, the statement that creates it, then its slide-level properties and
2658
3936
  * its calls in z-order.
@@ -2665,7 +3943,7 @@ function printSlide(slide, parts, collector, printAsset) {
2665
3943
  const identifier = `slide${slide.number}`;
2666
3944
  const lines = [
2667
3945
  "",
2668
- `// ${parts.comment}`,
3946
+ `// ${commentText(parts.comment)}`,
2669
3947
  `const ${identifier} = ${parts.construction}`
2670
3948
  ];
2671
3949
  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`);
@@ -2674,7 +3952,7 @@ function printSlide(slide, parts, collector, printAsset) {
2674
3952
  if (slide.transition) lines.push(`${identifier}.transition = ${printValue(asIrValue(slide.transition), 0, printAsset)}`);
2675
3953
  if (slide.notesText !== void 0) lines.push(printArguments(`${identifier}.addNotes`, [slide.notesText], 0, printAsset));
2676
3954
  for (const call of slide.calls) {
2677
- if (call.sourceName) lines.push(`// ${call.sourceName.replace(/\s+/g, " ")}`);
3955
+ if (call.sourceName) lines.push(`// ${commentText(call.sourceName.replace(/\s+/g, " "))}`);
2678
3956
  lines.push(printArguments(`${identifier}.${call.method}`, call.args, 0, printAsset));
2679
3957
  }
2680
3958
  return lines;
@@ -2695,7 +3973,7 @@ function header(prose, notes) {
2695
3973
  }
2696
3974
  return [
2697
3975
  "/*",
2698
- ...lines.map((line) => line ? ` * ${line}` : " *"),
3976
+ ...lines.map((line) => line ? ` * ${commentText(line)}` : " *"),
2699
3977
  " */"
2700
3978
  ].join("\n");
2701
3979
  }
@@ -2742,62 +4020,77 @@ function wrap(text, width, continuation) {
2742
4020
  * @param {DeckIr} ir - the IR being printed, for its assets
2743
4021
  * @param {AssetMode} assetMode - how image bytes reach the script
2744
4022
  * @param {FidelityNote[]} notes - the losses that apply to this output
4023
+ * @param {ReadonlySet<string>} printed - the assets the script's statements reference
2745
4024
  * @returns {PrintedScript} the finished script
2746
4025
  */
2747
- function printedScript(lines, ir, assetMode, notes) {
4026
+ function printedScript(lines, ir, assetMode, notes, printed) {
2748
4027
  return {
2749
4028
  code: lines.join("\n"),
2750
- assets: new Map(assetMode === "file" ? ir.assets.map((asset) => [asset.name, asset.bytes]) : []),
4029
+ assets: new Map(assetMode === "file" ? printedAssets(ir, printed).map((asset) => [asset.name, asset.bytes]) : []),
2751
4030
  notes
2752
4031
  };
2753
4032
  }
2754
- //#endregion
2755
- //#region src/script/print/script.ts
4033
+ /** The IR's assets a script printed, in `DeckIr.assets` order. */
4034
+ function printedAssets(ir, printed) {
4035
+ return ir.assets.filter((asset) => printed.has(asset.name));
4036
+ }
2756
4037
  /**
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.");
4038
+ * The emitted script's imports and its `here` helper, which every path it reads or writes goes
4039
+ * through.
4040
+ *
4041
+ * Both tiers printed this block line by line and differed only in what they import: the
4042
+ * template-anchored one reads and writes files and opens the source deck through the read half,
4043
+ * while the standalone one reads files only when it has media to load.
4044
+ * @param packageName - the import specifier, from the print options
4045
+ * @param imports - the `node:fs/promises` names to import, and whether to import the read half
4046
+ */
4047
+ function printPreamble(packageName, imports) {
4048
+ return [
4049
+ ...imports.fs.length > 0 ? [`import { ${imports.fs.join(", ")} } from 'node:fs/promises'`] : [],
4050
+ "import { fileURLToPath } from 'node:url'",
4051
+ `import TsPptx from ${printString(packageName)}`,
4052
+ ...imports.read ? [`import { Presentation } from ${printString(`${packageName}/read`)}`] : [],
4053
+ "",
4054
+ "/** Resolve a path against this script rather than against the working directory. */",
4055
+ "const here = (name: string): string => fileURLToPath(new URL(name, import.meta.url))"
4056
+ ];
2790
4057
  }
4058
+ /**
4059
+ * The three lines that construct a generator and size it to the source deck.
4060
+ *
4061
+ * Both tiers print them, and both used to convert the EMU slide size themselves — reaching
4062
+ * across the seam into `from-read/values.ts` to do it. `ir.ts` keeps `slideSize` in EMU
4063
+ * deliberately, because converting there "would introduce a rounding decision at the wrong
4064
+ * layer"; the printer is not that layer either, so the conversion lives in `script/units.ts`
4065
+ * and the two-line preamble lives here.
4066
+ *
4067
+ * `appendSlides` compares slide sizes exactly and throws when they differ, so a change to the
4068
+ * printed inches fails loudly rather than quietly.
4069
+ * @param size - the deck's slide size, in EMU
4070
+ * @param indent - the prefix each line takes; the tier-B generator emits them inside a function
4071
+ */
4072
+ function printLayoutSetup(size, indent = "") {
4073
+ return [
4074
+ `${indent}const pptx = new TsPptx()`,
4075
+ `${indent}pptx.defineLayout({ name: 'source', width: ${inches(size.widthEmu)}, height: ${inches(size.heightEmu)} })`,
4076
+ `${indent}pptx.layout = 'source'`
4077
+ ];
4078
+ }
4079
+ //#endregion
4080
+ //#region src/script/print/script.ts
2791
4081
  /** Turn a deck IR into a runnable, template-anchored TypeScript module. */
2792
4082
  function printScript(ir, options = {}) {
2793
4083
  const templatePath = options.templatePath ?? "./template.pptx";
2794
4084
  const { outputPath, assetDir, assetMode, packageName } = resolvePrintOptions(options);
2795
4085
  const collector = new NoteCollector();
2796
4086
  const assetNames = assetIdentifiers(ir);
2797
- const body = printSlides$1(ir, collector, assetPrinter(assetNames));
4087
+ const assets = assetPrinter(assetNames);
4088
+ const printAsset = assets.print;
4089
+ const body = printSlides$1(ir, collector, printAsset);
2798
4090
  const needsSource = ir.slides.some((slide) => slide.source === "carried");
2799
4091
  const needsFallbackLayout = ir.slides.some((slide) => slide.source === "authored" && slide.layout === null);
2800
- const notes = [...ir.fidelity.filter((note) => !isTemplateCarried(note.construct)), ...collector.notes];
4092
+ const carried = new Set(ir.slides.filter((slide) => slide.source === "carried").map((slide) => slide.number));
4093
+ const notes = [...ir.fidelity, ...collector.notes].filter((note) => noteAppliesTo(note.construct, "template") && !transcriptionOfCarried(note, carried));
2801
4094
  const lines = [
2802
4095
  header([
2803
4096
  "Generated from a .pptx by ts-pptx/script.",
@@ -2806,17 +4099,14 @@ function printScript(ir, options = {}) {
2806
4099
  "layouts, theme and document properties are the originals, byte for byte. Only the",
2807
4100
  "slide content below was rebuilt through the public write API.",
2808
4101
  "",
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}` : ""}.`,
4102
+ `Expects ${templatePath} (the source deck, unmodified) beside this file${assetMode === "file" && assets.printed.size > 0 ? `, plus ${assets.printed.size} media file(s) in ${assetDir}` : ""}.`,
2810
4103
  `Writes ${outputPath}. Needs an ESM context — it uses top-level await.`
2811
4104
  ], notes),
2812
4105
  "",
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))",
4106
+ ...printPreamble(packageName, {
4107
+ fs: ["readFile", "writeFile"],
4108
+ read: true
4109
+ }),
2820
4110
  "",
2821
4111
  "// The source deck is the template: fromTemplate strips its slides and leaves its masters,",
2822
4112
  "// layouts, theme and document properties exactly as they were.",
@@ -2824,10 +4114,22 @@ function printScript(ir, options = {}) {
2824
4114
  ];
2825
4115
  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
4116
  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);
4117
+ const assetLines = printAssetBindings(ir, assetNames, assetDir, assetMode, assets.printed);
2828
4118
  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);
4119
+ 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 {", ...printLayoutSetup(ir.slideSize, " "), " return pptx", "}", ...body, "", `await writeFile(here(${printString(outputPath)}), await deck.save())`, "");
4120
+ return printedScript(lines, ir, assetMode, notes, assets.printed);
4121
+ }
4122
+ /** The notes a slide this tier copies whole still reports: why it was copied, and what forced it. */
4123
+ const CARRIED_SLIDE_CONSTRUCTS = /* @__PURE__ */ new Set(["slide.carried", ...Object.values(UNWRITABLE_FRAME_CONSTRUCTS)]);
4124
+ /**
4125
+ * `true` for a note about transcribing a slide this tier copies instead.
4126
+ *
4127
+ * The IR maps a carried slide's shapes anyway, for the standalone tier, and each records its own
4128
+ * losses. `importSlide` copies the slide whole, so here those losses do not happen, and
4129
+ * `slide.carried` already excuses every difference on it, so the round trip could not tell.
4130
+ */
4131
+ function transcriptionOfCarried(note, carried) {
4132
+ return note.slideNumber !== null && carried.has(note.slideNumber) && !CARRIED_SLIDE_CONSTRUCTS.has(note.construct);
2831
4133
  }
2832
4134
  /**
2833
4135
  * The slide statements, batched into generators, preceded by the layout handles they bind to.
@@ -2898,7 +4200,7 @@ function printAppend(generatorName, first, collector, declared, declarations) {
2898
4200
  const identifier = `layout${layout.index}`;
2899
4201
  if (!declared.has(layout.index)) {
2900
4202
  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}')`);
4203
+ declarations.push(`const ${identifier} = deck.layouts()[${layout.index}] // ${commentText(JSON.stringify(layout.name))}`, `if (!${identifier}) throw new Error('the template has no layout at position ${layout.index}')`);
2902
4204
  }
2903
4205
  return `await deck.appendSlides(${generatorName}, { layout: ${identifier} })`;
2904
4206
  }
@@ -2915,30 +4217,50 @@ function printAuthoredSlide(slide, generatorName, collector, printAsset) {
2915
4217
  //#endregion
2916
4218
  //#region src/script/print/standalone.ts
2917
4219
  /**
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.
4220
+ * {@link DeckIr} → a runnable TypeScript module that depends on nothing but this package.
4221
+ * **Tier A.**
4222
+ *
4223
+ * **The trade, stated once.** The template-anchored printer (`printScript`) reuses the source
4224
+ * `.pptx` and gets the deck's entire design back byte for byte, at the cost of shipping that
4225
+ * file alongside the script and of leaving the design uneditable. This one ships a single
4226
+ * `.ts` file. Everything the source deck's chrome contained has to be *re-authored* from what
4227
+ * the read model exposes, and the read model deliberately exposes the property tiers a slide
4228
+ * inherits — colours, fonts, placeholder geometry — not the parts a slide merely sits on top
4229
+ * of. So the output is a deck that renders its slides faithfully and wears a different suit.
4230
+ *
4231
+ * **The ceiling is measured, not estimated, and no amount of printer work moves it.** Two
4232
+ * constructs are unreachable from *both* directions: `a:fmtScheme` (nothing reads it, and the
4233
+ * write path emits a hardcoded Office one) and `p:txStyles` (no reader, though
4234
+ * `SlideMasterProps.textStyles` could author it). A third, `p:clrMap`, is readable and has no
4235
+ * setter. Each is a fidelity note here and a `pass` in the other tier, which is the whole
4236
+ * reason the other tier shipped first. A **master's** own decoration joins them for a
4237
+ * structural reason rather than a reading one: `defineSlideMaster` creates a layout, so there
4238
+ * is no master shape tree to write to. A *layout's* decoration used to be on this list and is
4239
+ * not any more — it is transcribed into that layout's `objects`.
4240
+ *
4241
+ * **One structural difference beyond the chrome.** A slide marked `carried` has no source
4242
+ * package to be carried from here, so it is transcribed like any other and loses only the
4243
+ * construct that made it uncarryable — which each shape has already declared for itself. That
4244
+ * is why {@link DeckIr}'s calls are populated even for a carried slide.
2925
4245
  */
2926
- const NOT_APPLICABLE = /* @__PURE__ */ new Set(["slide.carried"]);
2927
4246
  /** Turn a deck IR into a runnable TypeScript module that needs no template. */
2928
4247
  function printStandaloneScript(ir, options = {}) {
2929
4248
  const { outputPath, assetDir, assetMode, packageName } = resolvePrintOptions(options);
2930
4249
  const collector = new NoteCollector();
2931
4250
  const assetNames = assetIdentifiers(ir);
2932
- const printAsset = assetPrinter(assetNames);
4251
+ const assets = assetPrinter(assetNames);
4252
+ const printAsset = assets.print;
2933
4253
  const masters = printMasters(ir, collector, printAsset);
2934
4254
  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;
4255
+ const props = printDocProps(ir, collector);
4256
+ const theme = Object.keys(ir.chrome.theme).length > 0 ? printValue(ir.chrome.theme, 0, printAsset) : null;
4257
+ const notes = [...ir.fidelity, ...collector.notes].filter((note) => noteAppliesTo(note.construct, "standalone"));
4258
+ const needsReadFile = assetMode === "file" && assets.printed.size > 0;
2937
4259
  const lines = [
2938
4260
  header([
2939
4261
  "Generated from a .pptx by ts-pptx/script.",
2940
4262
  "",
2941
- `Standalone: this script needs nothing but ${packageName}${needsReadFile ? ` and ${ir.assets.length} media file(s) in ${assetDir}` : ""}.`,
4263
+ `Standalone: this script needs nothing but ${packageName}${needsReadFile ? ` and ${assets.printed.size} media file(s) in ${assetDir}` : ""}.`,
2942
4264
  "The deck's theme, layouts and slide content are all re-authored through the public write",
2943
4265
  "API, so every one of them is editable here — and the parts of the original design the read",
2944
4266
  "model cannot see are gone. They are listed below. For a byte-identical design at the cost",
@@ -2948,44 +4270,31 @@ function printStandaloneScript(ir, options = {}) {
2948
4270
  `Writes ${outputPath}. Needs an ESM context — it uses top-level await.`
2949
4271
  ], notes),
2950
4272
  "",
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))",
4273
+ ...printPreamble(packageName, {
4274
+ fs: needsReadFile ? ["readFile"] : [],
4275
+ read: false
4276
+ }),
2957
4277
  "",
2958
- "const pptx = new TsPptx()",
2959
- `pptx.defineLayout({ name: 'source', width: ${inches(ir.slideSize.widthEmu)}, height: ${inches(ir.slideSize.heightEmu)} })`,
2960
- "pptx.layout = 'source'"
4278
+ ...printLayoutSetup(ir.slideSize)
2961
4279
  ];
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);
4280
+ if (theme !== null) 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 = ${theme}`);
2965
4281
  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.");
4282
+ const assetLines = printAssetBindings(ir, assetNames, assetDir, assetMode, assets.printed, "// Media bytes, inlined so this script is a single self-contained file.");
2967
4283
  if (assetLines.length > 0) lines.push("", ...assetLines);
2968
4284
  if (masters.length > 0) lines.push("", ...masters);
2969
4285
  lines.push(...body, "", `await pptx.writeFile({ fileName: here(${printString(outputPath)}) })`, "");
2970
- return printedScript(lines, ir, assetMode, notes);
4286
+ return printedScript(lines, ir, assetMode, notes, assets.printed);
2971
4287
  }
2972
4288
  /**
2973
- * The document properties the write API can set.
4289
+ * The document properties the write API can set, the ones `DECK_PROPS` in `ir.ts` lists.
2974
4290
  *
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.
4291
+ * The rest the read model exposes are the loss `deck.docProps` already declares, and unlike the
4292
+ * template-anchored tier, here it is a real one, because nothing else carries them.
2978
4293
  */
2979
4294
  function printDocProps(ir, collector) {
2980
4295
  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`);
4296
+ const stamped = DECK_PROP_KEYS.filter((key) => !(key in ir.props));
4297
+ if (stamped.length > 0) scopeNotes(collector, null).note("deck.docPropsDefault", "approximated", "unwritable", `the source deck declares no ${andList(stamped)}, 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
4298
  if (entries.length === 0) return [];
2990
4299
  return ["// Document properties. Only these have write-API setters; see the fidelity notes.", ...entries.map(([key, value]) => `pptx.${key} = ${printString(value)}`)];
2991
4300
  }
@@ -3001,14 +4310,33 @@ function printDocProps(ir, collector) {
3001
4310
  * twice over: it is also what `addSlide({ masterTitle })` binds on.
3002
4311
  */
3003
4312
  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 [];
4313
+ const seeded = ir.chrome.masters.find(isSeedLayout);
4314
+ if (!seeded) scopeNotes(collector, null, uniqueTitle("DEFAULT", new Set(ir.chrome.masters.map((master) => master.props["title"]).filter((t) => typeof t === "string")))).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");
4315
+ const authored = ir.chrome.masters.filter((master) => master !== seeded);
4316
+ if (authored.length === 0) return [];
3006
4317
  return [
3007
4318
  "// One master per source layout, carrying its name, background and decoration. What a",
3008
4319
  "// 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))
4320
+ ...authored.map((master) => printArguments("pptx.defineSlideMaster", [master.props], 0, printAsset))
3010
4321
  ];
3011
4322
  }
4323
+ /**
4324
+ * What the layout the write path seeds every presentation with reads back as: blank, titled
4325
+ * `DEFAULT`, on the white its master's `p:bgRef` resolves to. `compact` sorts an IR object's keys,
4326
+ * so the serialized form is stable.
4327
+ */
4328
+ const SEED_LAYOUT_PROPS = JSON.stringify({
4329
+ background: { color: "FFFFFF" },
4330
+ title: "DEFAULT"
4331
+ });
4332
+ /**
4333
+ * Whether a source layout is the one the output's constructor seeds: first in the gallery, where
4334
+ * the seed sits, and identical to it. Anything else is re-authored, even a layout named `DEFAULT`,
4335
+ * because binding its slides to the seed would lose what sets it apart.
4336
+ */
4337
+ function isSeedLayout(master) {
4338
+ return master.layoutIndex === 0 && JSON.stringify(master.props) === SEED_LAYOUT_PROPS;
4339
+ }
3012
4340
  /** Every slide, in source order, each bound to the master its source layout became. */
3013
4341
  function printSlides(ir, collector, printAsset) {
3014
4342
  const byLayout = /* @__PURE__ */ new Map();
@@ -3087,17 +4415,6 @@ const IMPLIED_DEFAULTS = {
3087
4415
  dashType: "solid",
3088
4416
  textDirection: "horz"
3089
4417
  };
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
4418
  /** Reduce a deck IR to the comparable form. */
3102
4419
  function canonicalDeckIr(ir) {
3103
4420
  const digests = assetDigests(ir.assets);
@@ -3156,7 +4473,11 @@ function collectObjectNames(value, out) {
3156
4473
  * happen and, worse, would *miss* one where two images traded places.
3157
4474
  */
3158
4475
  function canonicalValue(value, digests) {
3159
- if (isAssetRef(value)) return { $asset: digests.get(value.$asset) ?? `unknown:${value.$asset}` };
4476
+ if (isAssetRef(value)) {
4477
+ const digest = digests.get(value.$asset);
4478
+ if (digest === void 0) throw new InvalidOptionError("script/unresolved-asset-reference", `Asset reference ${value.$asset} has no entry in DeckIr.assets, so it cannot be compared by content`);
4479
+ return { $asset: digest };
4480
+ }
3160
4481
  if (Array.isArray(value)) return value.map((item) => canonicalValue(item, digests));
3161
4482
  if (value === null || typeof value !== "object") return value;
3162
4483
  const blankRun = value["text"] === "" && typeof value["options"] === "object" && value["options"] !== null;
@@ -3175,7 +4496,7 @@ function canonicalValue(value, digests) {
3175
4496
  return out;
3176
4497
  }
3177
4498
  function isDefaultMargin(value) {
3178
- return Array.isArray(value) && value.length === DEFAULT_MARGIN.length && value.every((item, index) => item === DEFAULT_MARGIN[index]);
4499
+ return Array.isArray(value) && value.length === BODY_INSET_DEFAULTS_IN.length && value.every((item, index) => item === BODY_INSET_DEFAULTS_IN[index]);
3179
4500
  }
3180
4501
  /**
3181
4502
  * Content digests keyed by asset name, so the same bytes compare equal under any name.
@@ -3198,6 +4519,78 @@ function assetDigests(assets) {
3198
4519
  return out;
3199
4520
  }
3200
4521
  //#endregion
4522
+ //#region src/script/verify/align.ts
4523
+ /**
4524
+ * ts-pptx: pairing two lists of the same things for comparison.
4525
+ *
4526
+ * The round trip compares a deck against the deck its script produced, and both the slide
4527
+ * calls and the chrome masters have to be paired up before anything can be diffed. Position
4528
+ * alone is wrong the moment one item drops out: every later one shifts by one, and the report
4529
+ * fills with mismatches that are really one missing shape. So a stable key — a shape's
4530
+ * `objectName`, a master's title — aligns what it can, with position as the fallback.
4531
+ *
4532
+ * Both sides wrote this out, down to two near-identical `nextUnclaimed` closures, and they
4533
+ * differed in one respect that matters. `diffChrome`'s own reasoning is recorded there: a
4534
+ * mutation that removed layout-title deduplication left two layouts sharing a title and the
4535
+ * round trip came back clean, so its key has to identify exactly one item *on both sides*.
4536
+ * The call aligner checked the actual side only, and that reasoning was never carried back.
4537
+ * This is the both-sides rule, once.
4538
+ */
4539
+ /**
4540
+ * Pair `expected` with `actual`, keyed where the key is unambiguous and positional elsewhere.
4541
+ *
4542
+ * The result holds one entry per pairing, in expected order, then one `[null, item]` for each
4543
+ * unclaimed actual item. A `[item, null]` means the item was lost; a `[null, item]` means it
4544
+ * was added.
4545
+ *
4546
+ * @param expected - the source deck's items, in document order
4547
+ * @param actual - the round-tripped deck's items, in document order
4548
+ * @param keyOf - the alignment key, or `null` for an item that has none
4549
+ */
4550
+ function alignByKey(expected, actual, keyOf) {
4551
+ const uniqueBy = (items) => {
4552
+ const counts = /* @__PURE__ */ new Map();
4553
+ for (const item of items) {
4554
+ const key = keyOf(item);
4555
+ if (key !== null) counts.set(key, (counts.get(key) ?? 0) + 1);
4556
+ }
4557
+ const out = /* @__PURE__ */ new Map();
4558
+ for (const item of items) {
4559
+ const key = keyOf(item);
4560
+ if (key !== null && counts.get(key) === 1) out.set(key, item);
4561
+ }
4562
+ return out;
4563
+ };
4564
+ const byKey = uniqueBy(actual);
4565
+ const expectedKeys = uniqueBy(expected);
4566
+ const claimed = /* @__PURE__ */ new Set();
4567
+ const reserved = /* @__PURE__ */ new Set();
4568
+ for (const before of expected) {
4569
+ const key = keyOf(before);
4570
+ if (key === null || !expectedKeys.has(key)) continue;
4571
+ const named = byKey.get(key);
4572
+ if (named !== void 0) reserved.add(named);
4573
+ }
4574
+ let cursor = 0;
4575
+ const nextUnclaimed = () => {
4576
+ while (cursor < actual.length) {
4577
+ const candidate = actual[cursor++];
4578
+ if (candidate !== void 0 && !claimed.has(candidate) && !reserved.has(candidate)) return candidate;
4579
+ }
4580
+ return null;
4581
+ };
4582
+ const pairs = [];
4583
+ for (const before of expected) {
4584
+ const key = keyOf(before);
4585
+ const named = key !== null && expectedKeys.has(key) ? byKey.get(key) : void 0;
4586
+ const after = named !== void 0 && !claimed.has(named) ? named : nextUnclaimed();
4587
+ if (after !== null && after !== void 0) claimed.add(after);
4588
+ pairs.push([before, after ?? null]);
4589
+ }
4590
+ for (const after of actual) if (!claimed.has(after)) pairs.push([null, after]);
4591
+ return pairs;
4592
+ }
4593
+ //#endregion
3201
4594
  //#region src/script/verify/diff.ts
3202
4595
  /**
3203
4596
  * The round-trip oracle: does the deck a printed script builds match the deck it was read
@@ -3254,164 +4647,18 @@ const WRITER_DEFAULTS = {
3254
4647
  fontSize: "runs inherit size from an unread list style; the write path copies the shape size onto each run",
3255
4648
  options: "a run or cell that carried no options at all in the source now carries the ones below",
3256
4649
  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",
4650
+ "border.type": "a table cell border edge the write path completes; the source’s came from the style graph the read model cannot separate",
4651
+ "border.color": "a table cell border edge the write path completes; the source’s came from the style graph the read model cannot separate",
4652
+ "border.width": "a table cell border edge the write path completes; the source’s came from the style graph the read model cannot separate",
4653
+ "line.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
4654
  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",
4655
+ altText: "a picture with no alt text is described by its image path on the write side",
4656
+ "fill.type": "a fill with no explicit kind takes the write path’s solid default",
4657
+ "line.type": "an outline with no explicit kind takes the write path’s solid default",
3260
4658
  chartColors: "the write path assigns a series palette; the source’s came from the theme",
3261
4659
  dataLabelFormatCode: "a data label with no explicit number format takes the write path’s default",
3262
4660
  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"]
4661
+ ...Object.fromEntries(DECK_PROP_KEYS.map((key) => [`props.${key}`, `a deck that declares no ${key} gets the write path’s own`]))
3415
4662
  };
3416
4663
  /**
3417
4664
  * Every note construct this table knows how to match, for a test to check against. The
@@ -3419,7 +4666,7 @@ const NOTE_FIELDS = {
3419
4666
  * which is what a corpus check should use.
3420
4667
  */
3421
4668
  function knownNoteConstructs() {
3422
- return Object.keys(NOTE_FIELDS).sort();
4669
+ return Object.keys(NOTE_CONSTRUCTS).sort();
3423
4670
  }
3424
4671
  /**
3425
4672
  * Compare the deck a script was generated from against the deck it produced.
@@ -3433,7 +4680,12 @@ function diffDeckIr(expected, actual, notes) {
3433
4680
  const differences = [];
3434
4681
  if (expected.slideSize.widthEmu !== actual.slideSize.widthEmu) differences.push(scalarDifference(0, null, [], "slideSize.widthEmu", "widthEmu", expected.slideSize.widthEmu, actual.slideSize.widthEmu));
3435
4682
  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);
4683
+ diffValue(expected.props, actual.props, "props", "props", {
4684
+ slideNumber: 0,
4685
+ shapeName: null,
4686
+ nestedNames: [],
4687
+ out: differences
4688
+ });
3437
4689
  diffChrome(expected.chrome, actual.chrome, differences);
3438
4690
  const slideCount = Math.max(expected.slides.length, actual.slides.length);
3439
4691
  for (let index = 0; index < slideCount; index++) {
@@ -3458,9 +4710,9 @@ function diffDeckIr(expected, actual, notes) {
3458
4710
  }
3459
4711
  for (const difference of differences) difference.declaredBy = declaringNote(difference, notes);
3460
4712
  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;
4713
+ const accounted = (difference) => difference.declaredBy !== null || difference.kind === "added" && Object.keys(WRITER_DEFAULTS).some((spec) => specCovers(spec, difference, "suffix"));
3462
4714
  return {
3463
- slideCount: expected.slides.length,
4715
+ slideCount: Math.max(expected.slides.length, actual.slides.length),
3464
4716
  differences,
3465
4717
  undeclared: differences.filter((difference) => !accounted(difference)),
3466
4718
  declared: differences.filter((difference) => difference.declaredBy !== null),
@@ -3487,7 +4739,12 @@ function diffDeckIr(expected, actual, notes) {
3487
4739
  * key repeats costs nothing when the invariant holds and catches it when it does not.
3488
4740
  */
3489
4741
  function diffChrome(expected, actual, out) {
3490
- diffValue(expected.theme, actual.theme, "chrome.theme", "theme", 0, null, [], out);
4742
+ diffValue(expected.theme, actual.theme, "chrome.theme", "theme", {
4743
+ slideNumber: 0,
4744
+ shapeName: null,
4745
+ nestedNames: [],
4746
+ out
4747
+ });
3491
4748
  const report = (path, kind, before, after) => {
3492
4749
  out.push({
3493
4750
  slideNumber: 0,
@@ -3501,45 +4758,23 @@ function diffChrome(expected, actual, out) {
3501
4758
  declaredBy: null
3502
4759
  });
3503
4760
  };
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;
4761
+ alignByKey(expected.masters, actual.masters, titleOf).forEach(([before, after], index) => {
4762
+ if (before === null) {
4763
+ report(`chrome.masters[${index}]`, "added", void 0, after ?? void 0);
4764
+ return;
3512
4765
  }
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) {
4766
+ if (after === null) {
3519
4767
  report(`chrome.masters[${index}]`, "lost", before, void 0);
3520
4768
  return;
3521
4769
  }
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);
4770
+ diffValue(before, after, `chrome.masters[${index}]`, "master", {
4771
+ slideNumber: 0,
4772
+ shapeName: null,
4773
+ nestedNames: decorationNames(before, after),
4774
+ out
4775
+ });
3527
4776
  });
3528
4777
  }
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
4778
  /**
3544
4779
  * The `objectName`s of every decorative shape in a master's props, taking both sides so a
3545
4780
  * shape that dropped out is still named by the source's.
@@ -3564,8 +4799,13 @@ function diffSlide(expected, actual, out) {
3564
4799
  at("layoutName", "layoutName", expected.layoutName, actual.layoutName);
3565
4800
  at("background", "background", expected.background, actual.background);
3566
4801
  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)) {
4802
+ diffValue(expected.transition, actual.transition, "transition", "transition", {
4803
+ slideNumber: number,
4804
+ shapeName: null,
4805
+ nestedNames: [],
4806
+ out
4807
+ });
4808
+ for (const [before, after] of alignByKey(expected.calls, actual.calls, (call) => call.shapeName)) {
3569
4809
  const name = before?.shapeName ?? after?.shapeName ?? null;
3570
4810
  const nested = [.../* @__PURE__ */ new Set([...before?.containedNames ?? [], ...after?.containedNames ?? []])];
3571
4811
  const path = `calls[${before ? expected.calls.indexOf(before) : actual.calls.indexOf(after)}]`;
@@ -3584,55 +4824,16 @@ function diffSlide(expected, actual, out) {
3584
4824
  continue;
3585
4825
  }
3586
4826
  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]);
4827
+ diffValue(before.args, after.args, `${path}.args`, "args", {
4828
+ slideNumber: number,
4829
+ shapeName: name,
4830
+ nestedNames: nested,
4831
+ out
4832
+ });
3631
4833
  }
3632
- for (const call of actual) if (!claimed.has(call)) pairs.push([null, call]);
3633
- return pairs;
3634
4834
  }
3635
- function diffValue(expected, actual, path, field, slideNumber, shapeName, nestedNames, out) {
4835
+ function diffValue(expected, actual, path, field, site) {
4836
+ const { slideNumber, shapeName, nestedNames, out } = site;
3636
4837
  if (expected === actual) return;
3637
4838
  const kindOf = (value) => value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
3638
4839
  if (kindOf(expected) !== kindOf(actual)) {
@@ -3658,7 +4859,7 @@ function diffValue(expected, actual, path, field, slideNumber, shapeName, nested
3658
4859
  });
3659
4860
  continue;
3660
4861
  }
3661
- diffValue(before, after, `${path}[${index}]`, field, slideNumber, shapeName, nestedNames, out);
4862
+ diffValue(before, after, `${path}[${index}]`, field, site);
3662
4863
  }
3663
4864
  return;
3664
4865
  }
@@ -3682,7 +4883,7 @@ function diffValue(expected, actual, path, field, slideNumber, shapeName, nested
3682
4883
  });
3683
4884
  continue;
3684
4885
  }
3685
- diffValue(before, after, `${path}.${key}`, key, slideNumber, shapeName, nestedNames, out);
4886
+ diffValue(before, after, `${path}.${key}`, key, site);
3686
4887
  }
3687
4888
  return;
3688
4889
  }
@@ -3701,15 +4902,73 @@ function scalarDifference(slideNumber, shapeName, nestedNames, path, field, expe
3701
4902
  declaredBy: null
3702
4903
  };
3703
4904
  }
4905
+ /**
4906
+ * The dotted option path a difference sits at, array indices removed: `calls[4].args[1].line.width`
4907
+ * becomes `['calls', 'args', 'line', 'width']`.
4908
+ *
4909
+ * Both exclusion mechanisms used to match the terminal key alone, at any depth anywhere in the
4910
+ * IR — so `type` (written about a fill's solid default) excused an added `bullet.type`, waving
4911
+ * through a character bullet that came back as a numbered list; `title` (written about
4912
+ * `docProps`) excused an added chart title; and `width` (written about `a:ln/@w`) excused an
4913
+ * added table-cell bevel or border width. Matching a suffix keeps every bare key working where
4914
+ * it is unambiguous and lets a spec say WHICH `width` it means.
4915
+ */
4916
+ function pathKeys(difference) {
4917
+ const keys = difference.path.split(".").map((segment) => segment.replace(/\[[^\]]*\]/g, ""));
4918
+ return keys[keys.length - 1] === difference.field ? keys : [...keys, difference.field];
4919
+ }
4920
+ /**
4921
+ * Whether one exclusion spec covers a difference. `'*'` covers everything in the note's scope.
4922
+ * Anything else is matched against the difference's own path in one of two modes:
4923
+ *
4924
+ * - `'suffix'`: the spec is a dotted SUFFIX of the path. Used for a note scoped to a shape, which
4925
+ * the shape scoping has already confined to one call, so the spec only has to say which option
4926
+ * inside it. Used for {@link WRITER_DEFAULTS} too, whose entries are option names wherever the
4927
+ * write path materialises them.
4928
+ * - `'root'`: the spec is the dotted path of one node from the root of the diff (`background`,
4929
+ * `chrome.masters.title`), and covers that node only. A trailing `.*` extends it to everything
4930
+ * beneath the node as well (`transition.sound.*`). Used for a note with no shape, which nothing
4931
+ * else confines. Matched as a suffix, a slide or deck note's field names matched inside every
4932
+ * call in its scope: `master.background`'s `color` excused every run colour in the deck, and
4933
+ * `slide.transitionSound`'s `$asset` excused different image bytes on its slide. Exact by
4934
+ * default because `slide.transition` is `transition` and must not cover the sound inside it.
4935
+ */
4936
+ function specCovers(spec, difference, anchor) {
4937
+ if (spec === "*") return true;
4938
+ const wanted = spec.split(".");
4939
+ const keys = pathKeys(difference);
4940
+ if (anchor === "root") {
4941
+ const subtree = wanted[wanted.length - 1] === "*";
4942
+ const node = subtree ? wanted.slice(0, -1) : wanted;
4943
+ if (subtree ? node.length > keys.length : node.length !== keys.length) return false;
4944
+ return node.every((segment, index) => segment === keys[index]);
4945
+ }
4946
+ if (wanted.length > keys.length) return false;
4947
+ return wanted.every((segment, index) => segment === keys[keys.length - wanted.length + index]);
4948
+ }
4949
+ /**
4950
+ * Whether the shape a shape-scoped note names is the one a difference sits on: the call itself, or
4951
+ * a group child inside it.
4952
+ *
4953
+ * A shape with no name of its own is scoped as `''`, and its call carries no name to match, so a
4954
+ * note on one covers the unnamed calls on its slide and nothing else. Not a named shape, and not
4955
+ * the slide's own keys either, which a `*` note would otherwise excuse. An unnamed group child is
4956
+ * not matched through its group: the group's call names no such child.
4957
+ */
4958
+ function coversShape(shapeName, difference) {
4959
+ if (shapeName === "") return difference.shapeName === null && difference.path.startsWith("calls[");
4960
+ return shapeName === difference.shapeName || difference.nestedNames.includes(shapeName);
4961
+ }
3704
4962
  /** The first note that covers this difference, or `null` if none does. */
3705
4963
  function declaringNote(difference, notes) {
3706
4964
  for (const note of notes) {
3707
4965
  if (note.slideNumber !== null && note.slideNumber !== difference.slideNumber) continue;
3708
4966
  if (note.construct.startsWith("layout.") && difference.slideNumber !== 0) continue;
3709
- if (note.shapeName !== null && note.shapeName !== difference.shapeName && !difference.nestedNames.includes(note.shapeName)) continue;
4967
+ if (note.shapeName !== null && !coversShape(note.shapeName, difference)) continue;
3710
4968
  const fields = noteFields(note.construct);
3711
4969
  if (!fields) continue;
3712
- if (fields.includes("*") || fields.includes(difference.field)) return note;
4970
+ const anchor = note.shapeName === null ? "root" : "suffix";
4971
+ if (fields.some((field) => specCovers(field, difference, anchor))) return note;
3713
4972
  }
3714
4973
  return null;
3715
4974
  }
@@ -3721,7 +4980,8 @@ function declaringNote(difference, notes) {
3721
4980
  * the same field. Only the two constructs with no slide twin need entries of their own.
3722
4981
  */
3723
4982
  function noteFields(construct) {
3724
- return NOTE_FIELDS[construct] ?? (construct.startsWith("layout.") ? NOTE_FIELDS[construct.slice(7)] : void 0);
4983
+ const table = NOTE_CONSTRUCTS;
4984
+ return table[construct]?.fields ?? (construct.startsWith("layout.") ? table[construct.slice(7)]?.fields : void 0);
3725
4985
  }
3726
4986
  /**
3727
4987
  * `true` when the coverage table can match this construct — directly or through the
@@ -3736,6 +4996,6 @@ function brief(value) {
3736
4996
  return text.length > 80 ? `${text.slice(0, 80)}…` : text;
3737
4997
  }
3738
4998
  //#endregion
3739
- export { InternalError, InvalidOptionError, LAYOUT_NOTE_PREFIX, MediaError, PackageReadError, TsPptxError, UnsupportedFeatureError, canonicalDeckIr, diffDeckIr, isAssetRef, isKnownNoteConstruct, knownNoteConstructs, printScript, printStandaloneScript, readModelToIr };
4999
+ export { InternalError, InvalidOptionError, LAYOUT_NOTE_PREFIX, MediaError, PackageReadError, TsPptxError, UnsupportedFeatureError, canonicalDeckIr, diffDeckIr, isAssetRef, isKnownNoteConstruct, knownNoteConstructs, printScript, printStandaloneScript, readModelToIr, resetDiagnosticState, setDiagnosticHandler };
3740
5000
 
3741
5001
  //# sourceMappingURL=script.js.map