@openpresentation/opf-pptx 0.11.9 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -3
- package/dist/annotation-export.js +83 -0
- package/dist/annotation-provenance.js +246 -0
- package/dist/body-text-import.js +4 -1
- package/dist/chart-options.js +276 -0
- package/dist/chartex.js +42 -11
- package/dist/code-provenance.js +34 -10
- package/dist/content-topology.js +259 -52
- package/dist/document-provenance.js +77 -5
- package/dist/index.d.ts +17 -1
- package/dist/index.js +304 -67
- package/dist/logo-provenance.js +4 -3
- package/dist/master-themes.js +49 -0
- package/dist/metric-provenance.js +6 -3
- package/dist/native-text-style.js +17 -1
- package/dist/numbered-list.js +114 -0
- package/dist/package-fonts.js +6 -4
- package/dist/run-colors.js +118 -0
- package/dist/script-fonts.js +150 -17
- package/dist/table-cell-import.js +3 -1
- package/dist/table-import.js +3 -2
- package/package.json +6 -6
package/dist/content-topology.js
CHANGED
|
@@ -19,14 +19,26 @@
|
|
|
19
19
|
// | {form: 'blocks', blocks: Node[]}
|
|
20
20
|
// | {form: 'regions', regions: {[key]: Node}} // key = promoted region key
|
|
21
21
|
// Node = {t: 'group', id?, ext?, comp?, typed?, blocks: Node[]}
|
|
22
|
-
// | {t: 'leaf', k, id?, ext?, typed?, box?, lines?} // box = [x, y, w, h] reference px, 1 decimal
|
|
22
|
+
// | {t: 'leaf', k, id?, ext?, typed?, bullets?, box?, lines?, wrap?} // box = [x, y, w, h] reference px, 1 decimal
|
|
23
23
|
// typed = true when the authored block spelled out its `type`
|
|
24
|
+
// bullets = true when the authored list block used the `bullets` key (import names every list `items`), also under `type: 'text'`
|
|
24
25
|
// lines = N (>= 2) when a rich-text `text` payload exported as N native line
|
|
25
26
|
// shapes that are soft wraps of one paragraph (no hard break between
|
|
26
27
|
// them); import joins those N imported lines back into the one payload
|
|
28
|
+
// wrap = {lines: N, gaps} instead of `lines` when any line break was a hard
|
|
29
|
+
// break (RR-09; an importer before RR-09 ignores `wrap` and keeps the
|
|
30
|
+
// flat blocks rather than join lines without their separators). The
|
|
31
|
+
// N native line shapes rebuild the authored runs exactly once joined
|
|
32
|
+
// with gaps, the N - 1 separators between them: '' for a soft wrap
|
|
33
|
+
// (nothing deleted), otherwise the whitespace the break held ("\n",
|
|
34
|
+
// "\r\n", "\n\n" for a blank line, trimmed spaces); [whitespace, k]
|
|
35
|
+
// when the first k characters end the previous line's last run (the
|
|
36
|
+
// rest start the next one)
|
|
27
37
|
//
|
|
28
|
-
// Native line shapes of rich text carry no names or tags, so the
|
|
29
|
-
// recorded here as a count
|
|
38
|
+
// Native line shapes of rich text carry no names or tags, so the lines are
|
|
39
|
+
// recorded here as a count and the separators as whitespace only: never a word.
|
|
40
|
+
// A payload whose runs cannot be shown to rebuild from the lines and separators
|
|
41
|
+
// exactly is not marked, and import keeps the flat blocks and its diagnostic.
|
|
30
42
|
//
|
|
31
43
|
// Tags are untrusted input: every field is type-, count- and depth-checked
|
|
32
44
|
// before use, and the rebuilt slide still has to validate with the document.
|
|
@@ -55,37 +67,154 @@ const kindOfField = field => field === 'items' || field === 'bullets' ? 'list' :
|
|
|
55
67
|
const isGroup = block => object(block) && (block.type === 'group' || (block.type === undefined && Array.isArray(block.blocks)));
|
|
56
68
|
const round1 = value => Math.round(value * 10) / 10;
|
|
57
69
|
export const MAX_WRAPPED_LINES = 1000;
|
|
70
|
+
// A break between two native lines holds only spaces, tabs and line endings: never words.
|
|
71
|
+
export const MAX_GAP_LENGTH = 256;
|
|
72
|
+
const GAP_CHARACTER = /^[ \t\r\n]$/;
|
|
73
|
+
const GAP_TEXT = /^[ \t\r\n]*$/;
|
|
74
|
+
|
|
75
|
+
const styleKey = run => JSON.stringify(Object.keys(run).filter(key => key !== 'text').sort().map(key => [key, run[key]]));
|
|
76
|
+
const sameRunStyle = (a, b) => styleKey(a) === styleKey(b);
|
|
77
|
+
// Adjacent runs that differ only in text are one run: a wrap or a break cut them apart, nothing else did.
|
|
78
|
+
function normalizeRuns(runs) {
|
|
79
|
+
const merged = [];
|
|
80
|
+
for (const run of runs) {
|
|
81
|
+
if (!run.text) continue;
|
|
82
|
+
const last = merged.at(-1);
|
|
83
|
+
if (last && sameRunStyle(last, run)) last.text += run.text;
|
|
84
|
+
else merged.push({...run});
|
|
85
|
+
}
|
|
86
|
+
return merged;
|
|
87
|
+
}
|
|
88
|
+
const gapParts = gap => Array.isArray(gap) ? {text: gap[0], keep: gap[1]} : {text: gap ?? '', keep: 0};
|
|
58
89
|
|
|
59
90
|
/**
|
|
60
|
-
* The
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* or dropped
|
|
65
|
-
*
|
|
91
|
+
* The lines' text joined in order. `gaps[i]` is what separates line i from line
|
|
92
|
+
* i + 1: '' for a soft wrap, the whitespace a hard break held otherwise. A gap
|
|
93
|
+
* is a string (it starts the next line's first run) or [text, keep] (its first
|
|
94
|
+
* `keep` characters end the previous line's last run). Runs are never invented
|
|
95
|
+
* or dropped; at a seam the last run of one line and the first of the next
|
|
96
|
+
* merge when they differ only in text (a run the wrap cut in two), the same
|
|
97
|
+
* way a rejoined list does.
|
|
66
98
|
*/
|
|
67
|
-
export function
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
99
|
+
export function joinWrappedText(parts, gaps = []) {
|
|
100
|
+
if (parts.every(part => typeof part === 'string')) return parts.reduce((text, part, index) => text + (index ? gapParts(gaps[index - 1]).text : '') + part, '');
|
|
101
|
+
const joined = [];
|
|
102
|
+
parts.forEach((part, index) => {
|
|
103
|
+
const runs = (typeof part === 'string' ? [{text: part}] : part).map(run => ({...run}));
|
|
104
|
+
if (index) {
|
|
105
|
+
const {text, keep} = gapParts(gaps[index - 1]);
|
|
106
|
+
const last = joined.at(-1);
|
|
107
|
+
if (last && keep) last.text += text.slice(0, keep);
|
|
108
|
+
if (text.length > keep && runs.length) runs[0].text = text.slice(keep) + runs[0].text;
|
|
109
|
+
}
|
|
110
|
+
runs.forEach((run, position) => {
|
|
111
|
+
const last = joined.at(-1);
|
|
112
|
+
if (position === 0 && last && sameRunStyle(last, run)) last.text += run.text;
|
|
113
|
+
else joined.push(run);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
return joined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Walk the native line fragments of `value` (a string or authored runs): the character range each non-empty line covers
|
|
121
|
+
* and the whitespace each break deleted. Undefined when the lines do not account for the source exactly (leading or
|
|
122
|
+
* trailing whitespace outside the lines, dropped or changed characters, fragments of one line not contiguous, a break
|
|
123
|
+
* that is not whitespace or is too long). `gaps` is the separator list that rebuilds the authored runs from the lines
|
|
124
|
+
* (plain strings, or split [text, keep] entries when a break straddles two styles) and is undefined when no form of it
|
|
125
|
+
* can be shown to; `breaks` are the plain whitespace strings. `indexes` are the positions in `lines` of the non-empty ones.
|
|
126
|
+
*/
|
|
127
|
+
function analyzeLines(value, lines) {
|
|
128
|
+
if (!Array.isArray(lines) || lines.length > MAX_WRAPPED_LINES) return undefined;
|
|
129
|
+
const authored = Array.isArray(value) ? value : typeof value === 'string' ? [value] : null;
|
|
130
|
+
if (!authored) return undefined;
|
|
131
|
+
const runs = [];
|
|
132
|
+
for (const run of authored) {
|
|
74
133
|
const text = typeof run === 'string' ? run : object(run) ? run.text : undefined;
|
|
75
134
|
if (typeof text !== 'string') return undefined;
|
|
76
|
-
|
|
135
|
+
runs.push(typeof run === 'string' ? {text} : run);
|
|
77
136
|
}
|
|
137
|
+
const source = runs.map(run => run.text).join('');
|
|
78
138
|
let position = 0;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
139
|
+
const spans = [], breaks = [], indexes = [];
|
|
140
|
+
for (const [lineIndex, line] of lines.entries()) {
|
|
141
|
+
if (!Array.isArray(line?.fragments)) return undefined;
|
|
142
|
+
if (!line.fragments.length) continue;
|
|
143
|
+
const span = {start: position, end: position};
|
|
144
|
+
for (const [index, fragment] of line.fragments.entries()) {
|
|
145
|
+
// RR-34: a citation/footnote marker is generated text with no source range; it never starts a line.
|
|
146
|
+
if (fragment?.kind === 'marker') continue;
|
|
147
|
+
const text = fragment?.text;
|
|
148
|
+
if (typeof text !== 'string' || !text) return undefined;
|
|
149
|
+
let at = position;
|
|
150
|
+
// The first fragment of a line after the first may follow whitespace the break deleted.
|
|
151
|
+
if (index === 0 && spans.length) {
|
|
152
|
+
while (!source.startsWith(text, at)) {
|
|
153
|
+
if (at - position >= MAX_GAP_LENGTH || !GAP_CHARACTER.test(source[at] ?? '')) return undefined;
|
|
154
|
+
at += 1;
|
|
155
|
+
}
|
|
156
|
+
breaks.push(source.slice(position, at));
|
|
157
|
+
} else if (!source.startsWith(text, at)) return undefined;
|
|
158
|
+
if (index === 0) span.start = at;
|
|
159
|
+
position = at + text.length;
|
|
84
160
|
}
|
|
161
|
+
span.end = position;
|
|
162
|
+
spans.push(span);
|
|
163
|
+
indexes.push(lineIndex);
|
|
85
164
|
}
|
|
86
|
-
|
|
165
|
+
if (!spans.length || spans[0].start !== 0 || position !== source.length) return undefined;
|
|
166
|
+
// The offset at which each authored run starts.
|
|
167
|
+
const starts = [];
|
|
168
|
+
let offset = 0;
|
|
169
|
+
for (const run of runs) { starts.push(offset); offset += run.text.length; }
|
|
170
|
+
// The leading part of a break that the previous line's last run (and any run after it of the same style) holds.
|
|
171
|
+
const kept = breaks.map((text, index) => {
|
|
172
|
+
const from = spans[index].end, to = from + text.length;
|
|
173
|
+
const owner = starts.findLastIndex((start, run) => start < from && runs[run].text);
|
|
174
|
+
let keep = 0;
|
|
175
|
+
for (let run = owner; run >= 0 && run < runs.length && starts[run] < to; run += 1) {
|
|
176
|
+
if (run !== owner && runs[run].text && !sameRunStyle(runs[run], runs[owner])) break;
|
|
177
|
+
keep = Math.max(0, Math.min(text.length, starts[run] + runs[run].text.length - from));
|
|
178
|
+
}
|
|
179
|
+
return keep ? [text, keep] : text;
|
|
180
|
+
});
|
|
181
|
+
const pieces = spans.map(span => runs.flatMap((run, index) => {
|
|
182
|
+
const from = Math.max(span.start, starts[index]), to = Math.min(span.end, starts[index] + run.text.length);
|
|
183
|
+
return from < to ? [{...run, text: run.text.slice(from - starts[index], to - starts[index])}] : [];
|
|
184
|
+
}));
|
|
185
|
+
const signature = list => JSON.stringify(normalizeRuns(list).map(run => [styleKey(run), run.text]));
|
|
186
|
+
const authoredSignature = signature(runs);
|
|
187
|
+
// Plain strings (a break belongs to the next line's first run) when that already rebuilds the runs; the split form otherwise.
|
|
188
|
+
const gaps = [breaks, kept].find(candidate => signature(joinWrappedText(pieces, candidate)) === authoredSignature);
|
|
189
|
+
return {spans, breaks, gaps, indexes};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* How a rich-text `text` item exported as native line shapes, when the authored
|
|
194
|
+
* runs can be rebuilt from those lines exactly: `{lines, gaps?}`. `lines` is
|
|
195
|
+
* the number of native line shapes (blank lines have none). Each line break
|
|
196
|
+
* either deleted no characters (a soft wrap: gap '') or deleted whitespace
|
|
197
|
+
* only (a hard break: a newline, a blank line, trimmed spaces); `gaps` lists
|
|
198
|
+
* the N - 1 separators when any is not '' (see joinWrappedText) and is absent
|
|
199
|
+
* for pure soft wraps. Only whitespace is ever stored, never a word. Undefined
|
|
200
|
+
* for a single line or when the runs cannot be proven to rebuild (leading or
|
|
201
|
+
* trailing whitespace outside the lines, dropped or changed characters, a gap
|
|
202
|
+
* that spans a differently styled run): nothing then marks the leaf and import
|
|
203
|
+
* keeps the separate blocks.
|
|
204
|
+
*/
|
|
205
|
+
export function wrappedLines(item) {
|
|
206
|
+
if (item?.field !== 'text' || !Array.isArray(item.text?.richLines) || item.text.richLines.length < 2) return undefined;
|
|
207
|
+
const analysis = analyzeLines(item.value, item.text.richLines);
|
|
208
|
+
if (!analysis?.gaps || analysis.spans.length < 2) return undefined;
|
|
209
|
+
return {lines: analysis.spans.length, ...(analysis.breaks.some(Boolean) ? {gaps: analysis.gaps} : {})};
|
|
87
210
|
}
|
|
88
211
|
|
|
212
|
+
/** The number of native line shapes when the lines are consecutive soft wraps only (no hard break between them). */
|
|
213
|
+
export const softWrappedLines = item => {
|
|
214
|
+
const wrapped = wrappedLines(item);
|
|
215
|
+
return wrapped && !wrapped.gaps ? wrapped.lines : undefined;
|
|
216
|
+
};
|
|
217
|
+
|
|
89
218
|
function blockKind(block) {
|
|
90
219
|
if (typeof block.type === 'string' && block.type !== 'group') return CONTENT_KINDS.includes(block.type) ? block.type : null;
|
|
91
220
|
for (const field of ROOT_PAYLOAD_FIELDS) if (block[field] !== undefined) return kindOfField(field);
|
|
@@ -107,8 +236,8 @@ export function contentTopology(slide, items, slideIndex, report = () => {}) {
|
|
|
107
236
|
const boxes = new Map(), wrapped = new Map();
|
|
108
237
|
for (const item of items ?? []) if (typeof item?.path === 'string' && object(item.box)) {
|
|
109
238
|
boxes.set(item.path, item.box);
|
|
110
|
-
const lines =
|
|
111
|
-
if (lines) wrapped.set(item.path, lines);
|
|
239
|
+
const lines = wrappedLines(item);
|
|
240
|
+
if (lines) wrapped.set(item.path, wrapRecord(lines));
|
|
112
241
|
}
|
|
113
242
|
const base = `slides.${slideIndex}`;
|
|
114
243
|
let nodes = 0;
|
|
@@ -144,7 +273,8 @@ export function contentTopology(slide, items, slideIndex, report = () => {}) {
|
|
|
144
273
|
const leaf = identity(block, {t: 'leaf', k: kind}, path);
|
|
145
274
|
const box = leafBox(path);
|
|
146
275
|
if (box) leaf.box = box;
|
|
147
|
-
if (
|
|
276
|
+
if (block.bullets !== undefined && block.items === undefined && ['list', 'text'].includes(kind)) leaf.bullets = true;
|
|
277
|
+
if (kind === 'text' && wrapped.has(`${path}.text`)) Object.assign(leaf, wrapped.get(`${path}.text`));
|
|
148
278
|
return leaf;
|
|
149
279
|
};
|
|
150
280
|
try {
|
|
@@ -160,7 +290,7 @@ export function contentTopology(slide, items, slideIndex, report = () => {}) {
|
|
|
160
290
|
const fields = ROOT_PAYLOAD_FIELDS.filter(name => slide[name] !== undefined).map(field => {
|
|
161
291
|
const box = boxes.get(`${base}.${field}`);
|
|
162
292
|
const lines = field === 'text' ? wrapped.get(`${base}.text`) : undefined;
|
|
163
|
-
return box ? {field, box: [box.x, box.y, box.width, box.height].map(round1), ...
|
|
293
|
+
return box ? {field, box: [box.x, box.y, box.width, box.height].map(round1), ...lines} : {field};
|
|
164
294
|
});
|
|
165
295
|
if (!fields.length) return undefined;
|
|
166
296
|
if (fields.length === 1) return {form: 'root', ...fields[0]};
|
|
@@ -174,15 +304,97 @@ export function contentTopology(slide, items, slideIndex, report = () => {}) {
|
|
|
174
304
|
|
|
175
305
|
class TopologyError extends Error {}
|
|
176
306
|
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Hard line breaks inside list items
|
|
309
|
+
//
|
|
310
|
+
// A list exports one native shape per line of every entry: `OPF list <path> line N`, the entry's text lines (the first
|
|
311
|
+
// bulleted), then its description lines, numbered in that order. Import joins an entry's continuation lines without a
|
|
312
|
+
// separator (a soft wrap deletes no character) and tells description lines from text lines by size, so a newline inside an
|
|
313
|
+
// item was dropped and a blank line became an empty description. The slide record's `lists` stores, per list path, the
|
|
314
|
+
// whitespace each hard break deleted, keyed by the number of the line that follows the break:
|
|
315
|
+
//
|
|
316
|
+
// lists = [[path, [[line, gap], ...]], ...] // gap as in `wrap`: whitespace, or [whitespace, keep]
|
|
317
|
+
|
|
318
|
+
export const MAX_LIST_PATHS = 256;
|
|
319
|
+
export const MAX_LIST_BREAKS = 20000;
|
|
320
|
+
const LIST_PATH = /^slides\.\d+(?:\.[A-Za-z0-9_:+-]{1,64}){1,32}$/;
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The `lists` record of a slide from its composed geometry `items`, or undefined when no list item holds a hard break.
|
|
324
|
+
* `omit(path, reason)` names a list whose breaks could not be accounted for exactly (the characters of an item do not
|
|
325
|
+
* equal its native lines plus whitespace, leading or trailing whitespace outside the lines): import then joins that
|
|
326
|
+
* list's lines as before, and the export reports the omission.
|
|
327
|
+
*/
|
|
328
|
+
export function listLineBreaks(items, omit = () => {}) {
|
|
329
|
+
const lists = [];
|
|
330
|
+
for (const item of items ?? []) {
|
|
331
|
+
const entries = item?.text?.listEntries;
|
|
332
|
+
if ((item?.field !== 'items' && item?.field !== 'bullets') || !Array.isArray(entries) || typeof item.path !== 'string') continue;
|
|
333
|
+
const recorded = [];
|
|
334
|
+
let line = 0, failed = false;
|
|
335
|
+
for (const entry of entries) {
|
|
336
|
+
for (const [value, rich] of [[entry?.value, entry?.text?.richLines], [entry?.descriptionValue, entry?.description?.richLines]]) {
|
|
337
|
+
if (!Array.isArray(rich)) continue;
|
|
338
|
+
const first = line;
|
|
339
|
+
line += rich.length;
|
|
340
|
+
if (rich.filter(part => part?.fragments?.length).length < 2) continue;
|
|
341
|
+
const analysis = analyzeLines(value, rich);
|
|
342
|
+
if (!analysis) {
|
|
343
|
+
const source = (Array.isArray(value) ? value.map(run => typeof run === 'string' ? run : run?.text ?? '') : [typeof value === 'string' ? value : '']).join('');
|
|
344
|
+
if (/[\r\n]/.test(source)) failed = true;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const gaps = analysis.gaps ?? analysis.breaks;
|
|
348
|
+
gaps.forEach((gap, index) => { if (Array.isArray(gap) || gap) recorded.push([first + analysis.indexes[index + 1], gap]); });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (failed) omit(item.path, 'a line break inside a list item could not be recorded exactly');
|
|
352
|
+
if (recorded.length) lists.push([item.path, recorded]);
|
|
353
|
+
}
|
|
354
|
+
const total = lists.reduce((sum, [, entries]) => sum + entries.length, 0);
|
|
355
|
+
return lists.length && lists.length <= MAX_LIST_PATHS && total <= MAX_LIST_BREAKS ? lists : undefined;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Throws when `value` is not a well-formed `lists` record (untrusted input). Returns it otherwise. */
|
|
359
|
+
export function validateListBreaks(value) {
|
|
360
|
+
if (!Array.isArray(value) || !value.length || value.length > MAX_LIST_PATHS) throw Error('Invalid list breaks record.');
|
|
361
|
+
let total = 0;
|
|
362
|
+
const paths = new Set();
|
|
363
|
+
for (const entry of value) {
|
|
364
|
+
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || !LIST_PATH.test(entry[0]) || paths.has(entry[0]) || !Array.isArray(entry[1]) || !entry[1].length) throw Error('Invalid list breaks entry.');
|
|
365
|
+
paths.add(entry[0]);
|
|
366
|
+
let previous = -1;
|
|
367
|
+
for (const item of entry[1]) {
|
|
368
|
+
if (!Array.isArray(item) || item.length !== 2 || !Number.isSafeInteger(item[0]) || item[0] <= previous || item[0] > MAX_LIST_BREAKS || !validGap(item[1]) || (item[1] === '')) throw Error('Invalid list break.');
|
|
369
|
+
previous = item[0];
|
|
370
|
+
if (++total > MAX_LIST_BREAKS) throw Error('Too many list breaks.');
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return value;
|
|
374
|
+
}
|
|
375
|
+
|
|
177
376
|
// ---------------------------------------------------------------------------
|
|
178
377
|
// Import: validation
|
|
179
378
|
|
|
180
379
|
const validBox = value => value === undefined || (Array.isArray(value) && value.length === 4 && value.every(number => typeof number === 'number' && Number.isFinite(number) && Math.abs(number) < 1e7));
|
|
181
380
|
const validId = value => value === undefined || (typeof value === 'string' && value.length <= MAX_ID_LENGTH);
|
|
182
381
|
const validLines = value => value === undefined || (Number.isSafeInteger(value) && value >= 2 && value <= MAX_WRAPPED_LINES);
|
|
183
|
-
|
|
382
|
+
// gaps: one entry per line break, a whitespace string or [whitespace, characters kept by the previous line's last run].
|
|
383
|
+
const validGap = gap => (typeof gap === 'string' && gap.length <= MAX_GAP_LENGTH && GAP_TEXT.test(gap))
|
|
384
|
+
|| (Array.isArray(gap) && gap.length === 2 && typeof gap[0] === 'string' && gap[0].length <= MAX_GAP_LENGTH && GAP_TEXT.test(gap[0]) && Number.isSafeInteger(gap[1]) && gap[1] > 0 && gap[1] <= gap[0].length);
|
|
385
|
+
const validGaps = (gaps, lines) => gaps === undefined || (lines !== undefined && Array.isArray(gaps) && gaps.length === lines - 1 && gaps.every(validGap));
|
|
386
|
+
// `wrap` = {lines, gaps?} (RR-09) is the general record; `lines` alone (a count of pure soft wraps) is the form importers
|
|
387
|
+
// before RR-09 read, and the only one written for pure soft wraps. A leaf with a hard break stores `wrap` alone, so an
|
|
388
|
+
// older importer, which ignores the unknown key, keeps the flat blocks instead of joining lines without their separators.
|
|
389
|
+
const validWrap = wrap => wrap === undefined || (object(wrap) && Number.isSafeInteger(wrap.lines) && validLines(wrap.lines) && validGaps(wrap.gaps, wrap.lines));
|
|
390
|
+
const validWrapOf = value => validLines(value.lines) && validWrap(value.wrap) && ((value.lines === undefined && value.wrap === undefined) || value.k === 'text' || value.field === 'text');
|
|
391
|
+
/** The wrapped-line record of a stored leaf: {lines, gaps?}, or undefined. */
|
|
392
|
+
export const wrapOf = node => node.wrap ?? (node.lines === undefined ? undefined : {lines: node.lines});
|
|
393
|
+
/** The leaf fields that record `wrapped` ({lines, gaps?} from wrappedLines). */
|
|
394
|
+
const wrapRecord = wrapped => wrapped.gaps ? {wrap: wrapped} : {lines: wrapped.lines};
|
|
395
|
+
const validField = value => object(value) && ROOT_PAYLOAD_FIELDS.includes(value.field) && validBox(value.box) && validWrapOf(value);
|
|
184
396
|
/** The root payload leaves of a root-form record: [{field, box?}]. */
|
|
185
|
-
export const rootFields = topology => topology.fields ?? [{field: topology.field, box: topology.box, lines: topology.lines}];
|
|
397
|
+
export const rootFields = topology => topology.fields ?? [{field: topology.field, box: topology.box, lines: topology.lines, wrap: topology.wrap}];
|
|
186
398
|
|
|
187
399
|
/** Throws when `value` is not a well-formed topology record. Returns it otherwise. */
|
|
188
400
|
export function validateTopology(value) {
|
|
@@ -205,7 +417,8 @@ export function validateTopology(value) {
|
|
|
205
417
|
if (!CONTENT_KINDS.includes(item.k)) throw Error('Unknown content kind.');
|
|
206
418
|
if (item.comp !== undefined) throw Error('A content leaf has no composition.');
|
|
207
419
|
if (!validBox(item.box)) throw Error('Invalid content box.');
|
|
208
|
-
if (
|
|
420
|
+
if (item.bullets !== undefined && (item.bullets !== true || !['list', 'text'].includes(item.k))) throw Error('Invalid bullets flag.');
|
|
421
|
+
if (!validWrapOf(item)) throw Error('Invalid wrapped line record.');
|
|
209
422
|
};
|
|
210
423
|
switch (value.form) {
|
|
211
424
|
case 'root': {
|
|
@@ -217,7 +430,7 @@ export function validateTopology(value) {
|
|
|
217
430
|
}
|
|
218
431
|
if (!ROOT_PAYLOAD_FIELDS.includes(value.field)) throw Error('Unknown root payload field.');
|
|
219
432
|
if (!validBox(value.box)) throw Error('Invalid content box.');
|
|
220
|
-
if (!
|
|
433
|
+
if (!validWrapOf(value)) throw Error('Invalid wrapped line record.');
|
|
221
434
|
return value;
|
|
222
435
|
}
|
|
223
436
|
case 'blocks':
|
|
@@ -248,25 +461,16 @@ const containsOrigin = (box, bounds) => bounds.x >= box[0] - BOX_TOLERANCE && bo
|
|
|
248
461
|
const contains = (box, bounds) => containsOrigin(box, bounds)
|
|
249
462
|
&& bounds.x + bounds.width <= box[0] + box[2] + BOX_TOLERANCE && bounds.y + bounds.height <= box[1] + box[3] + BOX_TOLERANCE;
|
|
250
463
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
for (const part of parts) {
|
|
260
|
-
const runs = typeof part === 'string' ? [{text: part}] : part;
|
|
261
|
-
runs.forEach((run, position) => {
|
|
262
|
-
const last = joined.at(-1);
|
|
263
|
-
if (position === 0 && last && sameRunStyle(last, run)) last.text += run.text;
|
|
264
|
-
else joined.push({...run});
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
return joined;
|
|
464
|
+
// Rename an imported list payload's `items` to `bullets`, unless an item holds a description (only list items may).
|
|
465
|
+
// `bullets` is valid beside type 'text' only, so an authored `type` comes back as 'text'.
|
|
466
|
+
function bulletsKey(payload, typed) {
|
|
467
|
+
if (payload.items === undefined || payload.bullets !== undefined || !Array.isArray(payload.items)) return;
|
|
468
|
+
if (payload.items.some(item => object(item) && !Array.isArray(item) && item.description !== undefined)) return;
|
|
469
|
+
payload.bullets = payload.items;
|
|
470
|
+
delete payload.items;
|
|
471
|
+
if (typed) payload.type = 'text';
|
|
268
472
|
}
|
|
269
|
-
|
|
473
|
+
const textRuns = value => typeof value === 'string' || (Array.isArray(value) && value.length > 0 && value.every(run => object(run) && typeof run.text === 'string'));
|
|
270
474
|
/**
|
|
271
475
|
* Rebuild the authored content form from the imported flat `blocks` and their
|
|
272
476
|
* native `bounds` (reference px, one entry per block, null when unknown).
|
|
@@ -316,9 +520,10 @@ export function rebuildContent(topology, blocks, bounds, placed) {
|
|
|
316
520
|
// (the structure hash was checked before this rebuild), so exactly that many
|
|
317
521
|
// text blocks in the leaf are its consecutive lines: they rejoin in reading
|
|
318
522
|
// order, with no separator, because a soft wrap deletes no characters.
|
|
319
|
-
|
|
523
|
+
const wrap = wrapOf(leaf.node);
|
|
524
|
+
if (leaf.kind === 'text' && wrap?.lines === leaf.matches.length && leaf.matches.every(index => blocks[index]?.type === 'text' && textRuns(blocks[index].text))) {
|
|
320
525
|
const ordered = leaf.matches.map((index, position) => ({index, y: bounds[index].y, position})).sort((a, b) => a.y - b.y || a.position - b.position).map(entry => entry.index);
|
|
321
|
-
leaf.payload = {...blocks[ordered[0]], text: joinWrappedText(ordered.map(index => blocks[index].text))};
|
|
526
|
+
leaf.payload = {...blocks[ordered[0]], text: joinWrappedText(ordered.map(index => blocks[index].text), wrap.gaps)};
|
|
322
527
|
leaf.matches = [ordered[0]];
|
|
323
528
|
continue;
|
|
324
529
|
}
|
|
@@ -330,6 +535,8 @@ export function rebuildContent(topology, blocks, bounds, placed) {
|
|
|
330
535
|
const payload = clone(leaf.payload ?? blocks[leaf.matches[0]]);
|
|
331
536
|
// The imported block always names its type; the authored one may have left it implicit.
|
|
332
537
|
if (!leaf.node.typed) delete payload.type;
|
|
538
|
+
// The imported block names every list `items`; a payload authored as `bullets` takes its key back (a bullet has no description).
|
|
539
|
+
if (leaf.node.bullets === true) bulletsKey(payload, leaf.node.typed);
|
|
333
540
|
if (leaf.node.id !== undefined) { payload.id = leaf.node.id; ids.push(leaf.node.id); }
|
|
334
541
|
if (leaf.node.ext !== undefined) payload.extensions = clone(leaf.node.ext);
|
|
335
542
|
return payload;
|
|
@@ -371,7 +578,7 @@ export function rebuildContent(topology, blocks, bounds, placed) {
|
|
|
371
578
|
// The slide `type` is restored separately; the imported block names any
|
|
372
579
|
// list by `items`, so an authored `bullets` payload takes its key back.
|
|
373
580
|
const {type: _type, ...rest} = payload;
|
|
374
|
-
if (leaf.field === 'bullets'
|
|
581
|
+
if (leaf.field === 'bullets') bulletsKey(rest);
|
|
375
582
|
Object.assign(fields, rest);
|
|
376
583
|
}
|
|
377
584
|
placed?.(paths);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {XMLParser} from 'fast-xml-parser';
|
|
2
2
|
import {decodeTextTag, encodeTextTag} from './code-provenance.js';
|
|
3
|
-
import {contentTopology, rebuildContent, validateTopology} from './content-topology.js';
|
|
3
|
+
import {contentTopology, rebuildContent, validateTopology, listLineBreaks, validateListBreaks} from './content-topology.js';
|
|
4
|
+
import {runColorRecord, validateRunColors} from './run-colors.js';
|
|
4
5
|
// Namespace import: cores before FF-34 do not export resolveSocialProfile.
|
|
5
6
|
import * as opfCore from '@openpresentation/opf';
|
|
6
7
|
|
|
@@ -45,6 +46,7 @@ const REL_TAGS = 'http://schemas.openxmlformats.org/officeDocument/2006/relation
|
|
|
45
46
|
const NS = 'http://schemas.openxmlformats.org/presentationml/2006/main';
|
|
46
47
|
const TAGS_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.tags+xml';
|
|
47
48
|
const MAX_OMITTED = 256;
|
|
49
|
+
const MAX_AUTHOR_LENGTH = 4096;
|
|
48
50
|
const MAX_FIELD_BYTES = 256 * 1024, MAX_CATALOG_BYTES = 1024 * 1024, MAX_TAG_CHARS = 16 * 1024 * 1024;
|
|
49
51
|
|
|
50
52
|
const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal: true});
|
|
@@ -73,7 +75,26 @@ function authoredSocials(stored, observed, records) {
|
|
|
73
75
|
// native PowerPoint counterpart and round-trips from the stored value.
|
|
74
76
|
export const DESIGN_REFERENCES = Object.freeze(['theme', 'colorScheme', 'fontScheme', 'dimensions', 'background']);
|
|
75
77
|
export const COMPOSITION_HINTS = Object.freeze(['titleAlignment', 'contentAlignment', 'contentBox', 'contentDirection', 'chartPrimary', 'imageFill', 'listBullet']);
|
|
76
|
-
|
|
78
|
+
// `references` (RR-34): the deck's cited sources. Like `author`, stored as a top-level key of OPF_DOCUMENT_V1 (importers up to
|
|
79
|
+
// 0.11.9 drop a tag with an unknown `supplement` field but ignore an unknown top-level key) and read back into metadata.
|
|
80
|
+
export const METADATA = Object.freeze(['narrative', 'tone', 'audience', 'purpose', 'language', 'organization', 'speaker', 'takeaway', 'duration', 'tags', 'variables', 'filename', 'extensions', 'author', 'references']);
|
|
81
|
+
|
|
82
|
+
// `author` is native (docProps/core.xml dc:creator), where several authors share one field joined by "; " (the join the
|
|
83
|
+
// schema documents). Import splits a creator on exactly that separator, and only when every part is a non-empty name
|
|
84
|
+
// with no outer whitespace (so the split joins back to the identical text); a single name is never cut. The authored form (an array, or a string that itself holds a "; ") returns from the stored record
|
|
85
|
+
// while the creator still equals it; plain PPTX has no record and gets the split. The record is the top-level `author`
|
|
86
|
+
// of OPF_DOCUMENT_V1: importers up to 0.11.8 reject an unknown key inside `supplement` (dropping the whole tag) but ignore
|
|
87
|
+
// an unknown top-level key.
|
|
88
|
+
export const joinAuthors = author => Array.isArray(author) ? author.join('; ') : author;
|
|
89
|
+
export function splitAuthors(creator) {
|
|
90
|
+
if (typeof creator !== 'string') return creator;
|
|
91
|
+
const parts = creator.split('; ');
|
|
92
|
+
return parts.length > 1 && parts.every(part => part && part === part.trim()) ? parts : creator;
|
|
93
|
+
}
|
|
94
|
+
// The creator the package gets when the document names no author. An imported default is not an authored value.
|
|
95
|
+
export const DEFAULT_AUTHOR = 'OpenPresentation';
|
|
96
|
+
// Stored only when the native creator cannot carry the authored form by itself (or would read as the default).
|
|
97
|
+
const authorNeedsRecord = author => Array.isArray(author) || (typeof author === 'string' && (author === DEFAULT_AUTHOR || Array.isArray(splitAuthors(author))));
|
|
77
98
|
// Brand images have no native gate: they return from the stored value once
|
|
78
99
|
// their media and asset references resolve. The P2 logo picture (`OPF_LOGO_V1`)
|
|
79
100
|
// consumes the drawn logo; `design.logo` itself always returns from here.
|
|
@@ -399,6 +420,7 @@ export function documentProvenance(presentation, {mode = 'full', isCatalogId = (
|
|
|
399
420
|
for (const key of METADATA) {
|
|
400
421
|
if (!own(presentation, key)) continue;
|
|
401
422
|
const value = presentation[key];
|
|
423
|
+
if (key === 'author' && (referencesOnly || !authorNeedsRecord(value))) continue;
|
|
402
424
|
if (referencesOnly) {
|
|
403
425
|
const kind = METADATA_CATALOGS[key];
|
|
404
426
|
const ids = typeof value === 'string' ? [value] : key === 'audience' && Array.isArray(value) && value.every(item => typeof item === 'string') ? value : null;
|
|
@@ -447,7 +469,16 @@ export function recordContentTopology(provenance, slide, index, items) {
|
|
|
447
469
|
if (!provenance || provenance.mode !== 'full' || !provenance.slides[index]) return;
|
|
448
470
|
// An unstorable structure is reported with the other omissions (storable) and recorded in the tag.
|
|
449
471
|
const topology = contentTopology(slide, items, index, reason => { provenance.slides[index].omittedContent = reason; });
|
|
450
|
-
if (topology)
|
|
472
|
+
if (!topology) return;
|
|
473
|
+
provenance.slides[index].content = topology;
|
|
474
|
+
// Variable, scheme-slot and role names of text runs (the native colour holds only what they resolve to).
|
|
475
|
+
const colors = runColorRecord(slide);
|
|
476
|
+
if (colors) provenance.slides[index].colors = colors;
|
|
477
|
+
// Whitespace of the hard line breaks inside list items (their native lines carry no tag).
|
|
478
|
+
const omittedLists = [];
|
|
479
|
+
const lists = listLineBreaks(items, (path, reason) => omittedLists.push([path, reason]));
|
|
480
|
+
if (lists) provenance.slides[index].lists = lists;
|
|
481
|
+
if (omittedLists.length) provenance.slides[index].omittedLists = omittedLists;
|
|
451
482
|
}
|
|
452
483
|
|
|
453
484
|
function base64ToBytes(value) {
|
|
@@ -578,6 +609,11 @@ function storable(entries, provenance) {
|
|
|
578
609
|
const fields = {};
|
|
579
610
|
for (const [key, value] of Object.entries(source[section] ?? {})) {
|
|
580
611
|
const path = section === 'design' ? `design.${key}` : key;
|
|
612
|
+
if (section === 'metadata' && (key === 'author' || key === 'references')) {
|
|
613
|
+
const prepared = prepare(path, value);
|
|
614
|
+
if (prepared !== undefined) document[key] = prepared;
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
581
617
|
// A design reference is only restorable together with its asset.
|
|
582
618
|
if (section === 'design' && unstoredAsset(value)) { omit(path, 'the asset it references could not be stored'); continue; }
|
|
583
619
|
const prepared = prepare(path, value);
|
|
@@ -593,7 +629,8 @@ function storable(entries, provenance) {
|
|
|
593
629
|
const slides = provenance.slides.map((record, index) => {
|
|
594
630
|
const result = {v: 1, slide: index};
|
|
595
631
|
if (record.omittedContent) omit(`slides.${index}.content`, record.omittedContent);
|
|
596
|
-
for (const
|
|
632
|
+
for (const [path, reason] of record.omittedLists ?? []) omit(path, reason);
|
|
633
|
+
for (const key of ['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'colors', 'lists']) {
|
|
597
634
|
if (record[key] === undefined) continue;
|
|
598
635
|
const value = prepare(`slides.${index}.${key}`, record[key]);
|
|
599
636
|
if (value !== undefined) result[key] = value;
|
|
@@ -630,7 +667,7 @@ function storable(entries, provenance) {
|
|
|
630
667
|
}
|
|
631
668
|
for (const [index, record] of slides.entries()) {
|
|
632
669
|
while (tagChars(record) > budget) {
|
|
633
|
-
const candidates = [...['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'layoutRecord'].filter(key => record[key] !== undefined).map(key => ({key, size: sizeOf(record[key])})),
|
|
670
|
+
const candidates = [...['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'colors', 'lists', 'layoutRecord'].filter(key => record[key] !== undefined).map(key => ({key, size: sizeOf(record[key])})),
|
|
634
671
|
...Object.keys(record.design ?? {}).map(key => ({key, design: true, size: sizeOf(record.design[key])}))];
|
|
635
672
|
if (!candidates.length) break;
|
|
636
673
|
const largest = candidates.sort((a, b) => b.size - a.size)[0];
|
|
@@ -738,7 +775,15 @@ function validateDocument(stored) {
|
|
|
738
775
|
for (const key of Object.keys(value.metadata ?? {})) if (!METADATA.includes(key)) throw Error(`Unknown metadata field ${key}.`);
|
|
739
776
|
if (value.metadata?.filename !== undefined && typeof value.metadata.filename !== 'string') throw Error('Invalid filename record.');
|
|
740
777
|
if (value.metadata?.extensions !== undefined && !object(value.metadata.extensions)) throw Error('Invalid extensions record.');
|
|
778
|
+
const author = stored.author;
|
|
779
|
+
if (author !== undefined && !((typeof author === 'string' && author.length <= MAX_AUTHOR_LENGTH * 256) || (Array.isArray(author) && author.length > 0 && author.length <= 256 && author.every(name => typeof name === 'string' && name.length <= MAX_AUTHOR_LENGTH)))) throw Error('Invalid author record.');
|
|
780
|
+
const references = stored.references;
|
|
781
|
+
if (references !== undefined && (!Array.isArray(references) || references.length > 4096 || !references.every(object))) throw Error('Invalid references record.');
|
|
741
782
|
validateOmitted(value.omitted);
|
|
783
|
+
if (author !== undefined || references !== undefined) {
|
|
784
|
+
const {author: _author, references: _references, ...rest} = value;
|
|
785
|
+
return {...rest, metadata: {...rest.metadata, ...(author !== undefined ? {author} : {}), ...(references !== undefined ? {references} : {})}};
|
|
786
|
+
}
|
|
742
787
|
return value;
|
|
743
788
|
}
|
|
744
789
|
|
|
@@ -753,6 +798,8 @@ function validateSlide(stored) {
|
|
|
753
798
|
if (value.section !== undefined && typeof value.section !== 'string') throw Error('Invalid slide section record.');
|
|
754
799
|
if (value.extensions !== undefined && !object(value.extensions)) throw Error('Invalid slide extensions record.');
|
|
755
800
|
if (value.content !== undefined) validateTopology(value.content);
|
|
801
|
+
if (value.colors !== undefined) validateRunColors(value.colors);
|
|
802
|
+
if (value.lists !== undefined) validateListBreaks(value.lists);
|
|
756
803
|
validateOmitted(value.omitted);
|
|
757
804
|
return value;
|
|
758
805
|
}
|
|
@@ -792,6 +839,23 @@ function pruneDangling(value, dangling, path, removed) {
|
|
|
792
839
|
return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => isDangling(item) ? (removed.push(`${path}.${key}`), []) : [[key, pruneDangling(item, dangling, `${path}.${key}`, removed)]]));
|
|
793
840
|
}
|
|
794
841
|
|
|
842
|
+
/**
|
|
843
|
+
* The hard line breaks stored for the lists of one slide: Map(list path -> Map(line number -> gap)), or null when the slide
|
|
844
|
+
* carries none, its record is unreadable, or its arrangement changed since export (the lines are then no longer the exported
|
|
845
|
+
* ones). Read before the slide's shapes are merged into list blocks; any damage is reported later by restoreDocumentProvenance.
|
|
846
|
+
*/
|
|
847
|
+
export function slideListBreaks(entries, path, root, rels) {
|
|
848
|
+
try {
|
|
849
|
+
const found = readTag(entries, root?.['p:cSld']?.['p:custDataLst'], rels, SLIDE_TAG);
|
|
850
|
+
if (found.missing) return null;
|
|
851
|
+
const record = validateSlide(found.value);
|
|
852
|
+
if (record.lists === undefined || record.native.structure !== nativeSlide(entries, path).structure) return null;
|
|
853
|
+
return new Map(record.lists.map(([list, lines]) => [list, new Map(lines)]));
|
|
854
|
+
} catch {
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
795
859
|
/**
|
|
796
860
|
* Read OPF_DOCUMENT_V1 / OPF_SLIDE_V1 and decide what still matches the
|
|
797
861
|
* package. Nothing is modified here: the result lists restore groups (one per
|
|
@@ -1037,12 +1101,20 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
|
|
|
1037
1101
|
// content (the furniture organization name, linked socials) win over their
|
|
1038
1102
|
// stored values; stored-only fields (logo, role ...) return.
|
|
1039
1103
|
const metadata = document.metadata ?? {};
|
|
1104
|
+
// The exporter writes the default creator when no author was authored: with the document record present, that default is not the author.
|
|
1105
|
+
if (metadata.author === undefined && imported.author === DEFAULT_AUTHOR) group('author', [remove(['author'])]);
|
|
1040
1106
|
for (const key of METADATA) {
|
|
1041
1107
|
if (metadata[key] === undefined) continue;
|
|
1042
1108
|
if (key === 'organization' && organizationConflict) {
|
|
1043
1109
|
report({code: 'metadata-reference-changed', path: 'organization', message: 'Slides now show different organization names in their furniture, so the stored organization was not restored; each slide keeps its visible text.'});
|
|
1044
1110
|
continue;
|
|
1045
1111
|
}
|
|
1112
|
+
if (key === 'author') {
|
|
1113
|
+
// Native evidence: the stored authored form only stands for the creator it was joined into.
|
|
1114
|
+
if (joinAuthors(imported.author) === joinAuthors(metadata.author)) group('author', [set(['author'], clone(metadata.author))]);
|
|
1115
|
+
else report({code: 'metadata-reference-changed', path: 'author', message: 'The PowerPoint author field no longer matches the stored author, so the stored author was not restored; the current field is kept.'});
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1046
1118
|
let value = restorable(key, metadata[key], {prune: true});
|
|
1047
1119
|
if (value === undefined) continue;
|
|
1048
1120
|
if (key === 'organization' && object(imported.organization)) {
|
package/dist/index.d.ts
CHANGED
|
@@ -64,6 +64,15 @@ export interface ChartDataAdaptedDiagnostic { code: "chart-data-adapted"; path:
|
|
|
64
64
|
/** With `chartex: "native"`, a map chart (`world`) is exported as a chartex regionMap (`Requires="cx4"`) without cached geography: PowerPoint must fetch the region shapes from its online map service and shows "There was a problem getting the information for your map chart" with an empty chart until it does. */
|
|
65
65
|
export interface ChartMapGeodataDiagnostic { code: "chart-map-geodata"; path: string; message: string }
|
|
66
66
|
|
|
67
|
+
/** A template variable was unfilled and its `example` was used (template export only). */
|
|
68
|
+
export interface VariableExampleUsedDiagnostic {
|
|
69
|
+
code: "variable-example-used";
|
|
70
|
+
/** JSON pointer of the variable's declaration. */
|
|
71
|
+
path: string;
|
|
72
|
+
message: string;
|
|
73
|
+
id: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
67
76
|
export interface ToPptxOptions {
|
|
68
77
|
/** Default compatible converts WebP to a static PNG. Preserve embeds original WebP bytes. */
|
|
69
78
|
imageFormat?: "compatible" | "preserve";
|
|
@@ -90,11 +99,18 @@ export interface ToPptxOptions {
|
|
|
90
99
|
* also written as PowerPoint's native section list whatever this option says.
|
|
91
100
|
*/
|
|
92
101
|
provenance?: "full" | "references-only" | false;
|
|
102
|
+
/**
|
|
103
|
+
* Values for the deck's template variables, keyed by variable id (core `resolveVariables`). A deck that uses content
|
|
104
|
+
* variables, or is marked `template: true`, is resolved to a concrete deck first; the PPTX holds the resolved text.
|
|
105
|
+
* A template exports with each unfilled variable's example (diagnostic `variable-example-used`); a normal deck with an
|
|
106
|
+
* unfilled required variable throws `unfilled-variables`, and a value of the wrong kind throws `invalid-variables`.
|
|
107
|
+
*/
|
|
108
|
+
variables?: Record<string, unknown>;
|
|
93
109
|
textMeasurement?: TextMeasurement;
|
|
94
110
|
/** Match preview/pagination clearance around supplied vector text outlines; default 1. */
|
|
95
111
|
textRasterPadding?: number;
|
|
96
112
|
/** Layout diagnostics, `media-provenance-omitted` when video data cannot be stored, plus `unresolved-font-scheme` (once per reference path) when a font-scheme id matches no record and the default `aptos` scheme is used as the base. */
|
|
97
|
-
onDiagnostic?: (diagnostic: LayoutDiagnostic | FontSchemeDiagnostic | MediaProvenanceDiagnostic | ChartDataUnplottableDiagnostic | ChartDataAdaptedDiagnostic | ChartMapGeodataDiagnostic | ContentPlaceholderDiagnostic | UnresolvedAssetDiagnostic | WatermarkNotExportedDiagnostic | SvgSanitizedDiagnostic | SvgImageRasterizedDiagnostic) => void;
|
|
113
|
+
onDiagnostic?: (diagnostic: LayoutDiagnostic | FontSchemeDiagnostic | MediaProvenanceDiagnostic | ChartDataUnplottableDiagnostic | ChartDataAdaptedDiagnostic | ChartMapGeodataDiagnostic | ContentPlaceholderDiagnostic | UnresolvedAssetDiagnostic | WatermarkNotExportedDiagnostic | SvgSanitizedDiagnostic | SvgImageRasterizedDiagnostic | VariableExampleUsedDiagnostic) => void;
|
|
98
114
|
baseDir?: string;
|
|
99
115
|
compressionLevel?: number;
|
|
100
116
|
imageResolver?: (src: string, context: ImageResolverContext) => ImageResolverResult | Promise<ImageResolverResult | null | undefined> | null | undefined;
|