@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.
- package/README.md +13 -11
- package/dist/code-provenance.js +1 -1
- package/dist/document-provenance.js +37 -40
- package/dist/furniture-fields.js +41 -9
- package/dist/furniture-provenance.js +242 -135
- package/dist/image-fallback-node.js +2 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +127 -44
- package/dist/logo-provenance.js +8 -8
- package/dist/master-furniture.js +326 -0
- package/dist/media-dedupe.js +2 -0
- package/dist/native-furniture.js +46 -25
- package/package.json +5 -5
|
@@ -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, '&').replace(/</g, '<').replace(/"/g, '"');
|
|
36
|
+
const unescapeAttribute = value => value.replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/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
|
+
}
|
package/dist/media-dedupe.js
CHANGED
|
@@ -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
|
|
package/dist/native-furniture.js
CHANGED
|
@@ -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: '
|
|
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
|
-
|
|
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
|
|
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 =
|
|
49
|
-
if (part.kind !== 'footer' ||
|
|
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
|
-
|
|
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
|
-
*
|
|
256
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
if (
|
|
271
|
-
|
|
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
|
-
/**
|
|
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]
|
|
294
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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"
|