@openpresentation/opf-pptx 0.17.0 → 0.18.1
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 +35 -10
- package/dist/chart-order.js +76 -0
- package/dist/content-topology.js +25 -0
- package/dist/data-provenance.js +71 -4
- package/dist/document-provenance.js +34 -16
- package/dist/furniture-provenance.js +179 -69
- package/dist/image-fallback-node.js +2 -2
- package/dist/image-provenance.js +54 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +204 -56
- 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 +6 -2
- package/dist/run-colors.js +28 -2
- package/package.json +9 -4
|
@@ -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
|
@@ -78,9 +78,10 @@ const guid = (kind, n) => `{0F0F2701-${kind}-4000-8000-${n.toString(16).toUpperC
|
|
|
78
78
|
* maps a generated object name to its placeholder type. Runs after the tags are
|
|
79
79
|
* attached: the shape keeps its name, geometry, runs, fields and tags, and gains
|
|
80
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.
|
|
81
82
|
* Returns the placeholders in use and the first geometry/alignment seen per type.
|
|
82
83
|
*/
|
|
83
|
-
export function attachNativePlaceholders(entries, records) {
|
|
84
|
+
export function attachNativePlaceholders(entries, records, bands = new Map()) {
|
|
84
85
|
const used = new Set(), first = new Map(), seen = new Set();
|
|
85
86
|
if (!records.size) return {used, first};
|
|
86
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]));
|
|
@@ -101,7 +102,10 @@ export function attachNativePlaceholders(entries, records) {
|
|
|
101
102
|
if (!first.has(ph)) {
|
|
102
103
|
const geometry = shape.match(/<a:off x="(-?\d+)" y="(-?\d+)"\/><a:ext cx="(\d+)" cy="(\d+)"\/>/);
|
|
103
104
|
const algn = shape.match(/<a:pPr\b[^>]*\balgn="(\w+)"/)?.[1] ?? 'l';
|
|
104
|
-
|
|
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});
|
|
105
109
|
}
|
|
106
110
|
changed = true;
|
|
107
111
|
return shape;
|
package/dist/run-colors.js
CHANGED
|
@@ -87,7 +87,7 @@ export function validateRunColors(value) {
|
|
|
87
87
|
function* markedRuns(value) {
|
|
88
88
|
if (Array.isArray(value)) { for (const item of value) yield* markedRuns(item); return; }
|
|
89
89
|
if (!object(value)) return;
|
|
90
|
-
if (Object.hasOwn(value, '_opfScheme')) yield value;
|
|
90
|
+
if (Object.hasOwn(value, '_opfScheme') || Object.hasOwn(value, '_opfInherited')) yield value;
|
|
91
91
|
for (const item of Object.values(value)) if (item !== null && typeof item === 'object') yield* markedRuns(item);
|
|
92
92
|
}
|
|
93
93
|
|
|
@@ -96,13 +96,17 @@ function* markedRuns(value) {
|
|
|
96
96
|
* {record, structure, content}: the stored OPF_SLIDE_V1 value, whether the slide's arrangement matches it and whether its
|
|
97
97
|
* content structure was restored from it, or undefined for an untagged slide. `resolve(slide, name)` returns the '#RRGGBB'
|
|
98
98
|
* the name resolves to in the document (or undefined). Every transient `_opfScheme` marker is removed.
|
|
99
|
+
* opf-pptx#212: a list run whose colour import removed as the list's own (OPF_SLIDE_V1 listRuns) carries `_opfInherited`, that
|
|
100
|
+
* colour: a stored reference that resolves to it is restored (an authored `text` role equals the inherited colour), and the
|
|
101
|
+
* marker is removed; a run left with only its text becomes a plain string again.
|
|
99
102
|
*/
|
|
100
103
|
export function restoreRunColors(slides, info, resolve, report) {
|
|
101
104
|
slides.forEach((slide, index) => {
|
|
102
105
|
const tag = info[index];
|
|
103
106
|
const marked = [...markedRuns(slide)];
|
|
104
107
|
const restoreName = (run, name) => {
|
|
105
|
-
const
|
|
108
|
+
const current = run.color ?? run._opfInherited;
|
|
109
|
+
const hex = typeof current === 'string' && /^#[0-9a-f]{6}$/i.test(current) ? current.toUpperCase() : undefined;
|
|
106
110
|
if (hex && resolve(slide, name)?.toUpperCase() === hex) run.color = name;
|
|
107
111
|
};
|
|
108
112
|
if (tag?.record?.colors) {
|
|
@@ -117,5 +121,27 @@ export function restoreRunColors(slides, info, resolve, report) {
|
|
|
117
121
|
for (const run of marked) if (typeof run._opfScheme === 'string') restoreName(run, run._opfScheme);
|
|
118
122
|
}
|
|
119
123
|
for (const run of marked) delete run._opfScheme;
|
|
124
|
+
const inherited = new Set(marked.filter(run => Object.hasOwn(run, '_opfInherited')));
|
|
125
|
+
for (const run of inherited) delete run._opfInherited;
|
|
126
|
+
if (inherited.size) plainInheritedRuns(slide, inherited);
|
|
120
127
|
});
|
|
121
128
|
}
|
|
129
|
+
|
|
130
|
+
// The list values of a slide in which a run of `runs` is left with only its text: that run becomes a string, and a value that
|
|
131
|
+
// is then one string is that string (the authored form; the run slots are unchanged).
|
|
132
|
+
function plainInheritedRuns(slide, runs) {
|
|
133
|
+
const plain = value => {
|
|
134
|
+
if (!Array.isArray(value) || !value.some(run => runs.has(run))) return value;
|
|
135
|
+
const next = value.map(run => runs.has(run) && Object.keys(run).length === 1 ? run.text : run);
|
|
136
|
+
return next.length === 1 && typeof next[0] === 'string' ? next[0] : next;
|
|
137
|
+
};
|
|
138
|
+
for (const leaf of leaves(slide)) for (const field of ['items', 'bullets']) {
|
|
139
|
+
if (!Array.isArray(leaf[field])) continue;
|
|
140
|
+
leaf[field] = leaf[field].map(item => {
|
|
141
|
+
if (!object(item)) return plain(item);
|
|
142
|
+
const next = {...item, text: plain(item.text)};
|
|
143
|
+
if (item.description !== undefined) next.description = plain(item.description);
|
|
144
|
+
return next;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-pptx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -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.3",
|
|
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.1"
|
|
77
77
|
},
|
|
78
78
|
"peerDependenciesMeta": {
|
|
79
79
|
"@openpresentation/opf-render": {
|
|
@@ -86,13 +86,18 @@
|
|
|
86
86
|
"@expo-google-fonts/carlito": "0.4.1",
|
|
87
87
|
"@expo-google-fonts/cousine": "0.4.3",
|
|
88
88
|
"@expo-google-fonts/gelasio": "0.4.1",
|
|
89
|
+
"@expo-google-fonts/noto-color-emoji": "0.4.7",
|
|
89
90
|
"@expo-google-fonts/noto-naskh-arabic": "0.4.5",
|
|
90
91
|
"@expo-google-fonts/noto-sans": "0.4.2",
|
|
91
92
|
"@expo-google-fonts/noto-sans-arabic": "0.4.3",
|
|
93
|
+
"@expo-google-fonts/noto-sans-armenian": "0.4.2",
|
|
94
|
+
"@expo-google-fonts/noto-sans-ethiopic": "0.4.2",
|
|
95
|
+
"@expo-google-fonts/noto-sans-georgian": "0.4.3",
|
|
96
|
+
"@expo-google-fonts/noto-sans-symbols-2": "0.4.1",
|
|
92
97
|
"@expo-google-fonts/roboto": "0.4.3",
|
|
93
98
|
"@expo-google-fonts/roboto-mono": "0.4.2",
|
|
94
99
|
"@expo-google-fonts/tinos": "0.4.2",
|
|
95
|
-
"@openpresentation/opf-render": "0.
|
|
100
|
+
"@openpresentation/opf-render": "0.18.1",
|
|
96
101
|
"@resvg/resvg-js": "2.6.2",
|
|
97
102
|
"esbuild": "0.28.2",
|
|
98
103
|
"playwright": "1.63.0"
|