@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.
- package/README.md +69 -9
- 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 +280 -53
- package/dist/document-provenance.js +83 -10
- package/dist/furniture-provenance.js +67 -3
- package/dist/image-fallback-browser.js +12 -0
- package/dist/image-fallback-node.js +22 -0
- package/dist/import-signals.js +809 -0
- package/dist/index.d.ts +302 -4
- package/dist/index.js +486 -102
- package/dist/logo-provenance.js +8 -6
- package/dist/metric-provenance.js +6 -3
- package/dist/native-furniture.js +297 -0
- package/dist/native-text-style.js +17 -1
- package/dist/numbered-list.js +114 -0
- package/dist/run-colors.js +118 -0
- package/dist/script-fonts.js +90 -14
- package/dist/slide-image-provenance.js +6 -4
- package/dist/svg-image.js +354 -0
- package/dist/table-cell-import.js +3 -1
- package/dist/table-import.js +3 -2
- package/dist/watermark-provenance.js +4 -3
- package/package.json +7 -7
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};
|
|
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 =
|
|
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).
|
|
@@ -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
|
-
|
|
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'
|
|
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
|
}
|