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