@openpresentation/opf-pptx 0.16.1 → 0.18.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.
@@ -0,0 +1,326 @@
1
+ // RR-72: header and footer furniture on the slide master and layouts (docs/native-header-footer.md, "Master furniture").
2
+ //
3
+ // Furniture parts PowerPoint has no placeholder for (a logo or other zone image, socials, header text, footer text
4
+ // other than the one native `ftr`) are drawn by core on every slide. When a part is drawn the same on two or more slides
5
+ // (the same XML: content, box, style, picture bytes), it is written once instead:
6
+ //
7
+ // - on the slide master, when it is the same on every slide that shows liftable furniture (two or more slides);
8
+ // - on a layout (a copy of the master's layout, with RR-11's placeholders), when it is shared by two or more slides
9
+ // but not by all of them: one layout per distinct set, so a logo with onLight/onDark variants gets one layout per
10
+ // background tone, and a section's header text one layout per section;
11
+ // - a slide that shows none of the master's parts (its footer hidden, `design.footer: false`) uses a layout whose
12
+ // `showMasterSp="0"` hides the master's furniture.
13
+ //
14
+ // A part stays an ordinary shape on its slide when it is drawn on that slide only (nothing repeats, a one-slide deck
15
+ // included), when it holds a live field (a header slide number or current date: its cached value is per slide) or when
16
+ // slide content overlaps its box: PowerPoint draws master and layout shapes beneath every slide shape, while core paints
17
+ // furniture above content, so such a part stays above the content on its slide (`furniture-on-slide` diagnostic).
18
+ //
19
+ // Provenance. A lifted shape keeps its OPF_FURNITURE_V1 tag, with the slot (`footer.left.image`) in place of the slide
20
+ // group and part. Each slide's manifest moves the part (its topology entry, the definition's flag, and its stored text
21
+ // template or logo reference) into `shared`, keyed by slot, with `on: 'master' | 'layout'`; importers before 0.18,
22
+ // which read only slide shapes, ignore `shared` and read the rest of the slide's furniture as before.
23
+
24
+ import {decodeTextTag, encodeTextTag} from './code-provenance.js';
25
+
26
+ const dec = new TextDecoder('utf-8', {fatal: true}), enc = new TextEncoder();
27
+ const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
28
+ const TAGS_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.tags+xml';
29
+ const LAYOUT_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml';
30
+ const NS = 'http://schemas.openxmlformats.org/presentationml/2006/main';
31
+ const TAG = 'OPF_FURNITURE_V1';
32
+
33
+ const relsOf = path => path.replace(/([^/]+)$/, '_rels/$1.rels');
34
+ const attribute = (xml, name) => new RegExp(`\\s${name}="([^"]*)"`).exec(xml)?.[1];
35
+ const escapeAttribute = value => value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
36
+ const unescapeAttribute = value => value.replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
37
+ const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
38
+
39
+ function resolvePart(source, target) {
40
+ if (target.startsWith('/')) return target.slice(1);
41
+ const parts = source.split('/').slice(0, -1);
42
+ for (const piece of target.split('/')) {
43
+ if (piece === '..') parts.pop();
44
+ else if (piece !== '.' && piece) parts.push(piece);
45
+ }
46
+ return parts.join('/');
47
+ }
48
+
49
+ function relativeTarget(source, path) {
50
+ const from = source.split('/').slice(0, -1), to = path.split('/');
51
+ let common = 0;
52
+ while (common < from.length && common < to.length - 1 && from[common] === to[common]) common++;
53
+ return [...Array(from.length - common).fill('..'), ...to.slice(common)].join('/');
54
+ }
55
+
56
+ // The relationships of one part: [{node, id, type, target, external, path}].
57
+ function relationships(xml) {
58
+ return [...xml.matchAll(/<Relationship\b[^>]*\/>/g)].map(([node]) => ({node, id: attribute(node, 'Id'), type: attribute(node, 'Type') ?? '',
59
+ target: unescapeAttribute(attribute(node, 'Target') ?? ''), external: attribute(node, 'TargetMode') === 'External'}));
60
+ }
61
+
62
+ // Top-level text shapes and pictures by object name (generated furniture is never grouped).
63
+ const shapeElements = xml => [...xml.matchAll(/<p:(sp|pic)>[\s\S]*?<\/p:\1>/g)].map(match => ({xml: match[0], kind: match[1], name: unescapeAttribute(match[0].match(/<p:cNvPr\b[^>]*\bname="([^"]*)"/)?.[1] ?? '')}));
64
+ const RELATIONSHIP_ATTRIBUTE = /\br:(embed|link|id|pict)="([^"]*)"/g;
65
+
66
+ function bounds(xml) {
67
+ const boxes = [...xml.matchAll(/<a:off x="(-?\d+)" y="(-?\d+)"\/>\s*<a:ext cx="(\d+)" cy="(\d+)"\/>/g)].map(match => match.slice(1).map(Number));
68
+ if (!boxes.length) return undefined;
69
+ const left = Math.min(...boxes.map(box => box[0])), top = Math.min(...boxes.map(box => box[1]));
70
+ return {left, top, right: Math.max(...boxes.map(box => box[0] + box[2])), bottom: Math.max(...boxes.map(box => box[1] + box[3]))};
71
+ }
72
+ const overlaps = (a, b) => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 0 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 0;
73
+
74
+ /**
75
+ * Write the furniture that repeats across slides once, on the slide master or a layout. `output` maps a package path to
76
+ * [bytes, zipOptions] (the finished, normalized package; per-script masters already exist). `slides[i]` describes slide i:
77
+ * `{path, parts: [{index, kind, zone, field, type, names, native, path}]}`, the core furniture parts in manifest order with the
78
+ * object names of their shapes (one per text line). `report` receives export diagnostics.
79
+ */
80
+ export function liftMasterFurniture(output, slides, report = () => {}) {
81
+ const has = path => Object.hasOwn(output, path);
82
+ const read = path => dec.decode(output[path][0]);
83
+ const options = output['ppt/presentation.xml'][1];
84
+ const write = (path, xml) => { output[path] = [enc.encode(xml), output[path]?.[1] ?? options]; };
85
+ let types = read('[Content_Types].xml');
86
+ const relsCache = new Map();
87
+ const rels = path => {
88
+ if (!relsCache.has(path)) relsCache.set(path, has(relsOf(path)) ? relationships(read(relsOf(path))) : []);
89
+ return relsCache.get(path);
90
+ };
91
+ const related = (path, type) => {
92
+ const rel = rels(path).find(item => !item.external && item.type === `${REL}/${type}`);
93
+ return rel ? resolvePart(path, rel.target) : undefined;
94
+ };
95
+
96
+ // 1. Each slide's candidates: slot -> {signature, box, shapes, part}; and the parts content overlaps.
97
+ const plans = [];
98
+ for (const [slideIndex, slide] of slides.entries()) {
99
+ if (!slide || !has(slide.path)) continue;
100
+ const xml = read(slide.path), layout = related(slide.path, 'slideLayout'), master = layout && related(layout, 'slideMaster');
101
+ if (!layout || !master) continue;
102
+ const elements = new Map(shapeElements(xml).map(element => [element.name, element]));
103
+ const furnitureNames = new Set(slide.parts.flatMap(part => part.names));
104
+ // Every other shape's frame: furniture under any of them keeps its paint order on the slide.
105
+ let others = xml;
106
+ for (const element of shapeElements(xml)) if (furnitureNames.has(element.name)) others = others.replace(element.xml, '');
107
+ const occupied = [...others.matchAll(/<a:off x="(-?\d+)" y="(-?\d+)"\/>\s*<a:ext cx="(\d+)" cy="(\d+)"\/>/g)]
108
+ .map(match => match.slice(1).map(Number)).filter(([, , cx, cy]) => cx > 0 || cy > 0)
109
+ // A line has no area: one unit across, so a rule through the band still counts.
110
+ .map(([x, y, cx, cy]) => ({left: x, top: y, right: x + Math.max(cx, 1), bottom: y + Math.max(cy, 1)}));
111
+ const relationOf = id => {
112
+ const rel = rels(slide.path).find(item => item.id === id);
113
+ return rel ? `${rel.type}|${rel.external ? `external:${rel.target}` : resolvePart(slide.path, rel.target)}` : `missing:${id}`;
114
+ };
115
+ const candidates = new Map(), pinned = new Set();
116
+ for (const part of slide.parts) {
117
+ if (part.native || !part.names.length) continue;
118
+ const shapes = part.names.map(name => elements.get(name));
119
+ if (shapes.some(shape => !shape || shape.kind !== (part.type === 'image' ? 'pic' : 'sp'))) continue;
120
+ if (shapes.some(shape => /<a:fld\b/.test(shape.xml))) continue;
121
+ const box = bounds(shapes.map(shape => shape.xml).join(''));
122
+ if (!box) continue;
123
+ // Drawn content and box: the shape XML without its object id, name and tags, relationship ids read as their targets.
124
+ const signature = shapes.map(shape => shape.xml.replace(/<p:custDataLst>[\s\S]*?<\/p:custDataLst>/g, '')
125
+ .replace(/(<p:cNvPr\b[^>]*?)\s(?:id|name)="[^"]*"/g, '$1').replace(/(<p:cNvPr\b[^>]*?)\s(?:id|name)="[^"]*"/g, '$1')
126
+ .replace(RELATIONSHIP_ATTRIBUTE, (match, kind, id) => `r:${kind}="${relationOf(id)}"`)).join('\n');
127
+ const slot = `${part.kind}.${part.zone}.${part.field}`;
128
+ candidates.set(slot, {slot, signature, part, shapes, box});
129
+ if (occupied.some(frame => overlaps(frame, box))) pinned.add(slot);
130
+ }
131
+ plans.push({slideIndex, slide, xml, layout, master, candidates, pinned});
132
+ }
133
+ if (!plans.length) return;
134
+ // One report per part (its authored path), naming the slides where content covers it.
135
+ const covered = new Map();
136
+ for (const plan of plans) for (const slot of plan.pinned) {
137
+ const part = plan.candidates.get(slot).part;
138
+ covered.set(part.path, {part, slides: [...(covered.get(part.path)?.slides ?? []), plan.slideIndex + 1]});
139
+ }
140
+ for (const [path, {part, slides: numbers}] of covered) report({code: 'furniture-on-slide', path, message: `Slide content overlaps the ${part.kind} ${part.zone} ${part.field} on slide${numbers.length > 1 ? 's' : ''} ${numbers.join(', ')}. PowerPoint draws slide master and layout shapes beneath slide content, so there this part stays a shape on the slide, drawn above the content as in the preview.`});
141
+
142
+ // 2. Per slide master: the master's parts, each slide's layout key, and the layouts to create.
143
+ const value = (plan, slot) => plan.candidates.get(slot)?.signature;
144
+ const free = (plan, slot) => plan.candidates.has(slot) && !plan.pinned.has(slot);
145
+ const id = (slot, signature) => `${slot}\u0000${signature}`;
146
+ const assignments = [];
147
+ for (const master of [...new Set(plans.map(plan => plan.master))]) {
148
+ const group = plans.filter(plan => plan.master === master);
149
+ const showing = group.filter(plan => plan.candidates.size);
150
+ // The master holds the parts drawn the same on every slide that shows liftable furniture.
151
+ let common = showing.length >= 2 ? [...showing[0].candidates.keys()].filter(slot => showing.every(plan => value(plan, slot) === value(showing[0], slot))) : [];
152
+ if (!showing.some(plan => common.every(slot => free(plan, slot)))) common = [];
153
+ const counts = new Map();
154
+ for (const plan of group) for (const [slot, candidate] of plan.candidates) if (free(plan, slot)) counts.set(id(slot, candidate.signature), (counts.get(id(slot, candidate.signature)) ?? 0) + 1);
155
+ const layouts = new Map();
156
+ for (const plan of group) {
157
+ const showsMaster = plan.candidates.size ? common.every(slot => free(plan, slot)) : !common.length;
158
+ const extras = [...plan.candidates.keys()].filter(slot => free(plan, slot) && (showsMaster ? !common.includes(slot) && counts.get(id(slot, value(plan, slot))) >= 2
159
+ : common.includes(slot) || counts.get(id(slot, value(plan, slot))) >= 2)).sort();
160
+ const key = `${showsMaster ? 1 : 0}\u0001${extras.map(slot => id(slot, value(plan, slot))).join('\u0001')}`;
161
+ plan.extras = extras;
162
+ plan.lifted = new Map([...(showsMaster ? common.map(slot => [slot, 'master']) : []), ...extras.map(slot => [slot, 'layout'])]);
163
+ plan.layoutKey = showsMaster && !extras.length ? undefined : key;
164
+ if (plan.layoutKey !== undefined && !layouts.has(key)) layouts.set(key, {showsMaster, source: plan, slides: []});
165
+ if (plan.layoutKey !== undefined) layouts.get(key).slides.push(plan);
166
+ }
167
+ assignments.push({master, group, common, layouts});
168
+ }
169
+ if (!assignments.some(item => item.common.length || item.layouts.size)) return;
170
+
171
+ // 3. Write: the master's shapes, the new layouts, and each slide without its lifted shapes.
172
+ const tagPart = n => `ppt/tags/opfFurnitureShared${n}.xml`;
173
+ let tagNumber = 0, nextLayout = Math.max(0, ...Object.keys(output).map(path => Number(/^ppt\/slideLayouts\/slideLayout(\d+)\.xml$/.exec(path)?.[1] ?? 0))) + 1;
174
+ const presentation = read('ppt/presentation.xml');
175
+ const usedIds = [...presentation.matchAll(/<p:sldMasterId\b[^>]*\bid="(\d+)"/g), ...Object.keys(output).filter(path => /^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(path))
176
+ .flatMap(path => [...read(path).matchAll(/<p:sldLayoutId\b[^>]*\bid="(\d+)"/g)])].map(match => Number(match[1]));
177
+ let nextLayoutId = Math.max(2147483648, ...usedIds) + 1;
178
+ const pending = new Map(); // part path -> {xml, rels}
179
+ const partXml = path => pending.get(path)?.xml ?? read(path);
180
+ const partRels = path => pending.get(path)?.rels ?? (has(relsOf(path)) ? read(relsOf(path)) : `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`);
181
+ const stage = (path, xml, relsXml) => pending.set(path, {xml, rels: relsXml});
182
+ const relationshipId = (relsXml, type, target, external) => {
183
+ const existing = relationships(relsXml).find(rel => rel.type === type && rel.target === target && rel.external === external);
184
+ if (existing) return {relsXml, rid: existing.id};
185
+ const taken = new Set(relationships(relsXml).map(rel => rel.id));
186
+ let n = 1, rid;
187
+ do rid = `rId${n++}`; while (taken.has(rid));
188
+ return {rid, relsXml: relsXml.replace('</Relationships>', `<Relationship Id="${rid}" Type="${type}" Target="${escapeAttribute(target)}"${external ? ' TargetMode="External"' : ''}/></Relationships>`)};
189
+ };
190
+
191
+ // Copy one slide's shapes of a slot into a master or layout, with its relationships and a slot tag.
192
+ const transplant = (target, plan, slot) => {
193
+ const candidate = plan.candidates.get(slot);
194
+ let xml = partXml(target), relsXml = partRels(target);
195
+ let nextId = Math.max(0, ...[...xml.matchAll(/<p:cNvPr\b[^>]*\bid="(\d+)"/g)].map(match => Number(match[1]))) + 1;
196
+ const copies = candidate.shapes.map((shape, line) => {
197
+ let copy = shape.xml;
198
+ // The tag: the slide record with the slot in place of the slide's group and part.
199
+ const tagId = copy.match(/<p:custDataLst>\s*<p:tags r:id="([^"]+)"\/>\s*<\/p:custDataLst>/)?.[1];
200
+ copy = copy.replace(/<p:custDataLst>[\s\S]*?<\/p:custDataLst>/g, '');
201
+ copy = copy.replace(RELATIONSHIP_ATTRIBUTE, (match, kind, rid) => {
202
+ if (!rid) return match;
203
+ const rel = rels(plan.slide.path).find(item => item.id === rid);
204
+ if (!rel) throw new Error(`Furniture shape relationship ${rid} is missing.`);
205
+ const result = relationshipId(relsXml, rel.type, rel.external ? rel.target : relativeTarget(target, resolvePart(plan.slide.path, rel.target)), rel.external);
206
+ relsXml = result.relsXml;
207
+ return `r:${kind}="${result.rid}"`;
208
+ });
209
+ const tagRel = tagId && rels(plan.slide.path).find(item => item.id === tagId);
210
+ if (tagRel) {
211
+ const source = resolvePart(plan.slide.path, tagRel.target), record = decodeTextTag(read(source).match(/\bval="([^"]*)"/)[1]);
212
+ const {group, part, ...rest} = record;
213
+ const path = tagPart(++tagNumber);
214
+ output[path] = [enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${TAG}" val="${encodeTextTag({...rest, slot})}"/></p:tagLst>`), options];
215
+ types = types.replace('</Types>', `<Override PartName="/${path}" ContentType="${TAGS_TYPE}"/></Types>`);
216
+ const result = relationshipId(relsXml, `${REL}/tags`, relativeTarget(target, path), false);
217
+ relsXml = result.relsXml;
218
+ copy = copy.replace(/<p:nvPr\s*\/>/, '<p:nvPr></p:nvPr>').replace('</p:nvPr>', `<p:custDataLst><p:tags r:id="${result.rid}"/></p:custDataLst></p:nvPr>`);
219
+ }
220
+ const name = `OPF furniture ${slot.replace(/\./g, ' ')}${candidate.part.type === 'text' ? ` line ${line}` : ''}`;
221
+ return copy.replace(/(<p:cNvPr\b[^>]*?\bid=")\d+(")/, `$1${nextId++}$2`).replace(/(<p:cNvPr\b[^>]*?\bname=")[^"]*(")/, `$1${escapeAttribute(name)}$2`);
222
+ });
223
+ const end = xml.lastIndexOf('</p:spTree>');
224
+ if (end < 0) throw new Error(`${target} has no shape tree.`);
225
+ stage(target, xml.slice(0, end) + copies.join('') + xml.slice(end), relsXml);
226
+ };
227
+
228
+ for (const {master, group, common, layouts} of assignments) {
229
+ const showing = group.find(plan => common.every(slot => free(plan, slot)) && plan.candidates.size);
230
+ for (const slot of common) transplant(master, showing, slot);
231
+ // The layout the slides use now (each master has one), copied once per layout key.
232
+ const base = group[0].layout;
233
+ let masterXml = partXml(master), masterRels = partRels(master);
234
+ let number = 0;
235
+ for (const layout of layouts.values()) {
236
+ const path = `ppt/slideLayouts/slideLayout${nextLayout++}.xml`;
237
+ const name = layout.source.extras.length ? `OPF furniture ${++number}` : 'OPF no furniture';
238
+ let xml = read(base).replace(/(<p:cSld\b[^>]*?\bname=")[^"]*(")/, `$1${escapeAttribute(name)}$2`);
239
+ if (!layout.showsMaster) xml = xml.replace(/<p:sldLayout\b[^>]*>/, open => `${open.slice(0, -1).replace(/\s+showMasterSp="[^"]*"/, '')} showMasterSp="0">`);
240
+ const baseRels = has(relsOf(base)) ? read(relsOf(base)) : '';
241
+ // A copied layout keeps only its master relationship; the furniture brings its own.
242
+ const masterRel = relationships(baseRels).find(rel => rel.type === `${REL}/slideMaster`);
243
+ if (!masterRel) throw new Error(`${base} has no slide master relationship.`);
244
+ stage(path, xml, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${masterRel.node}</Relationships>`);
245
+ types = types.replace('</Types>', `<Override PartName="/${path}" ContentType="${LAYOUT_TYPE}"/></Types>`);
246
+ for (const slot of layout.source.extras) transplant(path, layout.source, slot);
247
+ const result = relationshipId(masterRels, `${REL}/slideLayout`, relativeTarget(master, path), false);
248
+ masterRels = result.relsXml;
249
+ if (!masterXml.includes('</p:sldLayoutIdLst>')) throw new Error(`${master} has no layout list.`);
250
+ masterXml = masterXml.replace('</p:sldLayoutIdLst>', `<p:sldLayoutId id="${nextLayoutId++}" r:id="${result.rid}"/></p:sldLayoutIdLst>`);
251
+ layout.path = path;
252
+ }
253
+ stage(master, masterXml, masterRels);
254
+
255
+ // Each slide: drop its lifted shapes (and what only they referenced), point it at its layout, and move the manifest entries.
256
+ for (const plan of group) {
257
+ if (!plan.lifted.size && plan.layoutKey === undefined) continue;
258
+ let xml = plan.xml, relsXml = read(relsOf(plan.slide.path));
259
+ // The relationships only the removed shapes used (their tags, pictures and links) go with them.
260
+ const released = new Set();
261
+ for (const slot of plan.lifted.keys()) for (const shape of plan.candidates.get(slot).shapes) {
262
+ for (const match of shape.xml.matchAll(RELATIONSHIP_ATTRIBUTE)) released.add(match[2]);
263
+ xml = xml.replace(shape.xml, '');
264
+ }
265
+ for (const rel of relationships(relsXml)) {
266
+ if (!released.has(rel.id) || new RegExp(`"${escapeRegExp(rel.id)}"`).test(xml)) continue;
267
+ relsXml = relsXml.replace(rel.node, '');
268
+ if (rel.type === `${REL}/tags`) {
269
+ const path = resolvePart(plan.slide.path, rel.target);
270
+ delete output[path];
271
+ types = types.replace(`<Override PartName="/${path}" ContentType="${TAGS_TYPE}"/>`, '');
272
+ }
273
+ }
274
+ if (plan.layoutKey !== undefined) {
275
+ const target = relativeTarget(plan.slide.path, layoutsFor(assignments, plan).path);
276
+ relsXml = relsXml.replace(/<Relationship\b[^>]*\/>/g, node => attribute(node, 'Type') === `${REL}/slideLayout` ? node.replace(/\sTarget="[^"]*"/, ` Target="${target}"`) : node);
277
+ }
278
+ moveManifestEntries(output, plan, read, write);
279
+ write(plan.slide.path, xml);
280
+ write(relsOf(plan.slide.path), relsXml);
281
+ }
282
+ }
283
+ for (const [path, {xml, rels: relsXml}] of pending) { write(path, xml); write(relsOf(path), relsXml); }
284
+ write('[Content_Types].xml', types);
285
+ }
286
+
287
+ function layoutsFor(assignments, plan) {
288
+ return assignments.find(item => item.group.includes(plan)).layouts.get(plan.layoutKey);
289
+ }
290
+
291
+ // The slide's manifest: each lifted part leaves `parts`, `definitions` (its flag), `templates` and `images` for `shared`, and the
292
+ // remaining parts are renumbered in the slide's own shape tags, so an importer that does not know `shared` reads the slide as it is.
293
+ function moveManifestEntries(output, plan, read, write) {
294
+ const path = `ppt/tags/opfFurnitureSlide${plan.slideIndex}.xml`;
295
+ if (!Object.hasOwn(output, path) || !plan.lifted.size) return;
296
+ const manifest = decodeTextTag(read(path).match(/\bval="([^"]*)"/)[1]);
297
+ const shared = {}, keep = [], renumber = new Map();
298
+ for (const [index, part] of manifest.parts.entries()) {
299
+ const slot = `${part.kind}.${part.zone}.${part.field}`, on = plan.lifted.get(slot);
300
+ if (!on) { renumber.set(index, keep.length); keep.push(part); continue; }
301
+ const zone = manifest.definitions[part.kind].value[part.zone];
302
+ const entry = {...part, on, flag: zone[part.field]};
303
+ delete zone[part.field];
304
+ const key = `${part.kind}.${part.zone}`;
305
+ if (part.field === 'text' && manifest.templates?.[key] !== undefined) { entry.template = manifest.templates[key]; delete manifest.templates[key]; }
306
+ if (part.field === 'image' && manifest.images?.[key] !== undefined) { entry.image = manifest.images[key]; delete manifest.images[key]; }
307
+ shared[slot] = entry;
308
+ }
309
+ for (const name of ['templates', 'images']) if (manifest[name] && !Object.keys(manifest[name]).length) delete manifest[name];
310
+ manifest.parts = keep;
311
+ manifest.shared = shared;
312
+ write(path, read(path).replace(/\bval="[^"]*"/, () => `val="${encodeTextTag(manifest)}"`));
313
+ // Renumber the slide's remaining furniture shape tags.
314
+ const slideRels = relationships(read(relsOf(plan.slide.path)));
315
+ const remaining = new Set(plan.slide.parts.filter(part => !plan.lifted.has(`${part.kind}.${part.zone}.${part.field}`)).flatMap(part => part.names));
316
+ for (const element of shapeElements(plan.xml)) {
317
+ if (!remaining.has(element.name)) continue;
318
+ const tagId = element.xml.match(/<p:custDataLst>\s*<p:tags r:id="([^"]+)"\/>/)?.[1];
319
+ const rel = tagId && slideRels.find(item => item.id === tagId);
320
+ if (!rel) continue;
321
+ const tag = resolvePart(plan.slide.path, rel.target);
322
+ const record = decodeTextTag(read(tag).match(/\bval="([^"]*)"/)[1]);
323
+ if (record.part === undefined || !renumber.has(record.part) || renumber.get(record.part) === record.part) continue;
324
+ write(tag, read(tag).replace(/\bval="[^"]*"/, () => `val="${encodeTextTag({...record, part: renumber.get(record.part)})}"`));
325
+ }
326
+ }
@@ -13,6 +13,8 @@ const hashBytes = bytes => {
13
13
  h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
14
14
  return (h2 >>> 0).toString(16) + (h1 >>> 0).toString(16);
15
15
  };
16
+ /** The content key of a media part's bytes: its length and hash (a bucket key; equal keys are confirmed byte for byte where it matters). */
17
+ export const mediaKey = bytes => `${bytes.byteLength}:${hashBytes(bytes)}`;
16
18
  const sameBytes = (a, b) => a.byteLength === b.byteLength && a.every((value, index) => value === b[index]);
17
19
  const decoder = new TextDecoder(), encoder = new TextEncoder();
18
20
 
@@ -16,7 +16,7 @@
16
16
  // or where core would draw a default footer when no slide has one.
17
17
 
18
18
  import {XMLParser} from 'fast-xml-parser';
19
- import {DEFAULT_DATE_FORMAT, NATIVE_DATE_FIELDS, formatDate} from './furniture-fields.js';
19
+ import {DEFAULT_DATE_FORMAT, NATIVE_DATE_FIELDS, formatDate, hasSlideNumberField, slideNumberTemplate} from './furniture-fields.js';
20
20
 
21
21
  const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal: true});
22
22
  const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, trimValues: false});
@@ -28,25 +28,33 @@ const EMU = 9525;
28
28
  export const NATIVE_PLACEHOLDERS = Object.freeze({
29
29
  dt: {field: 'date', name: 'Date Placeholder', sz: 'half', idx: 10, masterIdx: 2, zone: 'left', align: 'l'},
30
30
  ftr: {field: 'text', name: 'Footer Placeholder', sz: 'quarter', idx: 11, masterIdx: 3, zone: 'center', align: 'ctr'},
31
- sldNum: {field: 'slideNumber', name: 'Slide Number Placeholder', sz: 'quarter', idx: 12, masterIdx: 4, zone: 'right', align: 'r'},
31
+ sldNum: {field: 'text', name: 'Slide Number Placeholder', sz: 'quarter', idx: 12, masterIdx: 4, zone: 'right', align: 'r'},
32
32
  });
33
33
  const ORDER = ['dt', 'ftr', 'sldNum'];
34
- const phByField = {date: 'dt', text: 'ftr', slideNumber: 'sldNum'};
35
34
  export const isNativePlaceholderType = value => Object.hasOwn(NATIVE_PLACEHOLDERS, value);
36
- export const nativePlaceholderForField = field => phByField[field];
35
+ /**
36
+ * The placeholder type a core footer part maps to: a date is `dt`; a zone `text` with a slide-number field
37
+ * (`{{slide.number}}`) is `sldNum`, its words around the field fixed runs; any other zone `text` is `ftr`.
38
+ */
39
+ export function nativePlaceholderForPart(part) {
40
+ if (part.type !== 'text') return undefined;
41
+ if (part.field === 'date') return 'dt';
42
+ if (part.field !== 'text') return undefined;
43
+ return hasSlideNumberField(part) ? 'sldNum' : 'ftr';
44
+ }
37
45
  const ZONES = ['left', 'center', 'right'];
38
46
  const ALIGN = {left: 'l', center: 'ctr', right: 'r'};
39
47
 
40
48
  /**
41
49
  * Which accepted core parts of one slide become native placeholders: the first
42
- * footer text, the first footer date and the first footer slide number whose
43
- * text fits one accepted line. Returns Map<partIndex, 'dt'|'ftr'|'sldNum'>.
50
+ * footer text, the first footer date and the first footer text with a slide-number
51
+ * field whose text fits one accepted line. Returns Map<partIndex, 'dt'|'ftr'|'sldNum'>.
44
52
  */
45
53
  export function nativeFurnitureParts(layout) {
46
54
  const chosen = new Map(), taken = new Set();
47
55
  for (const [index, part] of (layout?.parts ?? []).entries()) {
48
- const ph = phByField[part.field];
49
- if (part.kind !== 'footer' || part.type !== 'text' || !ph || taken.has(ph)) continue;
56
+ const ph = nativePlaceholderForPart(part);
57
+ if (part.kind !== 'footer' || !ph || taken.has(ph)) continue;
50
58
  // One native shape holds one line; a part that wraps or breaks stays tagged shapes.
51
59
  if (part.fit?.sourceLines?.length !== 1 || part.fit.lines?.length !== 1 || !part.text || part.links?.length) continue;
52
60
  taken.add(ph);
@@ -70,9 +78,10 @@ const guid = (kind, n) => `{0F0F2701-${kind}-4000-8000-${n.toString(16).toUpperC
70
78
  * maps a generated object name to its placeholder type. Runs after the tags are
71
79
  * attached: the shape keeps its name, geometry, runs, fields and tags, and gains
72
80
  * `p:ph` (a placeholder is a text shape the layout can supply defaults for).
81
+ * `bands` maps a generated object name to its zone's band (`{x, width}` in pixels) for a part narrower than its zone.
73
82
  * Returns the placeholders in use and the first geometry/alignment seen per type.
74
83
  */
75
- export function attachNativePlaceholders(entries, records) {
84
+ export function attachNativePlaceholders(entries, records, bands = new Map()) {
76
85
  const used = new Set(), first = new Map(), seen = new Set();
77
86
  if (!records.size) return {used, first};
78
87
  const slides = Object.keys(entries).filter(path => /^ppt\/slides\/slide\d+\.xml$/.test(path)).sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]));
@@ -93,7 +102,10 @@ export function attachNativePlaceholders(entries, records) {
93
102
  if (!first.has(ph)) {
94
103
  const geometry = shape.match(/<a:off x="(-?\d+)" y="(-?\d+)"\/><a:ext cx="(\d+)" cy="(\d+)"\/>/);
95
104
  const algn = shape.match(/<a:pPr\b[^>]*\balgn="(\w+)"/)?.[1] ?? 'l';
96
- if (geometry) first.set(ph, {x: Number(geometry[1]), y: Number(geometry[2]), cx: Number(geometry[3]), cy: Number(geometry[4]), algn});
105
+ // A part in a zone with other parts (a logo beside the footer text) is only as wide as its text (RR-71); the master and
106
+ // layout placeholders a footer added natively lands in use the zone's whole band at the same edge.
107
+ const band = bands.get(name);
108
+ if (geometry) first.set(ph, {x: band ? Math.round(band.x * EMU) : Number(geometry[1]), y: Number(geometry[2]), cx: band ? Math.round(band.width * EMU) : Number(geometry[3]), cy: Number(geometry[4]), algn});
97
109
  }
98
110
  changed = true;
99
111
  return shape;
@@ -252,8 +264,9 @@ function zoneOf(bounds, slideWidth, fallback) {
252
264
  /**
253
265
  * The footer one slide's native placeholders describe. `claimed` is the set of
254
266
  * shape indexes OPF provenance already consumed or reserved. Returns
255
- * {fields: [{zone, field, value, settings, index}], skipped: [index]} where `value`
256
- * is the OPF field value and `settings` the zone's `dateFormat`/`slideNumberFormat`.
267
+ * [{zone, field, value, settings, index, ph}] where `value` is the OPF field value (a zone
268
+ * `text` carries `{{slide.number}}` where the placeholder holds a native slide-number field)
269
+ * and `settings` the zone's `dateFormat`.
257
270
  */
258
271
  export function readNativePlaceholders(context, claimed, relationships, readPart) {
259
272
  const fields = [];
@@ -262,36 +275,44 @@ export function readNativePlaceholders(context, claimed, relationships, readPart
262
275
  if (!isNativePlaceholderType(ph) || claimed.has(index)) continue;
263
276
  const paragraphs = context.paragraphs[index] ?? [];
264
277
  const text = paragraphs.map(paragraph => paragraph.text).join('\n');
265
- const nativeFields = paragraphs.flatMap(paragraph => paragraph.fields ?? []);
278
+ // The native fields with their offsets into `text` (the paragraphs are joined by a newline).
279
+ const nativeFields = [];
280
+ let base = 0;
281
+ for (const paragraph of paragraphs) {
282
+ for (const field of paragraph.fields ?? []) nativeFields.push({...field, start: base + (field.start ?? text.indexOf(field.text, base) - base)});
283
+ base += paragraph.text.length + 1;
284
+ }
266
285
  const zone = zoneOf(inheritedBounds(shape, ph, context.path, relationships, readPart), context.slideWidth, NATIVE_PLACEHOLDERS[ph].zone);
267
286
  const properties = NATIVE_PLACEHOLDERS[ph];
268
287
  if (ph === 'sldNum') {
269
- const live = nativeFields.filter(field => field.type === 'slidenum');
270
- if (live.length !== 1) continue;
271
- const at = text.indexOf(live[0].text);
272
- const format = at < 0 ? undefined : text.slice(0, at) + '{current}' + text.slice(at + live[0].text.length);
273
- fields.push({index, zone, field: properties.field, value: true, settings: format && format !== '{current}' ? {slideNumberFormat: format} : {}});
288
+ // The words around a native slide-number field are the zone text and the field is `{{slide.number}}`.
289
+ if (!nativeFields.some(field => field.type === 'slidenum')) continue;
290
+ fields.push({index, zone, ph, field: properties.field, value: slideNumberTemplate(text, nativeFields), settings: {}});
274
291
  } else if (ph === 'dt') {
275
292
  if (!text.trim()) continue;
276
293
  const pattern = nativeFields.length === 1 && nativeFields[0].text === text ? dateFormatForField[nativeFields[0].type] : undefined;
277
- if (pattern) fields.push({index, zone, field: properties.field, value: true, settings: pattern === DEFAULT_DATE_FORMAT ? {} : {dateFormat: pattern}});
294
+ if (pattern) fields.push({index, zone, ph, field: properties.field, value: true, settings: pattern === DEFAULT_DATE_FORMAT ? {} : {dateFormat: pattern}});
278
295
  // A fixed date, or a field with no OPF pattern (a time, the master's datetimeFigureOut), keeps its current words.
279
- else fields.push({index, zone, field: properties.field, value: text, settings: {}});
296
+ else fields.push({index, zone, ph, field: properties.field, value: text, settings: {}});
280
297
  } else {
281
298
  if (!text.trim()) continue;
282
- fields.push({index, zone, field: properties.field, value: text, settings: {}});
299
+ fields.push({index, zone, ph, field: properties.field, value: slideNumberTemplate(text, nativeFields), settings: {}});
283
300
  }
284
301
  }
285
302
  return fields;
286
303
  }
287
304
 
288
- /** The OPF footer value for native placeholder fields: zones keyed left/center/right. */
305
+ /**
306
+ * The OPF footer value for native placeholder fields: zones keyed left/center/right. A footer text and a slide
307
+ * number in one zone are one `text`: the footer words, then the number's line (the zone stack's order).
308
+ */
289
309
  export function footerValue(fields) {
290
310
  const value = {};
291
- for (const item of fields) {
311
+ for (const item of [...fields].sort((a, b) => ORDER.indexOf(a.ph) - ORDER.indexOf(b.ph))) {
292
312
  value[item.zone] ??= {};
293
- value[item.zone][item.field] = item.value;
294
- Object.assign(value[item.zone], item.settings);
313
+ const zone = value[item.zone];
314
+ zone[item.field] = item.field === 'text' && typeof zone.text === 'string' ? `${zone.text}\n${item.value}` : item.value;
315
+ Object.assign(zone, item.settings);
295
316
  }
296
317
  return Object.fromEntries(ZONES.filter(zone => value[zone]).map(zone => [zone, value[zone]]));
297
318
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-pptx",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
4
4
  "description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -54,7 +54,7 @@
54
54
  "test:metric": "node test/shared-metric.mjs && node test/metric-provenance.mjs",
55
55
  "test:font-variants": "node test/font-variants.mjs",
56
56
  "test:timeline": "node test/shared-timeline.mjs",
57
- "test:furniture": "node test/shared-furniture.mjs && node test/furniture-provenance.mjs && node test/furniture-fields.mjs && node test/furniture-wrapped-date.mjs && node test/socials-furniture.mjs && node test/native-furniture.mjs",
57
+ "test:furniture": "node test/shared-furniture.mjs && node test/furniture-provenance.mjs && node test/slide-variables.mjs && node test/furniture-fields.mjs && node test/furniture-wrapped-date.mjs && node test/socials-furniture.mjs && node test/native-furniture.mjs",
58
58
  "test:native-picture-edit-controls": "node test/compare-native-picture-edit-controls.mjs",
59
59
  "test:native-font-embed-controls": "node test/native-font-embed-controls.mjs",
60
60
  "test:native-mixed-edit-controls": "node test/native-mixed-edit-controls.mjs",
@@ -65,7 +65,7 @@
65
65
  },
66
66
  "dependencies": {
67
67
  "@node-projects/jszip": "4.3.0",
68
- "@openpresentation/opf": "^0.16.0",
68
+ "@openpresentation/opf": "^0.18.1",
69
69
  "@rgrove/parse-xml": "4.2.3",
70
70
  "fast-xml-parser": "^5.8.0",
71
71
  "fflate": "^0.8.3",
@@ -73,7 +73,7 @@
73
73
  "sharp": "0.35.5"
74
74
  },
75
75
  "peerDependencies": {
76
- "@openpresentation/opf-render": "^0.16.0"
76
+ "@openpresentation/opf-render": "^0.18.0"
77
77
  },
78
78
  "peerDependenciesMeta": {
79
79
  "@openpresentation/opf-render": {
@@ -92,7 +92,7 @@
92
92
  "@expo-google-fonts/roboto": "0.4.3",
93
93
  "@expo-google-fonts/roboto-mono": "0.4.2",
94
94
  "@expo-google-fonts/tinos": "0.4.2",
95
- "@openpresentation/opf-render": "0.16.0",
95
+ "@openpresentation/opf-render": "0.18.0",
96
96
  "@resvg/resvg-js": "2.6.2",
97
97
  "esbuild": "0.28.2",
98
98
  "playwright": "1.63.0"