@openpresentation/opf-pptx 0.18.2 → 0.20.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/DEPENDENCY-NOTES.md +2 -0
- package/README.md +7 -3
- package/dist/content-topology.js +63 -6
- package/dist/data-provenance.js +7 -4
- package/dist/document-provenance.js +23 -9
- package/dist/index.d.ts +8 -5
- package/dist/index.js +199 -20
- package/dist/master-furniture.js +25 -7
- package/dist/source-run-styles.js +71 -0
- package/dist/template-layouts.js +514 -0
- package/package.json +5 -4
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
// RR-81: OPF 0.19 layout templates as native PowerPoint slide layouts (core docs/programs/release-readiness/0.19-layouts.md,
|
|
2
|
+
// section 8).
|
|
3
|
+
//
|
|
4
|
+
// Export. When a slide of the deck uses a template, every template the deck can reach becomes one `p:sldLayout` under each
|
|
5
|
+
// slide master (one per theme and script profile), so Change Layout reaches all of them (design question 7): the templates of
|
|
6
|
+
// the registered catalogs in catalog order, then the templates the document embeds (`custom` first, then the other groups by
|
|
7
|
+
// name), each by id. Automatic slides also get an OPF auto titleOnly layout. Named by the record's `name`, with one placeholder per area of the empty layout that core's
|
|
8
|
+
// `composeLayoutAreas` composes at the deck size (nothing collapsed): `title` (`ctrTitle` with a `subTitle` below it on
|
|
9
|
+
// `cover` and `section`), `subTitle` for a `subtitle` area, and per body region a placeholder typed by what it accepts
|
|
10
|
+
// (`placeholderType`). The layout keeps RR-11's date, footer and slide-number placeholders and `p:hf` (it starts as a copy of
|
|
11
|
+
// the layout the slide used), and carries an OPF_LAYOUT_V1 tag in its common slide data: the layout's id, group, source, the
|
|
12
|
+
// reference its first slide wrote, a hash of the record and the placeholder index of every region. Slides relate to it. A
|
|
13
|
+
// slide whose areas collapse still uses the full layout: its own geometry is explicit.
|
|
14
|
+
//
|
|
15
|
+
// A `none` region that holds one picture, chart or table binds that object to its placeholder (`p:ph idx` at the composed
|
|
16
|
+
// geometry), so PowerPoint's Reset, Change Picture and Change Layout move it. Blocks of flowing regions, text blocks and
|
|
17
|
+
// everything else stay editable shapes at core's geometry (the slide's OPF_SLIDE_V1 content topology names the region of
|
|
18
|
+
// each root block); the slide does not instantiate those placeholders, so a filled slide shows no prompt.
|
|
19
|
+
//
|
|
20
|
+
// Accepted one-line plain titles bind to the native title placeholder; long and rich titles remain tagged line shapes.
|
|
21
|
+
// Automatic slides use OPF auto (titleOnly), independently of registered catalogs. Layout prompts carry explicit OPF typography.
|
|
22
|
+
//
|
|
23
|
+
// Import. Foreign layouts map by type or a case-insensitive built-in name when v2 built-ins are registered. A slide whose layout carries OPF_LAYOUT_V1 takes that layout's reference when it has none (a slide added in
|
|
24
|
+
// PowerPoint, or an export without provenance) or when its stored layout names another layout (Change Layout:
|
|
25
|
+
// `layout-changed`). A block read from a placeholder of a region gets a `region` pin when plain binding would put it
|
|
26
|
+
// elsewhere; content that document provenance restored is never pinned.
|
|
27
|
+
|
|
28
|
+
import {composeLayoutAreas, composeSlide, layoutTemplate, bindRegions, regionAccepts} from '@openpresentation/opf/composition';
|
|
29
|
+
import {validateCatalogRecord, catalogRecords, resolveReference} from '@openpresentation/opf';
|
|
30
|
+
import {gallery} from '@openpresentation/gallery';
|
|
31
|
+
import {decodeTextTag, encodeTextTag} from './code-provenance.js';
|
|
32
|
+
|
|
33
|
+
export const LAYOUT_TAG = 'OPF_LAYOUT_V1';
|
|
34
|
+
const dec = new TextDecoder('utf-8', {fatal: true}), enc = new TextEncoder();
|
|
35
|
+
const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
|
36
|
+
const NS = 'http://schemas.openxmlformats.org/presentationml/2006/main';
|
|
37
|
+
const LAYOUT_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml';
|
|
38
|
+
const TAGS_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.tags+xml';
|
|
39
|
+
const EMU = 9525;
|
|
40
|
+
// Region placeholder indexes (coordinator decisions on RR-81): a base per placeholder kind plus the region's position among the
|
|
41
|
+
// layout's regions of that kind, in the record's `regions` order: `obj` 100, `body` 300, `pic` 500, `chart` 700, `tbl` 900,
|
|
42
|
+
// `media` 1100 (so the first chart region of every layout is 700, its second 701). PowerPoint's Change Layout pairs a slide's
|
|
43
|
+
// placeholders with the new layout's by `idx`, so only regions of one kind pair (Pillars to Text keeps `obj` with `obj`; a chart
|
|
44
|
+
// bound to Chart beside's `chart` region never lands in Table beside's `tbl` region, as it did with one sequence for every kind).
|
|
45
|
+
// The indexes stay clear of PowerPoint's subtitle (1) and date, footer and slide-number placeholders (10, 11, 12, RR-11), and
|
|
46
|
+
// Each kind reserves 200 indexes so a 144-region custom grid cannot collide across kinds. They
|
|
47
|
+
// depend on the record only, so they are the same under every slide master.
|
|
48
|
+
export const REGION_IDX_BASE = Object.freeze({obj: 100, body: 300, pic: 500, chart: 700, tbl: 900, media: 1100});
|
|
49
|
+
export const SUBTITLE_IDX = 1;
|
|
50
|
+
const NAME = /^[a-z][a-z0-9-]*$/;
|
|
51
|
+
const MAX_REGIONS = 144;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The PowerPoint layout type of a built-in id (design section 8); every other template is `cust`. `statement` is `cust` too
|
|
55
|
+
* (coordinator decision on RR-81): `titleOnly` would contradict its message placeholder.
|
|
56
|
+
*/
|
|
57
|
+
export const POWERPOINT_LAYOUT_TYPES = Object.freeze({
|
|
58
|
+
cover: 'title', section: 'secHead', text: 'obj', list: 'obj', agenda: 'obj', faq: 'obj',
|
|
59
|
+
'two-column': 'twoObj', comparison: 'twoObj', 'image-beside': 'picTx',
|
|
60
|
+
});
|
|
61
|
+
const CENTERED_TITLE = new Set(['cover', 'section']);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The placeholder type of a body region: `pic` when it accepts only pictures (or is a `media` region whose leaf kinds are
|
|
65
|
+
* pictures and video), `chart` when only charts, `tbl` when only tables, `media` when only video, `body` when only text and
|
|
66
|
+
* lists, otherwise a content placeholder, `obj` (written without a type, as PowerPoint does).
|
|
67
|
+
*/
|
|
68
|
+
export function placeholderType(region) {
|
|
69
|
+
const accepts = new Set(region.accepts);
|
|
70
|
+
const only = (...kinds) => [...accepts].every(kind => kinds.includes(kind));
|
|
71
|
+
if (only('image')) return 'pic';
|
|
72
|
+
if (only('chart')) return 'chart';
|
|
73
|
+
if (only('table')) return 'tbl';
|
|
74
|
+
if (only('video')) return 'media';
|
|
75
|
+
if (region.role === 'media' && accepts.has('image') && only('image', 'video', 'group')) return 'pic';
|
|
76
|
+
if (only('text', 'list')) return 'body';
|
|
77
|
+
return 'obj';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Which slide objects a placeholder of each type takes (binding a `none` region's lone object). */
|
|
81
|
+
const BINDS = {title: ['title'], ctrTitle: ['title'], pic: ['picture'], chart: ['chart'], tbl: ['table'], obj: ['picture', 'chart', 'table']};
|
|
82
|
+
export const bindsObject = (type, object) => (BINDS[type] ?? []).includes(object);
|
|
83
|
+
|
|
84
|
+
/** The placeholder index of each region: its kind's base plus its position among the regions of that kind (REGION_IDX_BASE). */
|
|
85
|
+
export function regionIndexes(record) {
|
|
86
|
+
const regions = new Map(layoutTemplate(record).regions.map(region => [region.name, region]));
|
|
87
|
+
const counts = {};
|
|
88
|
+
return Object.fromEntries(Object.keys(record?.regions ?? {}).filter(name => regions.has(name)).map(name => {
|
|
89
|
+
const type = placeholderType(regions.get(name));
|
|
90
|
+
counts[type] = (counts[type] ?? -1) + 1;
|
|
91
|
+
return [name, REGION_IDX_BASE[type] + counts[type]];
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// FNV-1a (64-bit) of the record's canonical JSON: evidence of which record the layout was made from.
|
|
96
|
+
function canonical(value) {
|
|
97
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
98
|
+
if (value !== null && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
|
|
99
|
+
return JSON.stringify(value);
|
|
100
|
+
}
|
|
101
|
+
export function recordHash(record) {
|
|
102
|
+
let hash = 0xcbf29ce484222325n;
|
|
103
|
+
for (const byte of enc.encode(canonical(record))) hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 0x100000001b3n);
|
|
104
|
+
return hash.toString(16).padStart(16, '0');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The template a slide uses, from core's slide context (`resolveSlideContext`): undefined for automatic composition and 0.18
|
|
109
|
+
* records. `provenance` is core's provenance of the resolved layout reference.
|
|
110
|
+
*/
|
|
111
|
+
export function slideTemplate(core) {
|
|
112
|
+
const record = core?.resolved?.layout, provenance = core?.resolved?.provenance?.layout;
|
|
113
|
+
if (!record || !provenance || !Object.hasOwn(record, 'areas')) return undefined;
|
|
114
|
+
try { layoutTemplate(record); } catch { return undefined; }
|
|
115
|
+
return {record, id: provenance.id, group: provenance.group, ...(provenance.source !== undefined ? {source: provenance.source} : {}), reference: provenance.reference, origin: provenance.origin,
|
|
116
|
+
key: templateKey(provenance)};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const templateKey = ({group, id, source}) => `${group}\u0000${id}\u0000${source ?? ''}`;
|
|
120
|
+
function isTemplate(record) {
|
|
121
|
+
if (record === null || typeof record !== 'object' || !Object.hasOwn(record, 'areas')) return false;
|
|
122
|
+
try { layoutTemplate(record); return true; } catch { return false; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Every template the deck can reach, in the order its layouts are written: the registered catalogs' templates in catalog order
|
|
127
|
+
* (each catalog's ids as it lists them), then the document's embedded templates (`custom` first, then the other groups by name,
|
|
128
|
+
* each by id). `resolve(reference)` is core's resolveReference for the deck's layouts; a record is listed under the first
|
|
129
|
+
* reference that resolves to it, and a record no reference reaches is left out.
|
|
130
|
+
*/
|
|
131
|
+
export function deckTemplates(document, catalogs, resolve) {
|
|
132
|
+
const result = [], seen = new Set();
|
|
133
|
+
const add = (references, matches) => {
|
|
134
|
+
for (const reference of references) {
|
|
135
|
+
let found;
|
|
136
|
+
try { found = resolve(reference); } catch { found = undefined; }
|
|
137
|
+
if (!found || !matches(found) || !isTemplate(found.record)) continue;
|
|
138
|
+
const template = {record: found.record, id: found.id, group: found.group, ...(found.source !== undefined ? {source: found.source} : {}), reference, origin: found.origin};
|
|
139
|
+
template.key = templateKey(template);
|
|
140
|
+
if (!seen.has(template.key)) { seen.add(template.key); result.push(template); }
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const groups = document?.catalogs !== null && typeof document?.catalogs === 'object' ? document.catalogs : {};
|
|
145
|
+
for (const catalog of Array.isArray(catalogs) ? catalogs : []) {
|
|
146
|
+
const named = Object.keys(groups).filter(name => name !== 'custom' && groups[name]?.source === catalog?.source);
|
|
147
|
+
for (const [id, record] of Object.entries(catalog?.layouts ?? {})) {
|
|
148
|
+
if (!NAME.test(id) || !isTemplate(record)) continue;
|
|
149
|
+
add([id, `default:${id}`, ...named.map(name => `${name}:${id}`)], found => found.origin === 'host' && found.id === id && found.source === catalog.source);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const names = Object.keys(groups).filter(name => NAME.test(name)).sort((a, b) => a === 'custom' ? -1 : b === 'custom' ? 1 : a < b ? -1 : a > b ? 1 : 0);
|
|
153
|
+
for (const group of names) for (const id of Object.keys(groups[group]?.layouts ?? {}).filter(id => NAME.test(id)).sort()) {
|
|
154
|
+
add(group === 'custom' ? [id, `custom:${id}`] : [`${group}:${id}`], found => found.origin === 'document' && found.group === group && found.id === id);
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const escapeXml = value => String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
160
|
+
const attribute = (xml, name) => new RegExp(`\\s${name}="([^"]*)"`).exec(xml)?.[1];
|
|
161
|
+
const relsOf = path => path.replace(/([^/]+)$/, '_rels/$1.rels');
|
|
162
|
+
const unescapeAttribute = value => value.replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
163
|
+
|
|
164
|
+
function resolvePart(source, target) {
|
|
165
|
+
if (target.startsWith('/')) return target.slice(1);
|
|
166
|
+
const parts = source.split('/').slice(0, -1);
|
|
167
|
+
for (const piece of target.split('/')) {
|
|
168
|
+
if (piece === '..') parts.pop();
|
|
169
|
+
else if (piece !== '.' && piece) parts.push(piece);
|
|
170
|
+
}
|
|
171
|
+
return parts.join('/');
|
|
172
|
+
}
|
|
173
|
+
function relativeTarget(source, path) {
|
|
174
|
+
const from = source.split('/').slice(0, -1), to = path.split('/');
|
|
175
|
+
let common = 0;
|
|
176
|
+
while (common < from.length && common < to.length - 1 && from[common] === to[common]) common++;
|
|
177
|
+
return [...Array(from.length - common).fill('..'), ...to.slice(common)].join('/');
|
|
178
|
+
}
|
|
179
|
+
const relationships = xml => [...xml.matchAll(/<Relationship\b[^>]*\/>/g)].map(([node]) => ({node, id: attribute(node, 'Id'), type: attribute(node, 'Type') ?? '',
|
|
180
|
+
target: unescapeAttribute(attribute(node, 'Target') ?? ''), external: attribute(node, 'TargetMode') === 'External'}));
|
|
181
|
+
function addRelationship(relsXml, type, target) {
|
|
182
|
+
const taken = new Set(relationships(relsXml).map(rel => rel.id));
|
|
183
|
+
let n = 1, rid;
|
|
184
|
+
do rid = `rId${n++}`; while (taken.has(rid));
|
|
185
|
+
return {rid, relsXml: relsXml.replace('</Relationships>', `<Relationship Id="${rid}" Type="${type}" Target="${escapeXml(target)}"/></Relationships>`)};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The placeholders of one template layout in EMU: [{name, type, idx?, x, y, cx, cy, anchor?}]. Core's composeLayoutAreas
|
|
190
|
+
* composes the empty layout as composeSlide composes a slide with a one-line title and subtitle and every region kept:
|
|
191
|
+
* `template.areaOptions` are composeSlide's options of a slide on the template (canvas, the deck's header and footer, direction)
|
|
192
|
+
* plus its placed-image `placements`, so each box is where that slide's region lands, bled regions reaching the slide edge.
|
|
193
|
+
*/
|
|
194
|
+
export function layoutPlaceholders(template) {
|
|
195
|
+
const {record, id} = template;
|
|
196
|
+
const parsed = layoutTemplate(record);
|
|
197
|
+
const {areas} = composeLayoutAreas(record, {...template.areaOptions, textMeasurement: undefined});
|
|
198
|
+
const regions = new Map(parsed.regions.map(region => [region.name, region]));
|
|
199
|
+
const indexes = regionIndexes(record);
|
|
200
|
+
const emu = box => ({x: Math.round(box.x * EMU), y: Math.round(box.y * EMU), cx: Math.max(0, Math.round(box.width * EMU)), cy: Math.max(0, Math.round(box.height * EMU))});
|
|
201
|
+
const result = [];
|
|
202
|
+
for (const area of areas) {
|
|
203
|
+
if (area.name === 'title') {
|
|
204
|
+
if (CENTERED_TITLE.has(id) && area.parts) {
|
|
205
|
+
// `cover` and `section`: PowerPoint's centred title over its subtitle, at core's title and subtitle sub-boxes.
|
|
206
|
+
result.push({name: 'Title', type: 'ctrTitle', anchor: 'b', ...emu(area.parts.title)});
|
|
207
|
+
result.push({name: 'Subtitle', type: 'subTitle', idx: SUBTITLE_IDX, anchor: 't', ...emu(area.parts.subtitle)});
|
|
208
|
+
} else result.push({name: 'Title', type: CENTERED_TITLE.has(id) ? 'ctrTitle' : 'title', ...emu(area.box)});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (area.name === 'subtitle') { result.push({name: 'Subtitle', type: 'subTitle', idx: SUBTITLE_IDX, anchor: 't', ...emu(area.box)}); continue; }
|
|
212
|
+
const region = regions.get(area.name);
|
|
213
|
+
if (region) result.push({name: region.name, type: placeholderType(region), idx: indexes[region.name], ...emu(area.box)});
|
|
214
|
+
}
|
|
215
|
+
const sample = composeSlide({title: 'Title', subtitle: 'Subtitle', blocks: [{text: 'Body'}]}, {...template.areaOptions, layout: record, textMeasurement: undefined});
|
|
216
|
+
for (const placeholder of result) {
|
|
217
|
+
const field = placeholder.type === 'title' || placeholder.type === 'ctrTitle' ? 'title' : placeholder.type === 'subTitle' ? 'subtitle' : 'text';
|
|
218
|
+
const item = sample.items.find(item => item.field === field);
|
|
219
|
+
if (item?.textStyle && item.text?.fontSize) placeholder.style = {...item.textStyle, fontSize: item.text.fontSize, alignment: item.alignment};
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const PROMPTS = {title: 'Click to edit Master title style', ctrTitle: 'Click to edit Master title style', subTitle: 'Click to edit Master subtitle style', body: 'Click to edit Master text styles', obj: 'Click to edit Master text styles'};
|
|
225
|
+
function placeholderShape(placeholder, id) {
|
|
226
|
+
const style = placeholder.style;
|
|
227
|
+
const align = {left: 'l', center: 'ctr', right: 'r'}[style?.alignment] ?? 'l';
|
|
228
|
+
// Each copy inherits its own master's chosen heading/body fonts (including script-profile variants).
|
|
229
|
+
const fontRole = placeholder.type === 'title' || placeholder.type === 'ctrTitle' ? 'mj' : 'mn';
|
|
230
|
+
const defaults = style ? `<a:defRPr sz="${Math.round(style.fontSize * 75)}"${Number(style.fontWeight) >= 600 ? ' b="1"' : ''}><a:latin typeface="${fontRole === 'mj' ? '+mj-lt' : '+mn-lt'}"/><a:ea typeface="+${fontRole}-ea"/><a:cs typeface="+${fontRole}-cs"/></a:defRPr>` : '';
|
|
231
|
+
const listStyle = defaults ? `<a:lstStyle>${Array.from({length: 9}, (_, level) => `<a:lvl${level + 1}pPr algn="${align}">${defaults}</a:lvl${level + 1}pPr>`).join('')}</a:lstStyle>` : '<a:lstStyle/>';
|
|
232
|
+
const typeAttribute = placeholder.type === 'obj' ? '' : ` type="${placeholder.type}"`;
|
|
233
|
+
const idx = placeholder.idx !== undefined ? ` idx="${placeholder.idx}"` : '';
|
|
234
|
+
const prompt = PROMPTS[placeholder.type];
|
|
235
|
+
const paragraph = prompt ? `<a:p><a:r><a:rPr lang="en-US"/><a:t>${prompt}</a:t></a:r></a:p>` : '<a:p><a:endParaRPr lang="en-US"/></a:p>';
|
|
236
|
+
return `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="${escapeXml(placeholder.name)}"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph${typeAttribute}${idx}/></p:nvPr></p:nvSpPr>` +
|
|
237
|
+
`<p:spPr><a:xfrm><a:off x="${placeholder.x}" y="${placeholder.y}"/><a:ext cx="${placeholder.cx}" cy="${placeholder.cy}"/></a:xfrm></p:spPr>` +
|
|
238
|
+
`<p:txBody><a:bodyPr${placeholder.anchor ? ` anchor="${placeholder.anchor}"` : ''}/>${listStyle}${paragraph}</p:txBody></p:sp>`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The OPF_LAYOUT_V1 value of a template layout. With `record` (full provenance) a template the document embeds carries its
|
|
243
|
+
* record, so a template no slide uses comes back on import (document provenance stores only the records slides reference).
|
|
244
|
+
*/
|
|
245
|
+
export function layoutTagValue(template, {record = false} = {}) {
|
|
246
|
+
return {v: 1, id: template.id, group: template.group, ...(template.source !== undefined ? {source: template.source} : {}), reference: template.reference,
|
|
247
|
+
hash: recordHash(template.record), regions: Object.fromEntries(Object.entries(regionIndexes(template.record)).map(([name, idx]) => [String(idx), name])),
|
|
248
|
+
...(record && template.origin === 'document' ? {record: template.record} : {})};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Write the template layouts of the finished package and bind the placeholder objects. `output` maps a package path to
|
|
253
|
+
* [bytes, zipOptions]; runs after the per-script slide masters and before RR-72 lifts furniture. `slides[i]` is slide i's
|
|
254
|
+
* template plan ({template, areaOptions}) or undefined; `templates` (deckTemplates, each with the `areaOptions` its placeholders
|
|
255
|
+
* are composed with) are the layouts to write under every slide master, in order (a slide's own template is added when missing). `bindings`
|
|
256
|
+
* maps an object name on a slide to {slide, region, object} (`picture`, `chart` or `table`). `partText(path, xml)` applies the
|
|
257
|
+
* deck's language to a generated part.
|
|
258
|
+
*/
|
|
259
|
+
export function writeTemplateLayouts(output, slides, templates, bindings = new Map(), partText = (path, xml) => xml, {records = false, automaticSlides = []} = {}) {
|
|
260
|
+
if (!slides.some(Boolean) && !templates.length && !automaticSlides.some(Boolean)) return;
|
|
261
|
+
const all = [...templates];
|
|
262
|
+
for (const plan of slides) if (plan && !all.some(template => template.key === plan.template.key)) all.push({...plan.template, areaOptions: plan.areaOptions});
|
|
263
|
+
const has = path => Object.hasOwn(output, path);
|
|
264
|
+
const read = path => dec.decode(output[path][0]);
|
|
265
|
+
const options = output['ppt/presentation.xml'][1];
|
|
266
|
+
const write = (path, xml) => { output[path] = [enc.encode(xml), output[path]?.[1] ?? options]; };
|
|
267
|
+
let types = read('[Content_Types].xml');
|
|
268
|
+
const presentation = read('ppt/presentation.xml'), presentationRels = relationships(read('ppt/_rels/presentation.xml.rels'));
|
|
269
|
+
// Slide parts in presentation order.
|
|
270
|
+
const slidePaths = [...presentation.matchAll(/<p:sldId\b[^>]*\br:id="([^"]+)"/g)].map(match => {
|
|
271
|
+
const rel = presentationRels.find(item => item.id === match[1]);
|
|
272
|
+
return rel ? resolvePart('ppt/presentation.xml', rel.target) : undefined;
|
|
273
|
+
});
|
|
274
|
+
const related = (path, type) => {
|
|
275
|
+
if (!has(relsOf(path))) return undefined;
|
|
276
|
+
const rel = relationships(read(relsOf(path))).find(item => !item.external && item.type === `${REL}/${type}`);
|
|
277
|
+
return rel ? resolvePart(path, rel.target) : undefined;
|
|
278
|
+
};
|
|
279
|
+
const used = [...presentation.matchAll(/<p:sldMasterId\b[^>]*\bid="(\d+)"/g), ...Object.keys(output).filter(path => /^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(path))
|
|
280
|
+
.flatMap(path => [...read(path).matchAll(/<p:sldLayoutId\b[^>]*\bid="(\d+)"/g)])].map(match => Number(match[1]));
|
|
281
|
+
let nextLayoutId = Math.max(2147483648, ...used) + 1;
|
|
282
|
+
let nextLayout = Math.max(0, ...Object.keys(output).map(path => Number(/^ppt\/slideLayouts\/slideLayout(\d+)\.xml$/.exec(path)?.[1] ?? 0))) + 1;
|
|
283
|
+
let nextTag = Math.max(0, ...Object.keys(output).map(path => Number(/^ppt\/tags\/opfLayout(\d+)\.xml$/.exec(path)?.[1] ?? 0))) + 1;
|
|
284
|
+
const created = new Map(); // master + template key -> {path, placeholders}
|
|
285
|
+
// Every slide master that holds a slide, in slide order, with the layout its slides use now (the copy each new layout starts from).
|
|
286
|
+
const masters = new Map();
|
|
287
|
+
for (const [index, slidePath] of slidePaths.entries()) {
|
|
288
|
+
if (!slidePath || !has(slidePath)) throw new Error(`Slide ${index + 1} is missing.`);
|
|
289
|
+
const base = related(slidePath, 'slideLayout'), master = base && related(base, 'slideMaster');
|
|
290
|
+
if (!base || !master) throw new Error(`Slide ${index + 1} has no slide layout and master.`);
|
|
291
|
+
if (!masters.has(master)) masters.set(master, base);
|
|
292
|
+
}
|
|
293
|
+
for (const [master, base] of masters) {
|
|
294
|
+
const taken = new Set(Object.keys(output).filter(part => /^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(part) && related(part, 'slideMaster') === master)
|
|
295
|
+
.map(part => unescapeAttribute(read(part).match(/<p:cSld\b[^>]*\bname="([^"]*)"/)?.[1] ?? '')));
|
|
296
|
+
for (const template of all) {
|
|
297
|
+
const key = `${master}\u0001${template.key}`;
|
|
298
|
+
const path = `ppt/slideLayouts/slideLayout${nextLayout++}.xml`, tagPath = `ppt/tags/opfLayout${nextTag++}.xml`;
|
|
299
|
+
let name = typeof template.record.name === 'string' && template.record.name.trim() ? template.record.name : template.id;
|
|
300
|
+
if (taken.has(name)) name = `${name} (${template.group}:${template.id})`;
|
|
301
|
+
taken.add(name);
|
|
302
|
+
const type = POWERPOINT_LAYOUT_TYPES[template.id] ?? 'cust';
|
|
303
|
+
const placeholders = layoutPlaceholders(template);
|
|
304
|
+
let xml = read(base);
|
|
305
|
+
xml = xml.replace(/<p:sldLayout\b[^>]*>/, open => `${open.slice(0, -1).replace(/\s+(?:type|userDrawn|showMasterSp)="[^"]*"/g, '')} type="${type}" userDrawn="1">`);
|
|
306
|
+
xml = xml.replace(/(<p:cSld\b[^>]*?)(\s+name="[^"]*")?(\s*>)/, (match, open, _name, close) => `${open} name="${escapeXml(name)}"${close}`);
|
|
307
|
+
let nextId = Math.max(1, ...[...xml.matchAll(/<p:cNvPr\b[^>]*\bid="(\d+)"/g)].map(match => Number(match[1]))) + 1;
|
|
308
|
+
const shapes = placeholders.map(placeholder => placeholderShape(placeholder, nextId++)).join('');
|
|
309
|
+
const groupEnd = xml.indexOf('</p:grpSpPr>');
|
|
310
|
+
if (groupEnd < 0 || /<p:custDataLst>\s*<p:tags\b[^>]*\/>\s*<\/p:custDataLst>\s*<\/p:cSld>/.test(xml)) throw new Error(`${base} cannot take template placeholders.`);
|
|
311
|
+
xml = xml.slice(0, groupEnd + '</p:grpSpPr>'.length) + shapes + xml.slice(groupEnd + '</p:grpSpPr>'.length);
|
|
312
|
+
// A copied layout keeps only its master relationship; the tag brings its own.
|
|
313
|
+
const masterRel = relationships(has(relsOf(base)) ? read(relsOf(base)) : '').find(rel => rel.type === `${REL}/slideMaster`);
|
|
314
|
+
if (!masterRel) throw new Error(`${base} has no slide master relationship.`);
|
|
315
|
+
const tagged = addRelationship(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${masterRel.node}</Relationships>`,
|
|
316
|
+
`${REL}/tags`, relativeTarget(path, tagPath));
|
|
317
|
+
xml = xml.replace('</p:spTree>', `</p:spTree><p:custDataLst><p:tags r:id="${tagged.rid}"/></p:custDataLst>`);
|
|
318
|
+
write(path, partText(path, xml));
|
|
319
|
+
write(relsOf(path), tagged.relsXml);
|
|
320
|
+
output[tagPath] = [enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${LAYOUT_TAG}" val="${encodeTextTag(layoutTagValue(template, {record: records}))}"/></p:tagLst>`), options];
|
|
321
|
+
types = types.replace('</Types>', `<Override PartName="/${path}" ContentType="${LAYOUT_TYPE}"/><Override PartName="/${tagPath}" ContentType="${TAGS_TYPE}"/></Types>`);
|
|
322
|
+
const masterRels = has(relsOf(master)) ? read(relsOf(master)) : '';
|
|
323
|
+
const added = addRelationship(masterRels, `${REL}/slideLayout`, relativeTarget(master, path));
|
|
324
|
+
write(relsOf(master), added.relsXml);
|
|
325
|
+
const masterXml = read(master);
|
|
326
|
+
if (!masterXml.includes('</p:sldLayoutIdLst>')) throw new Error(`${master} has no layout list.`);
|
|
327
|
+
write(master, masterXml.replace('</p:sldLayoutIdLst>', `<p:sldLayoutId id="${nextLayoutId++}" r:id="${added.rid}"/></p:sldLayoutIdLst>`));
|
|
328
|
+
created.set(key, {path, placeholders: new Map(placeholders.map(item => [item.name, item]))});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// Automatic composition is its own titleOnly layout, independently of catalog region templates.
|
|
332
|
+
for (const [master, base] of masters) {
|
|
333
|
+
const first = automaticSlides.find((plan, index) => plan && related(related(slidePaths[index], 'slideLayout'), 'slideMaster') === master);
|
|
334
|
+
if (!first) continue;
|
|
335
|
+
const path = `ppt/slideLayouts/slideLayout${nextLayout++}.xml`, tagPath = `ppt/tags/opfLayout${nextTag++}.xml`;
|
|
336
|
+
// Reuse accepted title geometry; an empty slide needs only the engine's default prompt, never a re-fit through the caller's measurement provider.
|
|
337
|
+
const title = first.title ?? composeSlide({title: 'Title'}, {...first.areaOptions, layout: undefined, textMeasurement: undefined}).items.find(item => item.field === 'title');
|
|
338
|
+
const placeholder = {name: 'Title', type: 'title', x: Math.round(title.box.x * EMU), y: Math.round(title.box.y * EMU), cx: Math.round(title.box.width * EMU), cy: Math.round(title.box.height * EMU), style: {...title.textStyle, fontSize: title.text.fontSize, alignment: title.alignment}};
|
|
339
|
+
let xml = read(base).replace(/<p:sldLayout\b[^>]*>/, open => `${open.slice(0, -1).replace(/\s+(?:type|userDrawn|showMasterSp)="[^"]*"/g, '')} type="titleOnly" userDrawn="1">`);
|
|
340
|
+
xml = xml.replace(/(<p:cSld\b[^>]*?)(\s+name="[^"]*")?(\s*>)/, (match, open, _name, close) => `${open} name="OPF auto"${close}`);
|
|
341
|
+
const nextId = Math.max(1, ...[...xml.matchAll(/<p:cNvPr\b[^>]*\bid="(\d+)"/g)].map(match => Number(match[1]))) + 1;
|
|
342
|
+
xml = xml.replace('</p:grpSpPr>', `</p:grpSpPr>${placeholderShape(placeholder, nextId)}`);
|
|
343
|
+
const masterRel = relationships(read(relsOf(base))).find(rel => rel.type === `${REL}/slideMaster`);
|
|
344
|
+
const tagged = addRelationship(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${masterRel.node}</Relationships>`, `${REL}/tags`, relativeTarget(path, tagPath));
|
|
345
|
+
xml = xml.replace('</p:spTree>', `</p:spTree><p:custDataLst><p:tags r:id="${tagged.rid}"/></p:custDataLst>`);
|
|
346
|
+
write(path, partText(path, xml)); write(relsOf(path), tagged.relsXml);
|
|
347
|
+
output[tagPath] = [enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${LAYOUT_TAG}" val="${encodeTextTag({v: 1, id: 'auto', group: 'custom', reference: 'auto', hash: recordHash({auto: true}), regions: {}})}"/></p:tagLst>`), options];
|
|
348
|
+
types = types.replace('</Types>', `<Override PartName="/${path}" ContentType="${LAYOUT_TYPE}"/><Override PartName="/${tagPath}" ContentType="${TAGS_TYPE}"/></Types>`);
|
|
349
|
+
const added = addRelationship(read(relsOf(master)), `${REL}/slideLayout`, relativeTarget(master, path));
|
|
350
|
+
write(relsOf(master), added.relsXml);
|
|
351
|
+
write(master, read(master).replace('</p:sldLayoutIdLst>', `<p:sldLayoutId id="${nextLayoutId++}" r:id="${added.rid}"/></p:sldLayoutIdLst>`));
|
|
352
|
+
created.set(`${master}\u0001auto`, {path, placeholders: new Map([['Title', placeholder]])});
|
|
353
|
+
}
|
|
354
|
+
for (const [index, plan] of slidePaths.map((_, index) => slides[index]).entries()) {
|
|
355
|
+
if (!plan && !automaticSlides[index]) continue;
|
|
356
|
+
const slidePath = slidePaths[index];
|
|
357
|
+
const master = related(related(slidePath, 'slideLayout'), 'slideMaster');
|
|
358
|
+
const layout = created.get(`${master}\u0001${plan ? plan.template.key : 'auto'}`);
|
|
359
|
+
if (!layout) throw new Error(`Slide ${index + 1} has no template layout.`);
|
|
360
|
+
const slideRels = relsOf(slidePath);
|
|
361
|
+
write(slideRels, read(slideRels).replace(/<Relationship\b[^>]*\/>/g, node => attribute(node, 'Type') === `${REL}/slideLayout` ? node.replace(/\sTarget="[^"]*"/, ` Target="${relativeTarget(slidePath, layout.path)}"`) : node));
|
|
362
|
+
// The lone picture, chart or table of a `none` region becomes its placeholder's content.
|
|
363
|
+
let slideXml = read(slidePath), changed = false;
|
|
364
|
+
for (const [objectName, binding] of bindings) {
|
|
365
|
+
if (binding.slide !== index) continue;
|
|
366
|
+
const placeholder = layout.placeholders.get(binding.region);
|
|
367
|
+
if (!placeholder || !bindsObject(placeholder.type, binding.object)) continue;
|
|
368
|
+
const ph = `<p:ph${placeholder.type === 'obj' ? '' : ` type="${placeholder.type}"`}${placeholder.idx !== undefined ? ` idx="${placeholder.idx}"` : ''}/>`;
|
|
369
|
+
const element = binding.object === 'picture' ? 'pic' : binding.object === 'title' ? 'sp' : 'graphicFrame';
|
|
370
|
+
const pattern = new RegExp(`<p:${element}>[\\s\\S]*?<\\/p:${element}>`, 'g');
|
|
371
|
+
slideXml = slideXml.replace(pattern, shape => {
|
|
372
|
+
if (unescapeAttribute(shape.match(/<p:cNvPr\b[^>]*\bname="([^"]*)"/)?.[1] ?? '') !== objectName || /<p:ph\b/.test(shape)) return shape;
|
|
373
|
+
changed = true;
|
|
374
|
+
let next = shape.replace(/<p:nvPr\s*\/>/, '<p:nvPr></p:nvPr>').replace(/<p:nvPr>/, `<p:nvPr>${ph}`);
|
|
375
|
+
if (element === 'pic') {
|
|
376
|
+
next = /<a:picLocks\b[^>]*\/>/.test(next) ? next.replace(/<a:picLocks\b([^>]*?)\s*\/>/, (match, rest) => /noGrp=/.test(rest) ? match : `<a:picLocks noGrp="1"${rest}/>`)
|
|
377
|
+
: next.replace(/<p:cNvPicPr\s*\/>/, '<p:cNvPicPr><a:picLocks noGrp="1"/></p:cNvPicPr>').replace(/<p:cNvPicPr>(?!<a:picLocks)/, '<p:cNvPicPr><a:picLocks noGrp="1"/>');
|
|
378
|
+
} else if (element === 'sp') {
|
|
379
|
+
next = next.replace(/<p:cNvSpPr\b([^>]*)\/>/, '<p:cNvSpPr$1><a:spLocks noGrp="1"/></p:cNvSpPr>');
|
|
380
|
+
} else if (element === 'graphicFrame') {
|
|
381
|
+
next = /<a:graphicFrameLocks\b[^>]*\/>/.test(next) ? next.replace(/<a:graphicFrameLocks\b([^>]*?)\s*\/>/, (match, rest) => /noGrp=/.test(rest) ? match : `<a:graphicFrameLocks noGrp="1"${rest}/>`)
|
|
382
|
+
: next.replace(/<p:cNvGraphicFramePr\s*\/>/, '<p:cNvGraphicFramePr><a:graphicFrameLocks noGrp="1"/></p:cNvGraphicFramePr>');
|
|
383
|
+
}
|
|
384
|
+
return next;
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
if (changed) write(slidePath, slideXml);
|
|
388
|
+
}
|
|
389
|
+
write('[Content_Types].xml', types);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// Import
|
|
394
|
+
|
|
395
|
+
function tagIdentityError(tag) {
|
|
396
|
+
if (!NAME.test(tag?.id ?? '') || !NAME.test(tag?.group ?? '')) return 'invalid id or group';
|
|
397
|
+
const reference = /^(?:([a-z][a-z0-9-]*):)?([a-z][a-z0-9-]*)$/.exec(tag.reference ?? '');
|
|
398
|
+
if (!reference || reference[2] !== tag.id || (reference[1] !== undefined && reference[1] !== tag.group)) return 'reference does not match id and group';
|
|
399
|
+
if (reference[1] === undefined && !['custom', 'default'].includes(tag.group)) return 'bare reference cannot name an external catalog';
|
|
400
|
+
if (tag.source !== undefined && (typeof tag.source !== 'string' || !/^(?:https:\/\/[^\s]+|pkg:[^\s]+)$/.test(tag.source))) return 'invalid catalog source';
|
|
401
|
+
if (tag.group === 'custom' && tag.source !== undefined) return 'custom catalog cannot have a source';
|
|
402
|
+
if (tag.group !== 'custom' && tag.source === undefined) return 'catalog source is missing';
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The validated OPF_LAYOUT_V1 value of a slide layout part, or undefined. `report(message)` names a damaged tag. */
|
|
407
|
+
export function readLayoutTag(entries, layoutPath, report = () => {}) {
|
|
408
|
+
if (!layoutPath || !entries[layoutPath]) return undefined;
|
|
409
|
+
const xml = dec.decode(entries[layoutPath]);
|
|
410
|
+
const rid = xml.match(/<\/p:spTree>\s*<p:custDataLst>([\s\S]*?)<\/p:custDataLst>/)?.[1]?.match(/<p:tags\b[^>]*\br:id="([^"]+)"/)?.[1];
|
|
411
|
+
if (!rid || !entries[relsOf(layoutPath)]) return undefined;
|
|
412
|
+
const rel = relationships(dec.decode(entries[relsOf(layoutPath)])).find(item => item.id === rid && item.type === `${REL}/tags` && !item.external);
|
|
413
|
+
const tagPath = rel && resolvePart(layoutPath, rel.target);
|
|
414
|
+
if (!tagPath || !entries[tagPath]) return undefined;
|
|
415
|
+
const tag = [...dec.decode(entries[tagPath]).matchAll(/<p:tag\b[^>]*\/>/g)].map(([node]) => node).find(node => attribute(node, 'name') === LAYOUT_TAG);
|
|
416
|
+
if (!tag) return undefined;
|
|
417
|
+
try {
|
|
418
|
+
const value = decodeTextTag(unescapeAttribute(attribute(tag, 'val') ?? ''));
|
|
419
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value) || value.v !== 1) throw Error('unknown version');
|
|
420
|
+
const identityError = tagIdentityError(value);
|
|
421
|
+
if (identityError) throw Error(identityError);
|
|
422
|
+
if (typeof value.reference !== 'string' || !/^(?:[a-z][a-z0-9-]*:)?[a-z][a-z0-9-]*$/.test(value.reference) || typeof value.hash !== 'string') throw Error('invalid reference');
|
|
423
|
+
if (value.record !== undefined && (value.record === null || typeof value.record !== 'object' || Array.isArray(value.record))) throw Error('invalid record');
|
|
424
|
+
const regions = value.regions;
|
|
425
|
+
if (regions === null || typeof regions !== 'object' || Array.isArray(regions) || Object.keys(regions).length > MAX_REGIONS
|
|
426
|
+
|| Object.entries(regions).some(([idx, name]) => !/^\d{1,4}$/.test(idx) || typeof name !== 'string' || !NAME.test(name) || name.length > 64)) throw Error('invalid regions');
|
|
427
|
+
return value;
|
|
428
|
+
} catch (error) {
|
|
429
|
+
report(`The OPF layout tag of ${layoutPath} is damaged (${error.message}); the slide layout is read as a foreign layout.`);
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The region of a native slide object from its placeholder index, through the layout tag. */
|
|
435
|
+
export function placeholderRegion(node, tag) {
|
|
436
|
+
if (!tag) return undefined;
|
|
437
|
+
const nv = node?.['p:nvSpPr'] ?? node?.['p:nvPicPr'] ?? node?.['p:nvGraphicFramePr'];
|
|
438
|
+
const idx = nv?.['p:nvPr']?.['p:ph']?.idx;
|
|
439
|
+
return idx === undefined ? undefined : tag.regions[String(idx)];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const bareId = reference => /^(?:([a-z][a-z0-9-]*):)?([a-z][a-z0-9-]*)$/.exec(reference ?? '');
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* After document provenance: each slide's layout from its tagged slide layout, and the region pins of blocks read from region
|
|
446
|
+
* placeholders. `slides[i]` = {tag, blockRegions, restored}; `recordOf(index)` resolves the slide's layout record.
|
|
447
|
+
*/
|
|
448
|
+
export function applyTemplateLayouts(imported, slides, recordOf, report = () => {}, layoutTags = []) {
|
|
449
|
+
const usable = tag => {
|
|
450
|
+
const error = tagIdentityError(tag);
|
|
451
|
+
const existing = imported.catalogs?.[tag?.group];
|
|
452
|
+
const conflict = existing === false || (existing?.source !== undefined && existing.source !== tag.source);
|
|
453
|
+
if (!error && !conflict) return true;
|
|
454
|
+
report({code: 'invalid-layout-provenance', path: `catalogs.${tag?.group}`, message: `The PowerPoint layout tag was not restored (${error ?? 'catalog source conflicts with document provenance'}).`});
|
|
455
|
+
return false;
|
|
456
|
+
};
|
|
457
|
+
// Restore unused embedded templates only with consistent, schema-valid catalog provenance.
|
|
458
|
+
for (const tag of layoutTags) {
|
|
459
|
+
if (!tag?.record || !usable(tag) || recordHash(tag.record) !== tag.hash) continue;
|
|
460
|
+
if (imported.catalogs?.[tag.group]?.layouts?.[tag.id] !== undefined) continue;
|
|
461
|
+
if (!isTemplate(tag.record)) continue;
|
|
462
|
+
let valid = false;
|
|
463
|
+
try { valid = validateCatalogRecord('layouts', {$schema: 'https://openpresentation.org/schema/opf-layout/v2', id: tag.id, ...tag.record}).valid; } catch { valid = false; }
|
|
464
|
+
if (!valid) { report({code: 'invalid-layout-provenance', path: `catalogs.${tag.group}.layouts.${tag.id}`, message: `The layout record stored with the PowerPoint layout of ${tag.reference} does not validate; it was not restored.`}); continue; }
|
|
465
|
+
imported.catalogs = {...imported.catalogs, [tag.group]: {...(tag.source ? {source: tag.source} : {}), ...imported.catalogs?.[tag.group], layouts: {...imported.catalogs?.[tag.group]?.layouts, [tag.id]: structuredClone(tag.record)}}};
|
|
466
|
+
}
|
|
467
|
+
for (const [index, entry] of slides.entries()) {
|
|
468
|
+
const slide = imported.slides[index], tag = entry?.tag;
|
|
469
|
+
if (!slide || !tag || !usable(tag)) continue;
|
|
470
|
+
const qualified = bareId(tag.reference)?.[1];
|
|
471
|
+
if (qualified && tag.source && (!imported.catalogs?.[qualified] || typeof imported.catalogs[qualified] !== 'object'))
|
|
472
|
+
imported.catalogs = {...imported.catalogs, [qualified]: {source: tag.source}};
|
|
473
|
+
if (tag.id === 'auto' && entry.preservedAutomatic && (entry.storedLayout === undefined || entry.storedLayout === 'auto')) { if (entry.storedLayout === 'auto') slide.layout = 'auto'; continue; }
|
|
474
|
+
if (slide.layout === undefined) slide.layout = tag.reference;
|
|
475
|
+
else if (slide.layout !== tag.reference) {
|
|
476
|
+
const match = bareId(slide.layout);
|
|
477
|
+
const same = match && match[2] === tag.id && (match[1] === undefined || match[1] === tag.group);
|
|
478
|
+
if (!same) {
|
|
479
|
+
report({code: 'layout-changed', path: `slides.${index}.layout`, message: `The slide uses the PowerPoint layout of ${tag.reference} (Change Layout), not its stored layout ${slide.layout}; slides.${index}.layout is now ${tag.reference} and its content is bound by that layout.`});
|
|
480
|
+
slide.layout = tag.reference;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (entry.restored || !Array.isArray(slide.blocks) || !entry.blockRegions?.some(Boolean)) continue;
|
|
484
|
+
let record;
|
|
485
|
+
try { record = recordOf(index); layoutTemplate(record); } catch { continue; }
|
|
486
|
+
const regions = new Map(layoutTemplate(record).regions.map(region => [region.name, region]));
|
|
487
|
+
for (const [position, name] of entry.blockRegions.entries()) {
|
|
488
|
+
const block = slide.blocks[position], region = regions.get(name);
|
|
489
|
+
if (!name || !region || !block || typeof block !== 'object' || block.region !== undefined || !regionAccepts(region, block)) continue;
|
|
490
|
+
const bound = bindRegions(slide, record, {slideIndex: index}).regions.find(item => [...item.blocks, ...item.overflow].some(node => node.block === position));
|
|
491
|
+
if (bound?.name !== name) block.region = name;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Map foreign PowerPoint layouts to available v2 built-ins; no layout reconstruction or migration aliases. */
|
|
497
|
+
export function foreignLayoutReference(layout, catalogs = [gallery]) {
|
|
498
|
+
const types = {title: 'cover', secHead: 'section', obj: 'text', tx: 'text', twoObj: 'two-column', twoTxTwoObj: 'two-column', picTx: 'image-beside'};
|
|
499
|
+
const type = layout?.type;
|
|
500
|
+
if (type === 'blank' || type === 'titleOnly') return 'auto';
|
|
501
|
+
const options = catalogs === undefined ? {} : {catalogs};
|
|
502
|
+
// Use core's default catalog resolution: a same-id record in another host catalog must never supply a misleading name match.
|
|
503
|
+
const builtins = catalogRecords({}, 'layouts', options).filter(entry => entry.group === 'default' && isTemplate(entry.record))
|
|
504
|
+
.filter(entry => Object.hasOwn(POWERPOINT_LAYOUT_TYPES, entry.id) || ['image', 'gallery', 'chart', 'chart-beside', 'table', 'table-beside', 'code', 'pillars', 'process', 'timeline', 'quote', 'metrics', 'scorecard', 'hero', 'dashboard', 'team', 'logos', 'statement', 'closing'].includes(entry.id))
|
|
505
|
+
.filter(entry => resolveReference({}, 'layouts', `default:${entry.id}`, options)?.source === entry.source);
|
|
506
|
+
const id = types[type];
|
|
507
|
+
if (id) return builtins.some(entry => entry.id === id) ? `default:${id}` : undefined;
|
|
508
|
+
if (type === 'cust') {
|
|
509
|
+
const name = String(layout?.['p:cSld']?.name ?? '').trim().toLowerCase();
|
|
510
|
+
const matched = builtins.find(entry => entry.id.toLowerCase() === name || String(entry.record.name ?? '').trim().toLowerCase() === name);
|
|
511
|
+
return matched ? `default:${matched.id}` : 'auto';
|
|
512
|
+
}
|
|
513
|
+
return 'auto';
|
|
514
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-pptx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -64,8 +64,9 @@
|
|
|
64
64
|
"test:determinism": "node test/determinism.mjs"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
+
"@openpresentation/gallery": "^3.0.0",
|
|
67
68
|
"@node-projects/jszip": "4.3.0",
|
|
68
|
-
"@openpresentation/opf": "^0.
|
|
69
|
+
"@openpresentation/opf": "^0.20.1",
|
|
69
70
|
"@rgrove/parse-xml": "4.2.3",
|
|
70
71
|
"fast-xml-parser": "^5.8.0",
|
|
71
72
|
"fflate": "^0.8.3",
|
|
@@ -73,7 +74,7 @@
|
|
|
73
74
|
"sharp": "0.35.5"
|
|
74
75
|
},
|
|
75
76
|
"peerDependencies": {
|
|
76
|
-
"@openpresentation/opf-render": "^0.
|
|
77
|
+
"@openpresentation/opf-render": "^0.20.0"
|
|
77
78
|
},
|
|
78
79
|
"peerDependenciesMeta": {
|
|
79
80
|
"@openpresentation/opf-render": {
|
|
@@ -97,7 +98,7 @@
|
|
|
97
98
|
"@expo-google-fonts/roboto": "0.4.3",
|
|
98
99
|
"@expo-google-fonts/roboto-mono": "0.4.2",
|
|
99
100
|
"@expo-google-fonts/tinos": "0.4.2",
|
|
100
|
-
"@openpresentation/opf-render": "0.
|
|
101
|
+
"@openpresentation/opf-render": "0.20.0",
|
|
101
102
|
"@resvg/resvg-js": "2.6.2",
|
|
102
103
|
"esbuild": "0.28.2",
|
|
103
104
|
"playwright": "1.63.0"
|