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