@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.
- package/README.md +69 -9
- package/dist/annotation-export.js +83 -0
- package/dist/annotation-provenance.js +246 -0
- package/dist/body-text-import.js +4 -1
- package/dist/chart-options.js +276 -0
- package/dist/chartex.js +42 -11
- package/dist/code-provenance.js +34 -10
- package/dist/content-topology.js +280 -53
- package/dist/document-provenance.js +83 -10
- package/dist/furniture-provenance.js +67 -3
- package/dist/image-fallback-browser.js +12 -0
- package/dist/image-fallback-node.js +22 -0
- package/dist/import-signals.js +809 -0
- package/dist/index.d.ts +302 -4
- package/dist/index.js +486 -102
- package/dist/logo-provenance.js +8 -6
- package/dist/metric-provenance.js +6 -3
- package/dist/native-furniture.js +297 -0
- package/dist/native-text-style.js +17 -1
- package/dist/numbered-list.js +114 -0
- package/dist/run-colors.js +118 -0
- package/dist/script-fonts.js +90 -14
- package/dist/slide-image-provenance.js +6 -4
- package/dist/svg-image.js +354 -0
- package/dist/table-cell-import.js +3 -1
- package/dist/table-import.js +3 -2
- package/dist/watermark-provenance.js +4 -3
- package/package.json +7 -7
package/dist/logo-provenance.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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)
|
|
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
|
+
}
|