@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.
@@ -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 soft wraps are
29
- // recorded here as a count; a wrapped payload with any hard break is not marked.
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 number of native line shapes a rich-text `text` item exported as, when
61
- * those lines are consecutive soft wraps of the authored runs: the line
62
- * fragments, concatenated in order, are exactly the concatenated run text, so
63
- * no line boundary stands for a hard break (a newline, a blank line, collapsed
64
- * or dropped characters). Undefined for a single line or when that cannot be
65
- * shown: nothing then marks the leaf and import keeps the separate blocks.
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 softWrappedLines(item) {
68
- const lines = item?.text?.richLines;
69
- if (item?.field !== 'text' || !Array.isArray(lines) || lines.length < 2 || lines.length > MAX_WRAPPED_LINES) return undefined;
70
- const runs = Array.isArray(item.value) ? item.value : typeof item.value === 'string' ? [item.value] : null;
71
- if (!runs) return undefined;
72
- let source = '';
73
- for (const run of runs) {
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
- source += text;
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
- for (const line of lines) {
80
- if (!Array.isArray(line?.fragments) || !line.fragments.length) return undefined;
81
- for (const fragment of line.fragments) {
82
- if (typeof fragment?.text !== 'string' || !source.startsWith(fragment.text, position)) return undefined;
83
- position += fragment.text.length;
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
- return position === source.length ? lines.length : undefined;
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 = softWrappedLines(item);
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 (kind === 'text' && wrapped.has(`${path}.text`)) leaf.lines = wrapped.get(`${path}.text`);
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), ...(lines ? {lines} : {})} : {field};
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
- const validField = value => object(value) && ROOT_PAYLOAD_FIELDS.includes(value.field) && validBox(value.box) && validLines(value.lines) && (value.lines === undefined || value.field === 'text');
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 (!validLines(item.lines) || (item.lines !== undefined && item.k !== 'text')) throw Error('Invalid wrapped line count.');
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 (!validLines(value.lines) || (value.lines !== undefined && value.field !== 'text')) throw Error('Invalid wrapped line count.');
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
- const textRuns = value => typeof value === 'string' || (Array.isArray(value) && value.length > 0 && value.every(run => object(run) && typeof run.text === 'string'));
252
- const sameRunStyle = (a, b) => JSON.stringify({...a, text: ''}) === JSON.stringify({...b, text: ''});
253
- // The lines' text joined in order. Runs are never invented or dropped; at a seam the
254
- // last run of one line and the first of the next merge when they differ only in text
255
- // (a run the wrap cut in two), the same way a rejoined list does.
256
- function joinWrappedText(parts) {
257
- if (parts.every(part => typeof part === 'string')) return parts.join('');
258
- const joined = [];
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
- if (leaf.kind === 'text' && leaf.node.lines === leaf.matches.length && leaf.matches.every(index => blocks[index]?.type === 'text' && textRuns(blocks[index].text))) {
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' && rest.items !== undefined && rest.bullets === undefined) { rest.bullets = rest.items; delete rest.items; }
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
- export const METADATA = Object.freeze(['narrative', 'tone', 'audience', 'purpose', 'language', 'organization', 'speaker', 'takeaway', 'duration', 'tags', 'variables', 'filename', 'extensions']);
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) provenance.slides[index].content = 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 key of ['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content']) {
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;