@openpresentation/opf-pptx 0.11.8 → 0.12.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.
@@ -1,4 +1,5 @@
1
1
  import {XMLParser} from 'fast-xml-parser';
2
+ import {normalizeCrop, withoutSvgBlip} from './svg-image.js';
2
3
  import {decodeTextTag, encodeTextTag} from './code-provenance.js';
3
4
  import {pictureTransform} from './image-geometry.js';
4
5
 
@@ -25,8 +26,8 @@ export const LOGO_TAG = TAG;
25
26
  export const logoName = () => 'OPF logo';
26
27
 
27
28
  function blipFillIdentity(blipFill) {
28
- const {['a:blip']: blip, ...rest} = blipFill ?? {};
29
- const {['r:embed']: _embed, ...blipRest} = blip ?? {};
29
+ const {['a:blip']: blip, ...rest} = normalizeCrop(blipFill) ?? {};
30
+ const {['r:embed']: _embed, ...blipRest} = withoutSvgBlip(blip) ?? {};
30
31
  return {...rest, 'a:blip': blipRest};
31
32
  }
32
33
 
@@ -35,10 +36,11 @@ function blipFillIdentity(blipFill) {
35
36
  * left edge and vertically centered. A 90 degree EXIF orientation swaps the
36
37
  * frame's axes, so its visual width is the stored height.
37
38
  */
38
- export function logoFrame(metadata, box) {
39
+ export function logoFrame(metadata, box, anchor = 'left') {
39
40
  const placed = pictureTransform(metadata, box, 'fit');
40
41
  const visualWidth = (metadata.orientation ?? 1) >= 5 ? placed.h : placed.w;
41
- return {...placed, x: placed.x - (box.w - visualWidth) / 2};
42
+ // Right to left (RR-05): a cover logo sits at the top right, anchored to the box's right edge.
43
+ return {...placed, x: placed.x + (anchor === 'right' ? 1 : -1) * (box.w - visualWidth) / 2};
42
44
  }
43
45
 
44
46
  /**
@@ -60,7 +62,7 @@ export function placeLogos(entries, logos, metadataFor, fail) {
60
62
  const embed = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
61
63
  const metadata = metadataFor(part, embed);
62
64
  if (!metadata) fail(logo.path);
63
- const placed = logoFrame(metadata, logo.box);
65
+ const placed = logoFrame(metadata, logo.box, logo.anchor);
64
66
  const attributes = `${placed.rotation ? ` rot="${placed.rotation * 60000}"` : ''}${placed.flipH ? ' flipH="1"' : ''}${placed.flipV ? ' flipV="1"' : ''}`;
65
67
  picture = picture.replace(/<a:xfrm\b[^>]*>[\s\S]*?<\/a:xfrm>/, `<a:xfrm${attributes}><a:off x="${emu(placed.x)}" y="${emu(placed.y)}"/><a:ext cx="${emu(placed.w)}" cy="${emu(placed.h)}"/></a:xfrm>`);
66
68
  picture = picture.replace(/<a:srcRect\b[^>]*\/>/, '');
@@ -162,7 +164,7 @@ export function importLogo(pictures, relationships, entries, slideIndex, readPic
162
164
  const manifest = decodeTextTag(own[0].val);
163
165
  if (manifest?.v !== 1 || manifest.slide !== `slides.${slideIndex}` || typeof manifest.path !== 'string' || !LOGO_PATH.test(manifest.path) || typeof manifest.variant !== 'string' || manifest.variant.length > 32) throw Error('Invalid logo manifest.');
164
166
  if (picture['p:nvPicPr']?.['p:cNvPr']?.name !== logoName()) throw Error('Logo identity changed.');
165
- if (canonical(picture['p:spPr']) !== canonical(manifest.properties) || canonical(blipFillIdentity(picture['p:blipFill'])) !== canonical(manifest.blipFill)) throw Error('Logo geometry changed.');
167
+ if (canonical(picture['p:spPr']) !== canonical(manifest.properties) || canonical(blipFillIdentity(picture['p:blipFill'])) !== canonical(normalizeCrop(manifest.blipFill))) throw Error('Logo geometry changed.');
166
168
  const image = readPicture(picture)?.payload?.image;
167
169
  consumed.add(index);
168
170
  return {consumed, ...(typeof image?.src === 'string' ? {fallback: {path: manifest.path, image: {src: image.src, ...(typeof image.alt === 'string' ? {alt: image.alt} : {})}}} : {})};
@@ -14,7 +14,7 @@ export function metricManifest(value,layout,group) {
14
14
  return {v:1,group,role:'anchor',part:0,line:0,value,parts:layout.parts.map(part=>({role:part.role,visible:part.visible,
15
15
  lines:part.fit?.sourceLines.map(({start,end,nextStart,boundary})=>({start,end,nextStart,boundary}))??[]}))};
16
16
  }
17
- export function attachMetricTags(entries,records) {return attachTextTags(entries,records,TAG,'opfMetric','metric');}
17
+ export function attachMetricTags(entries,records,descriptions) {return attachTextTags(entries,records,TAG,'opfMetric','metric',{descriptions});}
18
18
 
19
19
  function validateManifest(manifest) {
20
20
  if (!validatePresentation({slides:[{metric:manifest.value}]}).valid) throw new Error('Invalid metric source.');
@@ -59,10 +59,13 @@ export function importMetricGroups(shapes,paragraphs,relationships,entries,repor
59
59
  const anchors=group.filter(item=>item.data.role==='anchor');
60
60
  if (anchors.length!==1) throw new Error('Missing or duplicated metric anchor.');
61
61
  const anchor=anchors[0],manifest=anchor.data;validateManifest(manifest);
62
+ // RR-07: the trend arrow (role 'mark') is generated decoration: at most one, consumed with the group, never source text.
63
+ const marks=group.filter(item=>item.data.role==='mark'),members=group.filter(item=>item.data.role!=='mark');
64
+ if (marks.length>1) throw new Error('Duplicated metric trend mark.');
62
65
  const expected=manifest.parts.reduce((sum,part)=>sum+part.lines.length,0);
63
- if (group.length!==expected||new Set(group.map(item=>item.shape)).size!==expected) throw new Error('Incomplete or duplicated metric shapes.');
66
+ if (members.length!==expected||new Set(members.map(item=>item.shape)).size!==expected) throw new Error('Incomplete or duplicated metric shapes.');
64
67
  const nativeLines=new Map();
65
- for (const item of group) {
68
+ for (const item of members) {
66
69
  const {role,part,line}=item.data,key=`${part}:${line}`;
67
70
  if ((item!==anchor&&role!=='line')||!Number.isSafeInteger(part)||!Number.isSafeInteger(line)||!manifest.parts[part]?.lines[line]||nativeLines.has(key)) throw new Error('Ambiguous metric source line.');
68
71
  nativeLines.set(key,item.text);
@@ -0,0 +1,297 @@
1
+ // Native PowerPoint Header & Footer objects (RR-11, docs/native-header-footer.md).
2
+ //
3
+ // PowerPoint's Insert > Header & Footer owns three slide placeholders: a date
4
+ // (`dt`), a footer text (`ftr`) and a slide number (`sldNum`). OPF footer parts
5
+ // that map onto one of those become real placeholders on the slide, with matching
6
+ // placeholders on the slide master and layouts and `p:hf` flags that say which of
7
+ // them the deck uses, so the dialog shows the right state and "Apply to All"
8
+ // works. Everything PowerPoint has no object for (header zones, organization,
9
+ // section, socials, images, a second text/date/number part, multi-line text)
10
+ // stays an ordinary tagged OPF furniture shape.
11
+ //
12
+ // Geometry never moves: a native slide placeholder carries the explicit `a:xfrm`
13
+ // core composed (exactly the box the tagged shape had), so preview, export and the
14
+ // parity harness agree. The master and layout placeholders are the defaults for
15
+ // footers a user adds natively; they sit where the first slide's native part sits,
16
+ // or where core would draw a default footer when no slide has one.
17
+
18
+ import {XMLParser} from 'fast-xml-parser';
19
+ import {DEFAULT_DATE_FORMAT, NATIVE_DATE_FIELDS, formatDate} from './furniture-fields.js';
20
+
21
+ const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal: true});
22
+ const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, trimValues: false});
23
+ const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
24
+ const EMU = 9525;
25
+
26
+ // idx values are PowerPoint's own: layouts number the three footer placeholders
27
+ // 10, 11, 12 and the master 2, 3, 4; a slide placeholder points at its layout by idx.
28
+ export const NATIVE_PLACEHOLDERS = Object.freeze({
29
+ dt: {field: 'date', name: 'Date Placeholder', sz: 'half', idx: 10, masterIdx: 2, zone: 'left', align: 'l'},
30
+ ftr: {field: 'text', name: 'Footer Placeholder', sz: 'quarter', idx: 11, masterIdx: 3, zone: 'center', align: 'ctr'},
31
+ sldNum: {field: 'slideNumber', name: 'Slide Number Placeholder', sz: 'quarter', idx: 12, masterIdx: 4, zone: 'right', align: 'r'},
32
+ });
33
+ const ORDER = ['dt', 'ftr', 'sldNum'];
34
+ const phByField = {date: 'dt', text: 'ftr', slideNumber: 'sldNum'};
35
+ export const isNativePlaceholderType = value => Object.hasOwn(NATIVE_PLACEHOLDERS, value);
36
+ export const nativePlaceholderForField = field => phByField[field];
37
+ const ZONES = ['left', 'center', 'right'];
38
+ const ALIGN = {left: 'l', center: 'ctr', right: 'r'};
39
+
40
+ /**
41
+ * Which accepted core parts of one slide become native placeholders: the first
42
+ * footer text, the first footer date and the first footer slide number whose
43
+ * text fits one accepted line. Returns Map<partIndex, 'dt'|'ftr'|'sldNum'>.
44
+ */
45
+ export function nativeFurnitureParts(layout) {
46
+ const chosen = new Map(), taken = new Set();
47
+ for (const [index, part] of (layout?.parts ?? []).entries()) {
48
+ const ph = phByField[part.field];
49
+ if (part.kind !== 'footer' || part.type !== 'text' || !ph || taken.has(ph)) continue;
50
+ // One native shape holds one line; a part that wraps or breaks stays tagged shapes.
51
+ if (part.fit?.sourceLines?.length !== 1 || part.fit.lines?.length !== 1 || !part.text || part.links?.length) continue;
52
+ taken.add(ph);
53
+ chosen.set(index, ph);
54
+ }
55
+ return chosen;
56
+ }
57
+
58
+ /** Geometry of a default footer part for the master and layouts: its box and alignment in EMU. */
59
+ export function defaultPlaceholderGeometry(part) {
60
+ if (!part?.box) return undefined;
61
+ return {x: Math.round(part.box.x * EMU), y: Math.round(part.box.y * EMU), cx: Math.round(part.box.width * EMU),
62
+ cy: Math.round((part.fit?.lineHeight ?? part.box.height) * EMU), algn: ALIGN[part.alignment] ?? 'l', sz: Math.round((part.fit?.fontSize ?? 16) * .75 * 100)};
63
+ }
64
+
65
+ const xmlEscape = value => String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
66
+ const guid = (kind, n) => `{0F0F2701-${kind}-4000-8000-${n.toString(16).toUpperCase().padStart(12, '0')}}`;
67
+
68
+ /**
69
+ * Turn the recorded generated shapes of each slide into placeholders. `records`
70
+ * maps a generated object name to its placeholder type. Runs after the tags are
71
+ * attached: the shape keeps its name, geometry, runs, fields and tags, and gains
72
+ * `p:ph` (a placeholder is a text shape the layout can supply defaults for).
73
+ * Returns the placeholders in use and the first geometry/alignment seen per type.
74
+ */
75
+ export function attachNativePlaceholders(entries, records) {
76
+ const used = new Set(), first = new Map(), seen = new Set();
77
+ if (!records.size) return {used, first};
78
+ 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]));
79
+ for (const path of slides) {
80
+ let changed = false;
81
+ const xml = dec.decode(entries[path]).replace(/<p:sp>[\s\S]*?<\/p:sp>/g, shape => {
82
+ const name = shape.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
83
+ const ph = records.get(name);
84
+ if (!ph) return shape;
85
+ if (seen.has(name)) throw new Error('Duplicate generated native furniture shape.');
86
+ seen.add(name);
87
+ const properties = NATIVE_PLACEHOLDERS[ph];
88
+ if (!/<p:cNvSpPr\b[^>]*\/>/.test(shape)) throw new Error('Generated furniture shape has no plain shape properties.');
89
+ shape = shape.replace(/<p:cNvSpPr\b([^>]*)\/>/, '<p:cNvSpPr$1><a:spLocks noGrp="1"/></p:cNvSpPr>').replace(/<p:nvPr\s*\/>/, '<p:nvPr></p:nvPr>');
90
+ if (!shape.includes('<p:nvPr>')) throw new Error('Generated furniture shape has no native application properties.');
91
+ shape = shape.replace('<p:nvPr>', `<p:nvPr><p:ph type="${ph}" sz="${properties.sz}" idx="${properties.idx}"/>`);
92
+ used.add(ph);
93
+ if (!first.has(ph)) {
94
+ const geometry = shape.match(/<a:off x="(-?\d+)" y="(-?\d+)"\/><a:ext cx="(\d+)" cy="(\d+)"\/>/);
95
+ const algn = shape.match(/<a:pPr\b[^>]*\balgn="(\w+)"/)?.[1] ?? 'l';
96
+ if (geometry) first.set(ph, {x: Number(geometry[1]), y: Number(geometry[2]), cx: Number(geometry[3]), cy: Number(geometry[4]), algn});
97
+ }
98
+ changed = true;
99
+ return shape;
100
+ });
101
+ if (changed) entries[path] = enc.encode(xml);
102
+ }
103
+ if (seen.size !== records.size) throw new Error('Missing generated native furniture shapes.');
104
+ return {used, first};
105
+ }
106
+
107
+ // The text style a footer placeholder defaults to: the run properties of the deck's
108
+ // first native footer shape (else its first furniture shape), read from the finished
109
+ // slide (colors already theme references), so a footer added natively matches the
110
+ // exported ones. A hyperlink belongs to one slide's relationships and is dropped.
111
+ function furnitureRunStyle(slideXmls, names) {
112
+ for (const native of [true, false]) for (const xml of slideXmls) for (const [shape] of xml.matchAll(/<p:sp>[\s\S]*?<\/p:sp>/g)) {
113
+ const name = shape.match(/<p:cNvPr\b[^>]*\bname="(OPF furniture \d+ part \d+ line \d+)"/)?.[1];
114
+ if (!name || names.has(name) !== native) continue;
115
+ const run = shape.match(/<a:(?:r|fld)\b[^>]*>(<a:rPr\b[^>]*\/>|<a:rPr\b[^>]*>[\s\S]*?<\/a:rPr>)/)?.[1];
116
+ if (!run) continue;
117
+ const sz = run.match(/\bsz="(\d+)"/)?.[1];
118
+ const inner = (run.match(/^<a:rPr\b[^>]*>([\s\S]*)<\/a:rPr>$/)?.[1] ?? '')
119
+ .replace(/<a:hlink(Click|MouseOver)\b[^>]*\/>|<a:hlink(Click|MouseOver)\b[^>]*>[\s\S]*?<\/a:hlink(?:Click|MouseOver)>/g, '');
120
+ return {sz: sz ? Number(sz) : undefined, inner};
121
+ }
122
+ return undefined;
123
+ }
124
+
125
+ function placeholderGeometry(ph, info) {
126
+ const native = info.first.get(ph);
127
+ if (native) return native;
128
+ const fallback = info.defaults?.get(ph);
129
+ if (fallback) return fallback;
130
+ // No core geometry at all: PowerPoint's own proportions for a footer band.
131
+ const {width, height} = info.slideSize;
132
+ const band = Math.round(height * .0534), zone = Math.round(width * .26), left = Math.round(width * .07);
133
+ const at = {dt: left, ftr: Math.round(width * .37), sldNum: Math.round(width * .67)}[ph];
134
+ return {x: at, y: Math.round(height * .9), cx: zone, cy: band, algn: NATIVE_PLACEHOLDERS[ph].align};
135
+ }
136
+
137
+ function defRunProperties(style, geometry, extra) {
138
+ const sz = style?.sz ?? geometry.sz ?? 1200;
139
+ return `<a:defRPr sz="${sz}"${extra}>${style?.inner ?? ''}</a:defRPr>`;
140
+ }
141
+
142
+ function fieldRun(ph, id, text) {
143
+ if (ph === 'ftr') return '<a:endParaRPr lang="en-US"/>';
144
+ return `<a:fld id="${id}" type="${ph === 'dt' ? 'datetimeFigureOut' : 'slidenum'}"><a:rPr lang="en-US"/><a:t>${xmlEscape(text)}</a:t></a:fld><a:endParaRPr lang="en-US"/>`;
145
+ }
146
+
147
+ function masterShape(ph, order, info, style) {
148
+ const properties = NATIVE_PLACEHOLDERS[ph], geometry = placeholderGeometry(ph, info), id = order + 2;
149
+ return `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="${properties.name} ${id - 1}"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>` +
150
+ `<p:nvPr><p:ph type="${ph}" sz="${properties.sz}" idx="${properties.masterIdx}"/></p:nvPr></p:nvSpPr>` +
151
+ `<p:spPr><a:xfrm><a:off x="${geometry.x}" y="${geometry.y}"/><a:ext cx="${geometry.cx}" cy="${geometry.cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>` +
152
+ `<p:txBody><a:bodyPr vert="horz" lIns="0" tIns="0" rIns="0" bIns="0" rtlCol="0" anchor="t"/>` +
153
+ `<a:lstStyle><a:lvl1pPr algn="${geometry.algn}">${defRunProperties(style, geometry, '')}</a:lvl1pPr></a:lstStyle>` +
154
+ `<a:p>${fieldRun(ph, guid('0001', order + 1), ph === 'dt' ? info.dateText : '‹#›')}</a:p></p:txBody></p:sp>`;
155
+ }
156
+
157
+ function layoutShape(ph, order, info) {
158
+ const properties = NATIVE_PLACEHOLDERS[ph], id = order + 2;
159
+ return `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="${properties.name} ${id - 1}"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>` +
160
+ `<p:nvPr><p:ph type="${ph}" sz="${properties.sz}" idx="${properties.idx}"/></p:nvPr></p:nvSpPr><p:spPr/>` +
161
+ `<p:txBody><a:bodyPr/><a:lstStyle/><a:p>${fieldRun(ph, guid('0002', order + 1), ph === 'dt' ? info.dateText : '‹#›')}</a:p></p:txBody></p:sp>`;
162
+ }
163
+
164
+ const flag = value => value ? '1' : '0';
165
+ const headerFooterFlags = used => `<p:hf sldNum="${flag(used.has('sldNum'))}" hdr="0" ftr="${flag(used.has('ftr'))}" dt="${flag(used.has('dt'))}"/>`;
166
+
167
+ function insertPlaceholders(xml, shapes, part) {
168
+ if (/<p:ph\b[^>]*\btype="(?:dt|ftr|sldNum)"/.test(xml)) throw new Error(`${part} already has footer placeholders.`);
169
+ const end = xml.indexOf('</p:spTree>');
170
+ if (end < 0) throw new Error(`${part} has no shape tree.`);
171
+ return xml.slice(0, end) + shapes + xml.slice(end);
172
+ }
173
+
174
+ /**
175
+ * The master, layout and notes master half of the native header/footer, written
176
+ * into the finished package. `read`/`write` access parts by path.
177
+ *
178
+ * - Slide master and layouts: `dt`, `ftr` and `sldNum` placeholders and a
179
+ * `p:hf` whose flags say which the deck uses (`hdr` is 0: slides have no
180
+ * header placeholder).
181
+ * - Notes master: PowerPoint's own placeholders stay; `p:hf` records that the
182
+ * notes pages carry a page number only, as the generated notes slides do.
183
+ */
184
+ export function writeNativeMasters(paths, read, write, info) {
185
+ const slideXmls = paths.filter(path => /^ppt\/slides\/slide\d+\.xml$/.test(path)).sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0])).map(path => dec.decode(read(path)));
186
+ const style = furnitureRunStyle(slideXmls, info.names);
187
+ const flags = headerFooterFlags(info.used);
188
+ for (const path of paths.filter(path => /^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(path))) {
189
+ let xml = dec.decode(read(path));
190
+ xml = insertPlaceholders(xml, ORDER.map((ph, order) => masterShape(ph, order, info, style)).join(''), path);
191
+ xml = /<p:hf\b[^>]*\/>/.test(xml) ? xml.replace(/<p:hf\b[^>]*\/>/, flags) : xml.replace(/<p:txStyles\b/, `${flags}<p:txStyles`);
192
+ if (!xml.includes(flags)) throw new Error('Slide master header/footer flags could not be written.');
193
+ write(path, enc.encode(xml));
194
+ }
195
+ for (const path of paths.filter(path => /^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(path))) {
196
+ let xml = dec.decode(read(path));
197
+ xml = insertPlaceholders(xml, ORDER.map((ph, order) => layoutShape(ph, order, info)).join(''), path);
198
+ if (!xml.includes('</p:clrMapOvr>')) throw new Error('Slide layout has no color map override.');
199
+ xml = /<p:hf\b[^>]*\/>/.test(xml) ? xml.replace(/<p:hf\b[^>]*\/>/, flags) : xml.replace('</p:clrMapOvr>', `</p:clrMapOvr>${flags}`);
200
+ write(path, enc.encode(xml));
201
+ }
202
+ for (const path of paths.filter(path => /^ppt\/notesMasters\/notesMaster\d+\.xml$/.test(path))) {
203
+ let xml = dec.decode(read(path));
204
+ const notes = '<p:hf hdr="0" ftr="0" dt="0"/>';
205
+ xml = /<p:hf\b[^>]*\/>/.test(xml) ? xml.replace(/<p:hf\b[^>]*\/>/, notes) : xml.replace(/(<p:clrMap\b[^>]*\/>)/, `$1${notes}`);
206
+ if (!xml.includes(notes)) throw new Error('Notes master header/footer flags could not be written.');
207
+ write(path, enc.encode(xml));
208
+ }
209
+ }
210
+
211
+ export const nativeDateText = date => formatDate(date ?? '', DEFAULT_DATE_FORMAT) ?? formatDate('2026-01-01', DEFAULT_DATE_FORMAT);
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Import
215
+ // ---------------------------------------------------------------------------
216
+
217
+ const dateFormatForField = Object.fromEntries(Object.entries(NATIVE_DATE_FIELDS).map(([format, type]) => [type, format]));
218
+ const REL_LAYOUT = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout';
219
+ const REL_MASTER = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster';
220
+
221
+ function xfrmBounds(xfrm) {
222
+ const x = Number(xfrm?.['a:off']?.x), y = Number(xfrm?.['a:off']?.y), cx = Number(xfrm?.['a:ext']?.cx), cy = Number(xfrm?.['a:ext']?.cy);
223
+ return [x, y, cx, cy].every(Number.isFinite) && cx > 0 ? {x, y, cx, cy} : undefined;
224
+ }
225
+
226
+ // A placeholder without its own xfrm inherits it: slide -> layout (same idx, else same
227
+ // type) -> master (same type). Parsed layout/master trees are cached per package read.
228
+ function inheritedBounds(shape, ph, slidePath, relationships, readPart) {
229
+ const own = xfrmBounds(shape['p:spPr']?.['a:xfrm']);
230
+ if (own) return own;
231
+ const index = shape['p:nvSpPr']?.['p:nvPr']?.['p:ph']?.idx;
232
+ const find = (path, byIdx) => {
233
+ const tree = readPart(path)?.[path.includes('slideMaster') ? 'p:sldMaster' : 'p:sldLayout']?.['p:cSld']?.['p:spTree'];
234
+ const candidates = array(tree?.['p:sp']).filter(candidate => candidate['p:nvSpPr']?.['p:nvPr']?.['p:ph']?.type === ph);
235
+ const match = (byIdx && index !== undefined ? candidates.find(candidate => candidate['p:nvSpPr']['p:nvPr']['p:ph'].idx === index) : undefined) ?? candidates[0];
236
+ return match ? xfrmBounds(match['p:spPr']?.['a:xfrm']) : undefined;
237
+ };
238
+ const layout = [...relationships(slidePath).values()].find(relationship => relationship.type === REL_LAYOUT)?.path;
239
+ if (!layout) return undefined;
240
+ const fromLayout = find(layout, true);
241
+ if (fromLayout) return fromLayout;
242
+ const master = [...relationships(layout).values()].find(relationship => relationship.type === REL_MASTER)?.path;
243
+ return master ? find(master, false) : undefined;
244
+ }
245
+
246
+ function zoneOf(bounds, slideWidth, fallback) {
247
+ if (!bounds || !(slideWidth > 0)) return fallback;
248
+ const center = (bounds.x + bounds.cx / 2) / slideWidth;
249
+ return center < 1 / 3 ? 'left' : center > 2 / 3 ? 'right' : 'center';
250
+ }
251
+
252
+ /**
253
+ * The footer one slide's native placeholders describe. `claimed` is the set of
254
+ * shape indexes OPF provenance already consumed or reserved. Returns
255
+ * {fields: [{zone, field, value, settings, index}], skipped: [index]} where `value`
256
+ * is the OPF field value and `settings` the zone's `dateFormat`/`slideNumberFormat`.
257
+ */
258
+ export function readNativePlaceholders(context, claimed, relationships, readPart) {
259
+ const fields = [];
260
+ for (const [index, shape] of context.shapes.entries()) {
261
+ const ph = shape['p:nvSpPr']?.['p:nvPr']?.['p:ph']?.type;
262
+ if (!isNativePlaceholderType(ph) || claimed.has(index)) continue;
263
+ const paragraphs = context.paragraphs[index] ?? [];
264
+ const text = paragraphs.map(paragraph => paragraph.text).join('\n');
265
+ const nativeFields = paragraphs.flatMap(paragraph => paragraph.fields ?? []);
266
+ const zone = zoneOf(inheritedBounds(shape, ph, context.path, relationships, readPart), context.slideWidth, NATIVE_PLACEHOLDERS[ph].zone);
267
+ const properties = NATIVE_PLACEHOLDERS[ph];
268
+ if (ph === 'sldNum') {
269
+ const live = nativeFields.filter(field => field.type === 'slidenum');
270
+ if (live.length !== 1) continue;
271
+ const at = text.indexOf(live[0].text);
272
+ const format = at < 0 ? undefined : text.slice(0, at) + '{current}' + text.slice(at + live[0].text.length);
273
+ fields.push({index, zone, field: properties.field, value: true, settings: format && format !== '{current}' ? {slideNumberFormat: format} : {}});
274
+ } else if (ph === 'dt') {
275
+ if (!text.trim()) continue;
276
+ 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}});
278
+ // 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: {}});
280
+ } else {
281
+ if (!text.trim()) continue;
282
+ fields.push({index, zone, field: properties.field, value: text, settings: {}});
283
+ }
284
+ }
285
+ return fields;
286
+ }
287
+
288
+ /** The OPF footer value for native placeholder fields: zones keyed left/center/right. */
289
+ export function footerValue(fields) {
290
+ const value = {};
291
+ for (const item of fields) {
292
+ value[item.zone] ??= {};
293
+ value[item.zone][item.field] = item.value;
294
+ Object.assign(value[item.zone], item.settings);
295
+ }
296
+ return Object.fromEntries(ZONES.filter(zone => value[zone]).map(zone => [zone, value[zone]]));
297
+ }
@@ -5,6 +5,18 @@ const boolean = value => ['1','true','on'].includes(value) ? true : ['0','false'
5
5
 
6
6
  const themeFamilies = context => ['a:majorFont', 'a:minorFont'].map(key => context.fonts?.[key]?.['a:latin']?.typeface?.trim().toLowerCase()).filter(Boolean);
7
7
 
8
+ // A run painted with a bare theme slot (a:schemeClr, no color transform) remembers the slot as the OPF color name: the
9
+ // transient `_opfScheme` marker is read, and always removed, by restoreRunColors (run-colors.js) once the document's own
10
+ // colour scheme can confirm the slot holds that colour.
11
+ const OPF_SLOTS = {dk1: 'dark1', lt1: 'light1', dk2: 'dark2', lt2: 'light2', hlink: 'hyperlink', folHlink: 'followedHyperlink', accent1: 'accent1', accent2: 'accent2', accent3: 'accent3', accent4: 'accent4', accent5: 'accent5', accent6: 'accent6'};
12
+ function nativeSchemeSlot(fill, context) {
13
+ const node = fill?.['a:schemeClr'];
14
+ if (!node || Array.isArray(node) || Object.keys(fill).length !== 1 || Object.keys(node).some(key => key !== 'val') || typeof node.val !== 'string') return undefined;
15
+ // Own keys only: a slot name from the package (`__proto__`, `constructor`) must never reach an inherited property.
16
+ const mapping = context?.mapping, slot = mapping && Object.hasOwn(mapping, node.val) ? mapping[node.val] : node.val;
17
+ return typeof slot === 'string' && Object.hasOwn(OPF_SLOTS, slot) ? OPF_SLOTS[slot] : undefined;
18
+ }
19
+
8
20
  export function nativeRunStyle(properties, context, relationships, report, kind = 'table') {
9
21
  const result = {};
10
22
  for (const [native, opf] of [['b','bold'], ['i','italic']]) {
@@ -42,7 +54,11 @@ export function nativeRunStyle(properties, context, relationships, report, kind
42
54
  }
43
55
  if (properties['a:solidFill']) {
44
56
  const color = readBackgroundColor(properties['a:solidFill'], context);
45
- if (color) result.color = color.hex + (color.alpha < 1 ? Math.round(color.alpha * 255).toString(16).padStart(2, '0').toUpperCase() : '');
57
+ if (color) {
58
+ result.color = color.hex + (color.alpha < 1 ? Math.round(color.alpha * 255).toString(16).padStart(2, '0').toUpperCase() : '');
59
+ const slot = color.alpha === 1 ? nativeSchemeSlot(properties['a:solidFill'], context) : undefined;
60
+ if (slot) result._opfScheme = slot;
61
+ }
46
62
  else report(`unsupported-${kind}-text-color`, 'The native text color or its transforms could not be resolved.');
47
63
  } else if (properties['a:noFill']) {
48
64
  result.color = '#00000000';
@@ -0,0 +1,114 @@
1
+ // RR-33: numbered lists. OPF `numbering` (a style, a start and a suffix per list level) is written as native
2
+ // `a:buAutoNum` and read back from it.
3
+ //
4
+ // Export: every marker line of a measured list is its own text box (see addMeasuredList), so each carries the number
5
+ // core counted as `startAt`; there is nothing for PowerPoint to count across boxes.
6
+ // Import: `a:buAutoNum` of a native paragraph is read with PowerPoint's own counting (consecutive paragraphs of one
7
+ // level and one scheme count up from `startAt`; a paragraph of another scheme or start begins a new sequence; a
8
+ // shallower or unnumbered paragraph ends the deeper sequences), then the numbers of the whole list are expressed as the
9
+ // shortest `numbering` plus per-entry `start` values that reproduce them.
10
+
11
+ const STYLES = ['arabic', 'roman-upper', 'roman-lower', 'alpha-upper', 'alpha-lower'];
12
+ const STYLE_STEMS = {arabic: 'arabic', 'roman-upper': 'romanUc', 'roman-lower': 'romanLc', 'alpha-upper': 'alphaUc', 'alpha-lower': 'alphaLc'};
13
+ const SUFFIX_TAILS = {period: 'Period', paren: 'ParenR', 'paren-both': 'ParenBoth'};
14
+
15
+ /** The `a:buAutoNum@type` scheme of an OPF style and suffix. */
16
+ export const autoNumScheme = (style, suffix = 'period') => `${STYLE_STEMS[style]}${SUFFIX_TAILS[suffix]}`;
17
+
18
+ const SCHEMES = new Map(STYLES.flatMap(style => Object.keys(SUFFIX_TAILS).map(suffix => [autoNumScheme(style, suffix), {style, suffix}])));
19
+ /** OPF style and suffix of a native scheme, or `undefined` for a scheme OPF has no equivalent for. */
20
+ export const schemeNumbering = type => SCHEMES.get(type);
21
+
22
+ /** `startAt` of a native auto-number: an integer from 1 to 32767, default 1. */
23
+ export function autoNumStart(value) {
24
+ if (value === undefined || value === '') return 1;
25
+ const number = Number(value);
26
+ return Number.isInteger(number) && number >= 1 && number <= 32767 ? number : undefined;
27
+ }
28
+
29
+ /**
30
+ * The number each paragraph of one native shape displays, by PowerPoint's counting. `paragraphs` are
31
+ * `{level, autoNum?: {type, startAt}}`; returns the displayed numbers (`undefined` for an unnumbered paragraph).
32
+ */
33
+ export function displayedNumbers(paragraphs) {
34
+ const sequences = [];
35
+ return paragraphs.map(paragraph => {
36
+ const level = Number.isInteger(paragraph.level) && paragraph.level >= 0 ? paragraph.level : 0;
37
+ if (!paragraph.autoNum) { sequences.length = Math.min(sequences.length, level); return undefined; }
38
+ const start = autoNumStart(paragraph.autoNum.startAt) ?? 1, type = paragraph.autoNum.type ?? 'arabicPeriod';
39
+ const current = sequences[level];
40
+ const value = current && current.type === type && current.start === start ? current.value + 1 : start;
41
+ sequences[level] = {type, start, value};
42
+ sequences.length = level + 1;
43
+ return value;
44
+ });
45
+ }
46
+
47
+ const entryOf = ({style, start, suffix}) => {
48
+ const fields = {...(style !== 'arabic' ? {style} : {}), ...(start !== 1 ? {start} : {}), ...(suffix !== 'period' ? {suffix} : {})};
49
+ const names = Object.keys(fields);
50
+ if (!names.length) return 'arabic';
51
+ return names.length === 1 && names[0] === 'style' ? style : fields;
52
+ };
53
+
54
+ /**
55
+ * The OPF numbering of a native list: `paragraphs` are `{level, number?: {type, value}}` in reading order (`number` is the
56
+ * displayed number and its scheme). Returns `{numbering, starts, diagnostics}`: `numbering` is the shortest authored form
57
+ * (`undefined` when the list is not numbered), `starts` maps a paragraph index to the entry `start` that restores its
58
+ * number, and `diagnostics` lists what could not be represented.
59
+ */
60
+ export function deriveListNumbering(paragraphs) {
61
+ const diagnostics = [];
62
+ const numbered = paragraphs.filter(paragraph => paragraph.number);
63
+ if (!numbered.length) return {numbering: undefined, starts: new Map(), diagnostics};
64
+ if (numbered.length !== paragraphs.length) {
65
+ diagnostics.push({code: 'numbering-mixed', message: 'A list that mixes numbered and bulleted or plain paragraphs has no OPF equivalent (numbering applies to the whole list); it imports as bullets.'});
66
+ return {numbering: undefined, starts: new Map(), diagnostics};
67
+ }
68
+ const levels = [];
69
+ let unknown = false, mixedStyles = false;
70
+ for (const paragraph of numbered) {
71
+ const level = paragraph.level ?? 0;
72
+ const known = schemeNumbering(paragraph.number.type);
73
+ if (!known) { unknown = true; }
74
+ const resolved = known ?? {style: 'arabic', suffix: 'period'};
75
+ if (!levels[level]) levels[level] = {...resolved, start: paragraph.number.value};
76
+ else if (levels[level].style !== resolved.style || levels[level].suffix !== resolved.suffix) mixedStyles = true;
77
+ }
78
+ if (unknown) diagnostics.push({code: 'numbering-scheme-adapted', message: 'A native numbering scheme OPF has no equivalent for (an East Asian, Hebrew or Arabic scheme, or a bullet-only scheme) imports as arabic with a period.'});
79
+ if (mixedStyles) diagnostics.push({code: 'numbering-style-adapted', message: 'Entries of one list level use different native numbering schemes; OPF numbers a level in one style, so the first entry\'s style is used for the level.'});
80
+ const filled = [];
81
+ for (let level = 0; level < levels.length; level++) filled[level] = levels[level] ?? {...(filled[level - 1] ?? {style: 'arabic', suffix: 'period'}), start: 1};
82
+ // Per-entry starts: restart wherever the displayed number is not what counting gives.
83
+ const counters = [], starts = new Map();
84
+ paragraphs.forEach((paragraph, index) => {
85
+ const level = paragraph.level ?? 0, value = paragraph.number.value;
86
+ const natural = counters[level] === undefined ? filled[level].start : counters[level] + 1;
87
+ if (natural !== value) starts.set(index, value);
88
+ counters[level] = value;
89
+ counters.length = level + 1;
90
+ });
91
+ // The last entry repeats for deeper levels, so trailing equal entries are redundant.
92
+ const same = (a, b) => a.style === b.style && a.suffix === b.suffix && a.start === b.start;
93
+ while (filled.length > 1 && same(filled.at(-1), filled.at(-2))) filled.pop();
94
+ const entries = filled.map(entryOf);
95
+ return {numbering: entries.length === 1 ? entries[0] : entries, starts, diagnostics};
96
+ }
97
+
98
+ /** A canonical form of an authored `numbering`, for comparing two spellings of the same numbering. */
99
+ export function canonicalNumbering(numbering) {
100
+ const list = Array.isArray(numbering) ? numbering : [numbering];
101
+ const resolved = list.map(entry => typeof entry === 'string' ? {style: entry, start: 1, suffix: 'period'} : {style: entry?.style ?? 'arabic', start: entry?.start ?? 1, suffix: entry?.suffix ?? 'period'});
102
+ while (resolved.length > 1 && JSON.stringify(resolved.at(-1)) === JSON.stringify(resolved.at(-2))) resolved.pop();
103
+ return JSON.stringify(resolved);
104
+ }
105
+
106
+ /**
107
+ * Add `autoNum` (the paragraph's `a:buAutoNum` attributes) and the displayed `number` ({type, value}) to the numbered
108
+ * paragraphs of one native shape. Paragraphs without a native auto-number are returned unchanged.
109
+ */
110
+ export function withDisplayedNumbers(paragraphs) {
111
+ if (!paragraphs.some(paragraph => paragraph.autoNum)) return paragraphs;
112
+ const numbers = displayedNumbers(paragraphs);
113
+ return paragraphs.map((paragraph, index) => numbers[index] === undefined ? paragraph : {...paragraph, number: {type: paragraph.autoNum.type ?? 'arabicPeriod', value: numbers[index]}});
114
+ }
@@ -0,0 +1,118 @@
1
+ // Run colours named by a reference (RR-08).
2
+ //
3
+ // A TextRun colour may be a ColorRef: a `var:` variable, a colour-scheme slot ('accent2') or a role ('primary'). The
4
+ // export resolves it (or writes the theme slot that holds the same colour) and PowerPoint has no field for the name, so
5
+ // import used to return only the resolved hex. Two sources can name the reference again, and both are gated by the
6
+ // imported document's own colour resolution: a name is restored only while it still resolves to the run's current colour,
7
+ // so a colour edited in PowerPoint keeps its edited value and nothing is invented.
8
+ //
9
+ // 1. Provenance (full mode): OPF_SLIDE_V1 `colors` = {n, at: [[slot, reference], ...]}. The slots are the text runs of the
10
+ // slide's content in a fixed order (slideRunSlots: text runs, list item and description runs; regions by name, then
11
+ // blocks and groups in order, or the root payload). `n` is how many slots the authored slide had; the record is used only
12
+ // when the imported slide has exactly that many, so a run can never be matched to another. It holds reference names
13
+ // only, never words.
14
+ // 2. A slide with no OPF record (plain PPTX): a run painted with a bare a:schemeClr (no colour transform) names that
15
+ // theme slot ('accent2', 'dark1', 'hyperlink'), restored when the document's colour scheme holds that exact colour for
16
+ // the slot. The exporter writes schemeClr only where the theme holds the colour, so this is the same convention.
17
+
18
+ import {ROOT_PAYLOAD_FIELDS, REGION_KEYS} from './content-topology.js';
19
+
20
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
21
+ const REGION_KEY_SET = new Set(REGION_KEYS);
22
+ const isGroup = block => object(block) && (block.type === 'group' || (block.type === undefined && Array.isArray(block.blocks)));
23
+ export const MAX_COLOR_SLOTS = 100000;
24
+
25
+ // A reference name: the schema's ColorRef forms other than a hex literal.
26
+ const REFERENCE = /^(?:var:[a-z][a-z0-9-]*|accent[1-6]|dark[12]|light[12]|hyperlink|followedHyperlink|primary|secondary|accent|background|surface|text|textSecondary)$/;
27
+ export const isColorReference = value => typeof value === 'string' && REFERENCE.test(value);
28
+
29
+ /** The payload objects of a slide in the fixed traversal order: regions (by name), else blocks (groups depth first), else the slide's root payload. */
30
+ function* leaves(slide) {
31
+ const walk = function* (block) {
32
+ if (!object(block)) return;
33
+ if (isGroup(block)) { for (const child of Array.isArray(block.blocks) ? block.blocks : []) yield* walk(child); return; }
34
+ yield block;
35
+ };
36
+ const regions = Object.keys(slide).filter(key => REGION_KEY_SET.has(key) && object(slide[key])).sort();
37
+ if (regions.length) { for (const key of regions) yield* walk(slide[key]); return; }
38
+ if (Array.isArray(slide.blocks)) { for (const block of slide.blocks) yield* walk(block); return; }
39
+ yield slide;
40
+ }
41
+
42
+ /** The text runs of the slide's text and list payloads, in order: an object (a run) or null (a plain string). */
43
+ export function slideRunSlots(slide) {
44
+ const slots = [];
45
+ const runs = value => {
46
+ if (Array.isArray(value)) for (const run of value) slots.push(object(run) ? run : null);
47
+ else if (typeof value === 'string') slots.push(null);
48
+ };
49
+ for (const leaf of leaves(slide)) {
50
+ for (const field of ROOT_PAYLOAD_FIELDS) {
51
+ const value = leaf[field];
52
+ if (field === 'text') { if (Array.isArray(value)) runs(value); }
53
+ else if ((field === 'items' || field === 'bullets') && Array.isArray(value)) {
54
+ for (const item of value) {
55
+ if (Array.isArray(item) || typeof item === 'string') runs(item);
56
+ else if (object(item)) { runs(item.text); runs(item.description); }
57
+ }
58
+ }
59
+ }
60
+ }
61
+ return slots;
62
+ }
63
+
64
+ /** The record stored for an authored slide, or undefined when no run names a reference. */
65
+ export function runColorRecord(slide) {
66
+ const slots = slideRunSlots(slide);
67
+ if (slots.length > MAX_COLOR_SLOTS) return undefined;
68
+ const at = [];
69
+ slots.forEach((run, index) => { if (run && isColorReference(run.color)) at.push([index, run.color]); });
70
+ return at.length ? {n: slots.length, at} : undefined;
71
+ }
72
+
73
+ /** Throws when `value` is not a well-formed colours record. */
74
+ export function validateRunColors(value) {
75
+ if (!object(value) || !Number.isSafeInteger(value.n) || value.n < 1 || value.n > MAX_COLOR_SLOTS || !Array.isArray(value.at) || !value.at.length || value.at.length > value.n) throw Error('Invalid run colours record.');
76
+ let previous = -1;
77
+ for (const entry of value.at) {
78
+ if (!Array.isArray(entry) || entry.length !== 2 || !Number.isSafeInteger(entry[0]) || entry[0] <= previous || entry[0] >= value.n || !isColorReference(entry[1])) throw Error('Invalid run colour entry.');
79
+ previous = entry[0];
80
+ }
81
+ return value;
82
+ }
83
+
84
+ function* markedRuns(value) {
85
+ if (Array.isArray(value)) { for (const item of value) yield* markedRuns(item); return; }
86
+ if (!object(value)) return;
87
+ if (Object.hasOwn(value, '_opfScheme')) yield value;
88
+ for (const item of Object.values(value)) if (item !== null && typeof item === 'object') yield* markedRuns(item);
89
+ }
90
+
91
+ /**
92
+ * Restore run colour references on `slides` (the imported document's slides). `info[i]` is
93
+ * {record, structure, content}: the stored OPF_SLIDE_V1 value, whether the slide's arrangement matches it and whether its
94
+ * content structure was restored from it, or undefined for an untagged slide. `resolve(slide, name)` returns the '#RRGGBB'
95
+ * the name resolves to in the document (or undefined). Every transient `_opfScheme` marker is removed.
96
+ */
97
+ export function restoreRunColors(slides, info, resolve, report) {
98
+ slides.forEach((slide, index) => {
99
+ const tag = info[index];
100
+ const marked = [...markedRuns(slide)];
101
+ const restoreName = (run, name) => {
102
+ const hex = typeof run.color === 'string' && /^#[0-9a-f]{6}$/i.test(run.color) ? run.color.toUpperCase() : undefined;
103
+ if (hex && resolve(slide, name)?.toUpperCase() === hex) run.color = name;
104
+ };
105
+ if (tag?.record?.colors) {
106
+ const {n, at} = tag.record.colors;
107
+ // When the content form was not restored its own diagnostic says why, and the slots cannot be aligned.
108
+ if (tag.structure && tag.content) {
109
+ const slots = slideRunSlots(slide);
110
+ if (slots.length !== n) report({code: 'color-reference-changed', path: `slides.${index}`, message: `The slide's text runs no longer match the stored colour references (${slots.length} runs now, ${n} at export), so the named run colours of slides.${index} (variables, scheme slots and roles) were not restored; the runs keep their resolved colours.`});
111
+ else for (const [slot, name] of at) if (slots[slot]) restoreName(slots[slot], name);
112
+ }
113
+ } else if (!tag?.record) {
114
+ for (const run of marked) if (typeof run._opfScheme === 'string') restoreName(run, run._opfScheme);
115
+ }
116
+ for (const run of marked) delete run._opfScheme;
117
+ });
118
+ }