@openpresentation/opf-pptx 0.11.8 → 0.12.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.
@@ -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};
87
190
  }
88
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} : {})};
210
+ }
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).
@@ -275,7 +479,7 @@ function joinWrappedText(parts) {
275
479
  * {reason} when a block matches no leaf or several blocks land on one leaf.
276
480
  * Leaves with no block are dropped: empty payloads export nothing.
277
481
  */
278
- export function rebuildContent(topology, blocks, bounds) {
482
+ export function rebuildContent(topology, blocks, bounds, placed) {
279
483
  if (!Array.isArray(blocks) || !Array.isArray(bounds) || blocks.length !== bounds.length) return {reason: 'the imported blocks carry no native bounds'};
280
484
  const leaves = [];
281
485
  const collect = item => {
@@ -300,6 +504,8 @@ export function rebuildContent(topology, blocks, bounds) {
300
504
  }
301
505
  for (const leaf of leaves) {
302
506
  if (leaf.matches.length <= 1) continue;
507
+ // Every flat block that rejoins here lands at this leaf (reported to `placed`), not only the first.
508
+ leaf.rejoined = leaf.matches;
303
509
  // A list's native lines can arrive as several bullet blocks when another
304
510
  // object interleaves with them in reading order; they rejoin their list.
305
511
  if (leaf.kind === 'list' && leaf.matches.every(index => blocks[index]?.type === 'list' && Array.isArray(blocks[index].items))) {
@@ -314,9 +520,10 @@ export function rebuildContent(topology, blocks, bounds) {
314
520
  // (the structure hash was checked before this rebuild), so exactly that many
315
521
  // text blocks in the leaf are its consecutive lines: they rejoin in reading
316
522
  // order, with no separator, because a soft wrap deletes no characters.
317
- 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))) {
318
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);
319
- 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)};
320
527
  leaf.matches = [ordered[0]];
321
528
  continue;
322
529
  }
@@ -328,6 +535,8 @@ export function rebuildContent(topology, blocks, bounds) {
328
535
  const payload = clone(leaf.payload ?? blocks[leaf.matches[0]]);
329
536
  // The imported block always names its type; the authored one may have left it implicit.
330
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);
331
540
  if (leaf.node.id !== undefined) { payload.id = leaf.node.id; ids.push(leaf.node.id); }
332
541
  if (leaf.node.ext !== undefined) payload.extensions = clone(leaf.node.ext);
333
542
  return payload;
@@ -346,6 +555,21 @@ export function rebuildContent(topology, blocks, bounds) {
346
555
  group.blocks = children;
347
556
  return group;
348
557
  };
558
+ // Where each imported flat block lands under the slide (reported to `placed`, for import signals): the path of its leaf in the rebuilt form.
559
+ const paths = new Map();
560
+ const built = item => item.t !== 'group' ? Boolean(leaves.find(entry => entry.node === item)?.matches.length) : item.blocks.some(built);
561
+ const place = (item, prefix) => {
562
+ if (item.t !== 'group') {
563
+ const leaf = leaves.find(entry => entry.node === item);
564
+ if (leaf) for (const index of leaf.rejoined ?? leaf.matches) paths.set(index, prefix);
565
+ return;
566
+ }
567
+ let position = 0;
568
+ for (const child of item.blocks) if (built(child)) place(child, `${prefix}.blocks.${position++}`);
569
+ };
570
+ if (topology.form === 'root') { for (const leaf of leaves) for (const index of leaf.rejoined ?? leaf.matches) paths.set(index, leaf.field); }
571
+ else if (topology.form === 'blocks') { let position = 0; for (const item of topology.blocks) if (built(item)) place(item, `blocks.${position++}`); }
572
+ else for (const [key, item] of Object.entries(topology.regions)) if (built(item)) place(item, key);
349
573
  const fields = {blocks: null};
350
574
  if (topology.form === 'root') {
351
575
  for (const leaf of leaves) {
@@ -354,19 +578,22 @@ export function rebuildContent(topology, blocks, bounds) {
354
578
  // The slide `type` is restored separately; the imported block names any
355
579
  // list by `items`, so an authored `bullets` payload takes its key back.
356
580
  const {type: _type, ...rest} = payload;
357
- if (leaf.field === 'bullets' && rest.items !== undefined && rest.bullets === undefined) { rest.bullets = rest.items; delete rest.items; }
581
+ if (leaf.field === 'bullets') bulletsKey(rest);
358
582
  Object.assign(fields, rest);
359
583
  }
584
+ placed?.(paths);
360
585
  return {fields, ids};
361
586
  }
362
587
  if (topology.form === 'blocks') {
363
588
  const rebuilt = topology.blocks.map(build).filter(Boolean);
364
589
  if (rebuilt.length) fields.blocks = rebuilt;
590
+ placed?.(paths);
365
591
  return {fields, ids};
366
592
  }
367
593
  for (const [key, item] of Object.entries(topology.regions)) {
368
594
  const host = build(item);
369
595
  if (host !== undefined) fields[key] = host;
370
596
  }
597
+ placed?.(paths);
371
598
  return {fields, ids};
372
599
  }