@openpresentation/opf-pptx 0.11.5 → 0.11.7

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.
@@ -0,0 +1,295 @@
1
+ // Content topology (spec-gap closure P1): the structure of a slide's content
2
+ // survives a PPTX round trip through the OPF_SLIDE_V1 record.
3
+ //
4
+ // PowerPoint has no native counterpart for OPF's content structure: nested
5
+ // groups, promoted regions (`left`, `top:left` ...), a root payload (`text`,
6
+ // `items` ...) versus `blocks`, block ids, block extensions and group
7
+ // composition. Import rebuilds flat blocks from the native shapes in reading
8
+ // order. The topology record stores the structure and the reference-pixel box
9
+ // of every leaf, taken from the same `composeSlide` geometry the exporter draws
10
+ // from. Import matches each imported block to the smallest stored leaf whose
11
+ // box contains the block's native bounds and rebuilds the authored form. The
12
+ // record holds ids, extensions, composition and boxes only: never text, images
13
+ // or payload values, which the native shapes supply.
14
+ //
15
+ // Record (stored as `content` in OPF_SLIDE_V1, `full` mode only):
16
+ //
17
+ // Topology = {form: 'root', field, box?} // one root payload on the slide
18
+ // | {form: 'root', fields: [{field, box?}]} // root shorthand with several payloads
19
+ // | {form: 'blocks', blocks: Node[]}
20
+ // | {form: 'regions', regions: {[key]: Node}} // key = promoted region key
21
+ // Node = {t: 'group', id?, ext?, comp?, typed?, blocks: Node[]}
22
+ // | {t: 'leaf', k, id?, ext?, typed?, box?} // box = [x, y, w, h] reference px, 1 decimal
23
+ // typed = true when the authored block spelled out its `type`
24
+ //
25
+ // Tags are untrusted input: every field is type-, count- and depth-checked
26
+ // before use, and the rebuilt slide still has to validate with the document.
27
+
28
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
29
+ const clone = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
30
+
31
+ export const CONTENT_KINDS = Object.freeze(['text', 'list', 'image', 'video', 'chart', 'table', 'code', 'metric', 'quote', 'timeline']);
32
+ export const ROOT_PAYLOAD_FIELDS = Object.freeze(['text', 'items', 'bullets', 'image', 'video', 'chart', 'table', 'code', 'metric', 'quote', 'timeline']);
33
+ const HORIZONTAL = ['left', 'center', 'right', 'left+center', 'center+right', 'left+center+right'];
34
+ const VERTICAL = ['top', 'middle', 'bottom', 'top+middle', 'middle+bottom', 'top+middle+bottom'];
35
+ export const REGION_KEYS = Object.freeze([...HORIZONTAL, ...VERTICAL, ...VERTICAL.flatMap(row => HORIZONTAL.map(column => `${row}:${column}`))]);
36
+ const REGION_KEY_PATTERN = /^[a-z]+(\+[a-z]+)*(:[a-z]+(\+[a-z]+)*)?$/;
37
+ const REGION_KEY_SET = new Set(REGION_KEYS);
38
+ // Groups nest at most this deep (a leaf inside three groups). Deeper structures
39
+ // are valid OPF but are not stored; the slide then imports as flat blocks.
40
+ export const MAX_GROUP_DEPTH = 3;
41
+ export const MAX_NODES = 256;
42
+ // Ids are any schema string up to this length (the empty string included); a
43
+ // longer id leaves the slide's topology unstored, and import rejects it the same way.
44
+ export const MAX_ID_LENGTH = 256;
45
+ // Native bounds may sit this far outside a stored leaf box (reference px).
46
+ export const BOX_TOLERANCE = 3;
47
+
48
+ const kindOfField = field => field === 'items' || field === 'bullets' ? 'list' : field;
49
+ const isGroup = block => object(block) && (block.type === 'group' || (block.type === undefined && Array.isArray(block.blocks)));
50
+ const round1 = value => Math.round(value * 10) / 10;
51
+
52
+ function blockKind(block) {
53
+ if (typeof block.type === 'string' && block.type !== 'group') return CONTENT_KINDS.includes(block.type) ? block.type : null;
54
+ for (const field of ROOT_PAYLOAD_FIELDS) if (block[field] !== undefined) return kindOfField(field);
55
+ return null;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Export
60
+
61
+ /**
62
+ * The topology of `slide`'s content with leaf boxes from `items` (the
63
+ * `composeSlide` geometry items of that slide). Returns undefined when the
64
+ * slide has no content, or when its structure cannot be represented (an
65
+ * unknown payload, groups nested deeper than MAX_GROUP_DEPTH, too many nodes);
66
+ * `report(reason)` then names why.
67
+ */
68
+ export function contentTopology(slide, items, slideIndex, report = () => {}) {
69
+ if (!object(slide)) return undefined;
70
+ const boxes = new Map();
71
+ for (const item of items ?? []) if (typeof item?.path === 'string' && object(item.box)) boxes.set(item.path, item.box);
72
+ const base = `slides.${slideIndex}`;
73
+ let nodes = 0;
74
+ const fail = reason => { throw new TopologyError(reason); };
75
+ const leafBox = path => {
76
+ for (const field of ROOT_PAYLOAD_FIELDS) {
77
+ const box = boxes.get(`${path}.${field}`);
78
+ if (box) return [box.x, box.y, box.width, box.height].map(round1);
79
+ }
80
+ return undefined;
81
+ };
82
+ const identity = (block, node, path) => {
83
+ if (typeof block.id === 'string') {
84
+ if (block.id.length > MAX_ID_LENGTH) fail(`${path}.id is longer than ${MAX_ID_LENGTH} characters`);
85
+ node.id = block.id;
86
+ }
87
+ if (object(block.extensions)) node.ext = clone(block.extensions);
88
+ if (typeof block.type === 'string') node.typed = true;
89
+ return node;
90
+ };
91
+ const node = (block, path, depth) => {
92
+ if (!object(block)) fail(`${path} is not a content block`);
93
+ if (++nodes > MAX_NODES) fail(`the slide has more than ${MAX_NODES} content nodes`);
94
+ if (isGroup(block)) {
95
+ if (depth >= MAX_GROUP_DEPTH) fail(`groups nest deeper than ${MAX_GROUP_DEPTH} levels`);
96
+ const group = identity(block, {t: 'group'}, path);
97
+ if (object(block.composition)) group.comp = clone(block.composition);
98
+ group.blocks = (Array.isArray(block.blocks) ? block.blocks : []).map((child, index) => node(child, `${path}.blocks.${index}`, depth + 1));
99
+ return group;
100
+ }
101
+ const kind = blockKind(block);
102
+ if (!kind) fail(`${path} has no known content payload`);
103
+ const leaf = identity(block, {t: 'leaf', k: kind}, path);
104
+ const box = leafBox(path);
105
+ if (box) leaf.box = box;
106
+ return leaf;
107
+ };
108
+ try {
109
+ // Core precedence: promoted regions win over blocks, blocks over a root payload.
110
+ const regionKeys = Object.keys(slide).filter(key => REGION_KEY_SET.has(key) && object(slide[key]));
111
+ if (regionKeys.length) return {form: 'regions', regions: Object.fromEntries(regionKeys.map(key => [key, node(slide[key], `${base}.${key}`, 0)]))};
112
+ if (Array.isArray(slide.blocks)) {
113
+ if (!slide.blocks.length) return undefined;
114
+ return {form: 'blocks', blocks: slide.blocks.map((block, index) => node(block, `${base}.blocks.${index}`, 0))};
115
+ }
116
+ // Root shorthand: one payload is the common case; several payload fields
117
+ // on one slide compose as one item each (`slides.N.<field>`).
118
+ const fields = ROOT_PAYLOAD_FIELDS.filter(name => slide[name] !== undefined).map(field => {
119
+ const box = boxes.get(`${base}.${field}`);
120
+ return box ? {field, box: [box.x, box.y, box.width, box.height].map(round1)} : {field};
121
+ });
122
+ if (!fields.length) return undefined;
123
+ if (fields.length === 1) return {form: 'root', ...fields[0]};
124
+ return {form: 'root', fields};
125
+ } catch (error) {
126
+ if (!(error instanceof TopologyError)) throw error;
127
+ report(error.message);
128
+ return undefined;
129
+ }
130
+ }
131
+
132
+ class TopologyError extends Error {}
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Import: validation
136
+
137
+ const validBox = value => value === undefined || (Array.isArray(value) && value.length === 4 && value.every(number => typeof number === 'number' && Number.isFinite(number) && Math.abs(number) < 1e7));
138
+ const validId = value => value === undefined || (typeof value === 'string' && value.length <= MAX_ID_LENGTH);
139
+ const validField = value => object(value) && ROOT_PAYLOAD_FIELDS.includes(value.field) && validBox(value.box);
140
+ /** The root payload leaves of a root-form record: [{field, box?}]. */
141
+ export const rootFields = topology => topology.fields ?? [{field: topology.field, box: topology.box}];
142
+
143
+ /** Throws when `value` is not a well-formed topology record. Returns it otherwise. */
144
+ export function validateTopology(value) {
145
+ if (!object(value)) throw Error('Invalid content topology.');
146
+ let nodes = 0;
147
+ const node = (item, depth) => {
148
+ if (!object(item)) throw Error('Invalid content node.');
149
+ if (++nodes > MAX_NODES) throw Error(`Content topology has more than ${MAX_NODES} nodes.`);
150
+ if (!validId(item.id)) throw Error('Invalid content node id.');
151
+ if (item.ext !== undefined && !object(item.ext)) throw Error('Invalid content node extensions.');
152
+ if (item.typed !== undefined && item.typed !== true) throw Error('Invalid content node type flag.');
153
+ if (item.t === 'group') {
154
+ if (depth >= MAX_GROUP_DEPTH) throw Error(`Content groups nest deeper than ${MAX_GROUP_DEPTH} levels.`);
155
+ if (item.comp !== undefined && !object(item.comp)) throw Error('Invalid group composition.');
156
+ if (!Array.isArray(item.blocks)) throw Error('Invalid group blocks.');
157
+ for (const child of item.blocks) node(child, depth + 1);
158
+ return;
159
+ }
160
+ if (item.t !== 'leaf') throw Error('Unknown content node type.');
161
+ if (!CONTENT_KINDS.includes(item.k)) throw Error('Unknown content kind.');
162
+ if (item.comp !== undefined) throw Error('A content leaf has no composition.');
163
+ if (!validBox(item.box)) throw Error('Invalid content box.');
164
+ };
165
+ switch (value.form) {
166
+ case 'root': {
167
+ if (value.fields !== undefined) {
168
+ if (value.field !== undefined || value.box !== undefined) throw Error('Invalid root payload record.');
169
+ if (!Array.isArray(value.fields) || !value.fields.length || value.fields.length > ROOT_PAYLOAD_FIELDS.length || !value.fields.every(validField)) throw Error('Invalid root payload fields.');
170
+ if (new Set(value.fields.map(item => item.field)).size !== value.fields.length) throw Error('Repeated root payload field.');
171
+ return value;
172
+ }
173
+ if (!ROOT_PAYLOAD_FIELDS.includes(value.field)) throw Error('Unknown root payload field.');
174
+ if (!validBox(value.box)) throw Error('Invalid content box.');
175
+ return value;
176
+ }
177
+ case 'blocks':
178
+ if (!Array.isArray(value.blocks) || !value.blocks.length) throw Error('Invalid content blocks.');
179
+ for (const child of value.blocks) node(child, 0);
180
+ return value;
181
+ case 'regions': {
182
+ if (!object(value.regions)) throw Error('Invalid content regions.');
183
+ const keys = Object.keys(value.regions);
184
+ if (!keys.length || keys.length > REGION_KEYS.length) throw Error('Invalid content regions.');
185
+ for (const key of keys) {
186
+ if (!REGION_KEY_PATTERN.test(key) || !REGION_KEY_SET.has(key)) throw Error(`Unknown region key ${key}.`);
187
+ node(value.regions[key], 0);
188
+ }
189
+ return value;
190
+ }
191
+ default:
192
+ throw Error('Unknown content form.');
193
+ }
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Import: rebuild
198
+
199
+ const compatible = (leafKind, kind) => leafKind === kind || (['text', 'list'].includes(leafKind) && ['text', 'list'].includes(kind));
200
+ const containsOrigin = (box, bounds) => bounds.x >= box[0] - BOX_TOLERANCE && bounds.y >= box[1] - BOX_TOLERANCE
201
+ && bounds.x <= box[0] + box[2] + BOX_TOLERANCE && bounds.y <= box[1] + box[3] + BOX_TOLERANCE;
202
+ const contains = (box, bounds) => containsOrigin(box, bounds)
203
+ && bounds.x + bounds.width <= box[0] + box[2] + BOX_TOLERANCE && bounds.y + bounds.height <= box[1] + box[3] + BOX_TOLERANCE;
204
+
205
+ /**
206
+ * Rebuild the authored content form from the imported flat `blocks` and their
207
+ * native `bounds` (reference px, one entry per block, null when unknown).
208
+ * Returns {fields} (slide properties to set: root field, `blocks` or region
209
+ * keys; a `null` value removes the property) and the restored block ids, or
210
+ * {reason} when a block matches no leaf or several blocks land on one leaf.
211
+ * Leaves with no block are dropped: empty payloads export nothing.
212
+ */
213
+ export function rebuildContent(topology, blocks, bounds) {
214
+ if (!Array.isArray(blocks) || !Array.isArray(bounds) || blocks.length !== bounds.length) return {reason: 'the imported blocks carry no native bounds'};
215
+ const leaves = [];
216
+ const collect = item => {
217
+ if (item.t === 'group') { for (const child of item.blocks) collect(child); return; }
218
+ leaves.push({node: item, kind: item.k, box: item.box, matches: []});
219
+ };
220
+ if (topology.form === 'root') for (const item of rootFields(topology)) leaves.push({node: item, field: item.field, kind: kindOfField(item.field), box: item.box, matches: []});
221
+ else if (topology.form === 'blocks') topology.blocks.forEach(collect);
222
+ else Object.values(topology.regions).forEach(collect);
223
+ const kindOf = block => blockKind(block) ?? 'text';
224
+ for (const [index, block] of blocks.entries()) {
225
+ const native = bounds[index];
226
+ if (!object(native)) return {reason: `block ${index} has no native bounds`};
227
+ const kind = kindOf(block);
228
+ const smallest = (a, b) => (a.box[2] * a.box[3]) - (b.box[2] * b.box[3]) || (a.kind === kind ? -1 : 0) - (b.kind === kind ? -1 : 0);
229
+ let candidates = leaves.filter(leaf => leaf.box && compatible(leaf.kind, kind) && contains(leaf.box, native)).sort(smallest);
230
+ // Overflowing content (overflow: 'warn') runs past its box: the lines of a
231
+ // long list or timeline still start inside it, so the origin decides.
232
+ if (!candidates.length) candidates = leaves.filter(leaf => leaf.box && compatible(leaf.kind, kind) && containsOrigin(leaf.box, native)).sort(smallest);
233
+ if (!candidates.length) return {reason: `block ${index} (${kind}) lies in no stored content box`};
234
+ candidates[0].matches.push(index);
235
+ }
236
+ for (const leaf of leaves) {
237
+ if (leaf.matches.length <= 1) continue;
238
+ // A list's native lines can arrive as several bullet blocks when another
239
+ // object interleaves with them in reading order; they rejoin their list.
240
+ if (leaf.kind === 'list' && leaf.matches.every(index => blocks[index]?.type === 'list' && Array.isArray(blocks[index].items))) {
241
+ const merged = {...blocks[leaf.matches[0]], items: leaf.matches.flatMap(index => blocks[index].items)};
242
+ leaf.matches = [leaf.matches[0]];
243
+ leaf.payload = merged;
244
+ continue;
245
+ }
246
+ return {reason: `${leaf.matches.length} blocks lie in one stored content box`};
247
+ }
248
+ const ids = [];
249
+ const payloadOf = leaf => {
250
+ if (!leaf.matches.length) return undefined;
251
+ const payload = clone(leaf.payload ?? blocks[leaf.matches[0]]);
252
+ // The imported block always names its type; the authored one may have left it implicit.
253
+ if (!leaf.node.typed) delete payload.type;
254
+ if (leaf.node.id !== undefined) { payload.id = leaf.node.id; ids.push(leaf.node.id); }
255
+ if (leaf.node.ext !== undefined) payload.extensions = clone(leaf.node.ext);
256
+ return payload;
257
+ };
258
+ const build = item => {
259
+ if (item.t !== 'group') {
260
+ const leaf = leaves.find(entry => entry.node === item);
261
+ return leaf ? payloadOf(leaf) : undefined;
262
+ }
263
+ const children = item.blocks.map(build).filter(Boolean);
264
+ if (!children.length) return undefined;
265
+ const group = item.typed ? {type: 'group'} : {};
266
+ if (item.id !== undefined) { group.id = item.id; ids.push(item.id); }
267
+ if (item.ext !== undefined) group.extensions = clone(item.ext);
268
+ if (item.comp !== undefined) group.composition = clone(item.comp);
269
+ group.blocks = children;
270
+ return group;
271
+ };
272
+ const fields = {blocks: null};
273
+ if (topology.form === 'root') {
274
+ for (const leaf of leaves) {
275
+ const payload = payloadOf(leaf);
276
+ if (payload === undefined) continue;
277
+ // The slide `type` is restored separately; the imported block names any
278
+ // list by `items`, so an authored `bullets` payload takes its key back.
279
+ const {type: _type, ...rest} = payload;
280
+ if (leaf.field === 'bullets' && rest.items !== undefined && rest.bullets === undefined) { rest.bullets = rest.items; delete rest.items; }
281
+ Object.assign(fields, rest);
282
+ }
283
+ return {fields, ids};
284
+ }
285
+ if (topology.form === 'blocks') {
286
+ const rebuilt = topology.blocks.map(build).filter(Boolean);
287
+ if (rebuilt.length) fields.blocks = rebuilt;
288
+ return {fields, ids};
289
+ }
290
+ for (const [key, item] of Object.entries(topology.regions)) {
291
+ const host = build(item);
292
+ if (host !== undefined) fields[key] = host;
293
+ }
294
+ return {fields, ids};
295
+ }