@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
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
// Raw, deterministic per-shape signals for a PPTX (`fromPptx(bytes, {signals: true})`).
|
|
2
|
+
//
|
|
3
|
+
// The importer turns a native deck into OPF with fixed rules. A deck that did
|
|
4
|
+
// not come from this package carries no OPF provenance, so structure such as
|
|
5
|
+
// code, metrics, quotes, timelines or regions has to be inferred by something
|
|
6
|
+
// that can judge meaning. That judgement is not made here: this module only
|
|
7
|
+
// reports what is in the package, as plain JSON, so a host can run its own
|
|
8
|
+
// classifier (a model, a rules engine, a human) over it. It reads the archive
|
|
9
|
+
// only: no network, no models, no clock, no randomness, nothing guessed.
|
|
10
|
+
//
|
|
11
|
+
// Every value is read from the PPTX. Text properties are the effective values
|
|
12
|
+
// the way PowerPoint resolves them: the run, its paragraph, the shape's list
|
|
13
|
+
// style, the layout and master placeholder, the master text styles, the
|
|
14
|
+
// presentation defaults and the theme (font tokens and scheme colours). A value
|
|
15
|
+
// that no part of that chain states is omitted rather than defaulted, except
|
|
16
|
+
// paragraph alignment (PowerPoint's own default, left).
|
|
17
|
+
|
|
18
|
+
import {XMLParser} from 'fast-xml-parser';
|
|
19
|
+
import {drawingObject, readSlideTheme} from './background-import.js';
|
|
20
|
+
import {readBackgroundColor} from './background.js';
|
|
21
|
+
import {mergeNativeRunProperties} from './native-text-style.js';
|
|
22
|
+
import {rasterMetadata} from './image-geometry.js';
|
|
23
|
+
import {FONT_WEIGHT_WORDS} from './font-weights.js';
|
|
24
|
+
import {readThemeSlotColors} from './theme-colors.js';
|
|
25
|
+
|
|
26
|
+
export const SIGNALS_VERSION = 1;
|
|
27
|
+
|
|
28
|
+
/** Default bounds. Every one can be lowered or raised (up to SIGNAL_LIMIT_CAPS) with `signals: {…}`. */
|
|
29
|
+
export const DEFAULT_SIGNAL_LIMITS = Object.freeze({
|
|
30
|
+
maxSlides: 300,
|
|
31
|
+
maxShapesPerSlide: 300,
|
|
32
|
+
maxTotalShapes: 5000,
|
|
33
|
+
maxParagraphsPerShape: 200,
|
|
34
|
+
maxTextCharsPerShape: 8000,
|
|
35
|
+
maxTextCharsTotal: 400000,
|
|
36
|
+
maxTableCells: 400,
|
|
37
|
+
maxTableCellChars: 200,
|
|
38
|
+
maxGroupDepth: 12
|
|
39
|
+
});
|
|
40
|
+
export const SIGNAL_LIMIT_CAPS = Object.freeze({
|
|
41
|
+
maxSlides: 5000, maxShapesPerSlide: 5000, maxTotalShapes: 100000, maxParagraphsPerShape: 5000, maxTextCharsPerShape: 200000,
|
|
42
|
+
maxTextCharsTotal: 5000000, maxTableCells: 10000, maxTableCellChars: 5000, maxGroupDepth: 32
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const EMU_PER_PX = 9525; // 914400 EMU per inch / 96 reference px per inch
|
|
46
|
+
const MAX_TRUNCATIONS = 50;
|
|
47
|
+
const MAX_URL = 500;
|
|
48
|
+
|
|
49
|
+
const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', preserveOrder: true, parseAttributeValue: false, parseTagValue: false, trimValues: false, htmlEntities: true});
|
|
50
|
+
|
|
51
|
+
const asArray = value => value === undefined || value === null ? [] : Array.isArray(value) ? value : [value];
|
|
52
|
+
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
53
|
+
const tagOf = node => { for (const key in node) if (key !== ':@' && key !== '#text') return key; return undefined; };
|
|
54
|
+
const kidsOf = node => { const tag = tagOf(node); return tag ? node[tag] ?? [] : []; };
|
|
55
|
+
const attrsOf = node => node?.[':@'] ?? {};
|
|
56
|
+
const findKid = (kids, tag) => (kids ?? []).find(node => Object.hasOwn(node, tag));
|
|
57
|
+
const filterKids = (kids, tag) => (kids ?? []).filter(node => Object.hasOwn(node, tag));
|
|
58
|
+
const propsOf = node => node ? drawingObject([node])[tagOf(node)] : undefined;
|
|
59
|
+
const textOf = kids => (kids ?? []).map(node => node['#text'] ?? '').join('');
|
|
60
|
+
const round = (value, digits = 2) => Math.round(value * 10 ** digits) / 10 ** digits;
|
|
61
|
+
const number = value => value === undefined || value === '' ? undefined : Number.isFinite(Number(value)) ? Number(value) : undefined;
|
|
62
|
+
const bool = value => value === '1' || value === 'true' || value === 'on' ? true : value === '0' || value === 'false' || value === 'off' ? false : undefined;
|
|
63
|
+
const clip = (value, max) => value.length > max ? value.slice(0, max) : value;
|
|
64
|
+
|
|
65
|
+
/** Validate the `signals` option. Returns null (off) or the effective limits. */
|
|
66
|
+
export function normalizeSignalOptions(value, createError) {
|
|
67
|
+
if (value === undefined || value === false || value === null) return null;
|
|
68
|
+
if (value === true) return {...DEFAULT_SIGNAL_LIMITS};
|
|
69
|
+
if (!isObject(value)) throw createError('invalid-signals-option', 'signals must be true or an object of limits.', {path: 'options.signals'});
|
|
70
|
+
const limits = {...DEFAULT_SIGNAL_LIMITS};
|
|
71
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
72
|
+
if (!Object.hasOwn(DEFAULT_SIGNAL_LIMITS, key)) throw createError('invalid-signals-option', `Unknown signals option ${key}.`, {path: `options.signals.${key}`});
|
|
73
|
+
if (!Number.isInteger(entry) || entry < 1 || entry > SIGNAL_LIMIT_CAPS[key]) throw createError('invalid-signals-option', `signals.${key} must be an integer from 1 to ${SIGNAL_LIMIT_CAPS[key]}.`, {path: `options.signals.${key}`});
|
|
74
|
+
limits[key] = entry;
|
|
75
|
+
}
|
|
76
|
+
return limits;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Fonts
|
|
81
|
+
|
|
82
|
+
const MONOSPACE_FAMILIES = new Set([
|
|
83
|
+
'consolas', 'courier', 'courier new', 'lucida console', 'lucida sans typewriter', 'menlo', 'monaco', 'andale mono', 'source code pro',
|
|
84
|
+
'fira code', 'fira mono', 'jetbrains mono', 'cascadia code', 'cascadia mono', 'sf mono', 'sfmono-regular', 'roboto mono', 'ibm plex mono',
|
|
85
|
+
'inconsolata', 'dejavu sans mono', 'liberation mono', 'ubuntu mono', 'hack', 'droid sans mono', 'noto sans mono', 'pt mono', 'space mono',
|
|
86
|
+
'anonymous pro', 'overpass mono', 'dm mono', 'courier prime', 'nimbus mono', 'lucida sans typewriter', 'ocr a extended', 'terminal', 'fixedsys',
|
|
87
|
+
'letter gothic', 'prestige elite', 'monospace', 'victor mono', 'iosevka', 'input mono', 'mononoki', 'go mono', 'red hat mono', 'geist mono'
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
/** True for a family known to be fixed-width (a list, then a `mono`/`code`/`courier`/`typewriter` word in its name). */
|
|
91
|
+
export function isMonospaceFamily(family) {
|
|
92
|
+
if (typeof family !== 'string') return false;
|
|
93
|
+
const name = family.trim().toLowerCase();
|
|
94
|
+
if (!name) return false;
|
|
95
|
+
if (MONOSPACE_FAMILIES.has(name)) return true;
|
|
96
|
+
return /(^|[\s_-])(mono|monospace|typewriter|courier)([\s_-]|$)/.test(name) || /mono$/.test(name);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const weightWords = new Map(FONT_WEIGHT_WORDS);
|
|
100
|
+
// "Segoe UI Semibold" names its own weight; an explicit b="1" always means bold.
|
|
101
|
+
function familyWeight(family) {
|
|
102
|
+
const last = family.trim().split(/\s+/).at(-1)?.toLowerCase();
|
|
103
|
+
return last && last !== 'regular' && last !== 'normal' && last !== 'book' ? weightWords.get(last) : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Slide inheritance context: theme, colours, layout and master placeholders
|
|
108
|
+
|
|
109
|
+
const PH_TYPE_ALIASES = {ctrTitle: 'title', subTitle: 'body', obj: 'body'};
|
|
110
|
+
const placeholderKey = ph => PH_TYPE_ALIASES[ph?.type ?? 'obj'] ?? ph?.type ?? 'obj';
|
|
111
|
+
|
|
112
|
+
function placeholdersOf(root) {
|
|
113
|
+
return asArray(root?.['p:cSld']?.['p:spTree']?.['p:sp'])
|
|
114
|
+
.map(sp => ({ph: sp?.['p:nvSpPr']?.['p:nvPr']?.['p:ph'], sp})).filter(entry => entry.ph !== undefined);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function findPlaceholder(list, ph) {
|
|
118
|
+
if (!ph) return undefined;
|
|
119
|
+
if (ph.idx !== undefined) {
|
|
120
|
+
const exact = list.find(entry => entry.ph.idx === ph.idx && placeholderKey(entry.ph) === placeholderKey(ph)) ?? list.find(entry => entry.ph.idx === ph.idx);
|
|
121
|
+
if (exact) return exact;
|
|
122
|
+
}
|
|
123
|
+
return list.find(entry => placeholderKey(entry.ph) === placeholderKey(ph));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function buildContext(slidePath, archive) {
|
|
127
|
+
const theme = readSlideTheme(slidePath, archive);
|
|
128
|
+
const related = (path, type) => [...archive.relationships(path).values()].find(rel => rel.type.endsWith('/' + type) && archive.bytes(rel.path))?.path;
|
|
129
|
+
const layoutPath = related(slidePath, 'slideLayout');
|
|
130
|
+
const masterPath = layoutPath ? related(layoutPath, 'slideMaster') : undefined;
|
|
131
|
+
const layout = layoutPath ? theme.parsedPart(layoutPath)?.value?.['p:sldLayout'] : undefined;
|
|
132
|
+
const master = masterPath ? theme.parsedPart(masterPath)?.value?.['p:sldMaster'] : undefined;
|
|
133
|
+
const presentation = theme.parsedPart('ppt/presentation.xml')?.value?.['p:presentation'];
|
|
134
|
+
const themePath = masterPath ? related(masterPath, 'theme') : undefined;
|
|
135
|
+
return {
|
|
136
|
+
themeName: themePath ? theme.parsedPart(themePath)?.value?.['a:theme']?.name : undefined,
|
|
137
|
+
colors: theme.colors, mapping: theme.mapping, fonts: theme.fonts, format: theme.format,
|
|
138
|
+
layoutPath, masterPath, layout, master,
|
|
139
|
+
layoutPlaceholders: placeholdersOf(layout), masterPlaceholders: placeholdersOf(master),
|
|
140
|
+
defaultTextStyle: presentation?.['p:defaultTextStyle'], txStyles: master?.['p:txStyles']
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function resolveColor(node, context) {
|
|
145
|
+
if (!isObject(node)) return undefined;
|
|
146
|
+
const kind = ['a:srgbClr', 'a:sysClr', 'a:schemeClr', 'a:prstClr', 'a:hslClr', 'a:scrgbClr'].find(key => node[key] !== undefined);
|
|
147
|
+
if (!kind) return undefined;
|
|
148
|
+
const result = {};
|
|
149
|
+
const value = readBackgroundColor(node, {colors: context.colors, mapping: context.mapping});
|
|
150
|
+
if (value) {
|
|
151
|
+
result.color = value.hex;
|
|
152
|
+
if (value.alpha !== 1) result.alpha = round(value.alpha, 3);
|
|
153
|
+
}
|
|
154
|
+
if (kind === 'a:schemeClr' && node[kind]?.val) result.colorRef = node[kind].val;
|
|
155
|
+
return Object.keys(result).length ? result : undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function resolveFontToken(typeface, context) {
|
|
159
|
+
if (typeof typeface !== 'string' || !typeface) return undefined;
|
|
160
|
+
const match = /^\+(mj|mn)-lt$/.exec(typeface);
|
|
161
|
+
if (!match) return typeface.startsWith('+') ? undefined : typeface;
|
|
162
|
+
return context.fonts?.[match[1] === 'mj' ? 'a:majorFont' : 'a:minorFont']?.['a:latin']?.typeface || undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Geometry
|
|
167
|
+
|
|
168
|
+
const ALIGNMENTS = {l: 'left', ctr: 'center', r: 'right', just: 'justify', dist: 'distributed', justLow: 'justify', thaiDist: 'distributed'};
|
|
169
|
+
|
|
170
|
+
function readXfrm(node) {
|
|
171
|
+
if (!isObject(node)) return null;
|
|
172
|
+
const off = node['a:off'], ext = node['a:ext'];
|
|
173
|
+
const x = number(off?.x), y = number(off?.y), cx = number(ext?.cx), cy = number(ext?.cy);
|
|
174
|
+
if ([x, y, cx, cy].some(value => value === undefined)) return null;
|
|
175
|
+
const chOff = node['a:chOff'], chExt = node['a:chExt'];
|
|
176
|
+
return {
|
|
177
|
+
x, y, cx, cy, rot: number(node.rot) ?? 0, flipH: bool(node.flipH) === true, flipV: bool(node.flipV) === true,
|
|
178
|
+
child: chOff && chExt && number(chOff.x) !== undefined && number(chOff.y) !== undefined && number(chExt.cx) !== undefined && number(chExt.cy) !== undefined
|
|
179
|
+
? {x: number(chOff.x), y: number(chOff.y), cx: number(chExt.cx), cy: number(chExt.cy)} : null
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const IDENTITY = Object.freeze({sx: 1, sy: 1, tx: 0, ty: 0, approximate: false});
|
|
184
|
+
|
|
185
|
+
function boxOf(xfrm, transform, source) {
|
|
186
|
+
const x = transform.tx + transform.sx * xfrm.x, y = transform.ty + transform.sy * xfrm.y;
|
|
187
|
+
const w = transform.sx * xfrm.cx, h = transform.sy * xfrm.cy;
|
|
188
|
+
const emu = {x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h)};
|
|
189
|
+
return {px: {x: round(x / EMU_PER_PX), y: round(y / EMU_PER_PX), w: round(w / EMU_PER_PX), h: round(h / EMU_PER_PX)}, emu, source,
|
|
190
|
+
...(transform.approximate ? {approximate: true} : {})};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function childTransform(parent, xfrm) {
|
|
194
|
+
const child = xfrm.child;
|
|
195
|
+
const rx = child && child.cx ? xfrm.cx / child.cx : 1, ry = child && child.cy ? xfrm.cy / child.cy : 1;
|
|
196
|
+
const ox = child ? child.x : 0, oy = child ? child.y : 0;
|
|
197
|
+
return {
|
|
198
|
+
sx: parent.sx * rx, sy: parent.sy * ry,
|
|
199
|
+
tx: parent.tx + parent.sx * (xfrm.x - ox * rx), ty: parent.ty + parent.sy * (xfrm.y - oy * ry),
|
|
200
|
+
approximate: parent.approximate || xfrm.rot !== 0 || xfrm.flipH || xfrm.flipV
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Text
|
|
206
|
+
|
|
207
|
+
function mergeParagraphLevels(levels) {
|
|
208
|
+
const out = {attrs: {}, rpr: {}};
|
|
209
|
+
for (const level of levels) {
|
|
210
|
+
if (!isObject(level)) continue;
|
|
211
|
+
for (const [key, value] of Object.entries(level)) {
|
|
212
|
+
if (typeof value !== 'object' || value === null) out.attrs[key] = value;
|
|
213
|
+
else if (key === 'a:defRPr') out.rpr = mergeNativeRunProperties(out.rpr, value);
|
|
214
|
+
else if (['a:buNone', 'a:buChar', 'a:buAutoNum', 'a:buBlip'].includes(key)) out.bullet = {key, value};
|
|
215
|
+
else if (['a:spcBef', 'a:spcAft', 'a:lnSpc'].includes(key)) out[key] = value;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function bulletOf(bullet) {
|
|
222
|
+
if (!bullet || bullet.key === 'a:buNone') return null;
|
|
223
|
+
if (bullet.key === 'a:buChar') return {kind: 'char', ...(bullet.value.char ? {char: clip(String(bullet.value.char), 8)} : {})};
|
|
224
|
+
if (bullet.key === 'a:buAutoNum') return {kind: 'number', scheme: bullet.value.type ?? 'arabicPeriod', ...(number(bullet.value.startAt) !== undefined ? {startAt: number(bullet.value.startAt)} : {})};
|
|
225
|
+
return {kind: 'picture'};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function spacing(node, suffix) {
|
|
229
|
+
if (!isObject(node)) return {};
|
|
230
|
+
const pts = number(node['a:spcPts']?.val), pct = number(node['a:spcPct']?.val);
|
|
231
|
+
if (pts !== undefined) return {[`${suffix}Pt`]: round(pts / 100)};
|
|
232
|
+
if (pct !== undefined) return {[`${suffix}Pct`]: round(pct / 1000, 1)};
|
|
233
|
+
return {};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function runSignal(text, properties, context, relationships, extra) {
|
|
237
|
+
const run = {text};
|
|
238
|
+
const font = resolveFontToken(properties['a:latin']?.typeface, context);
|
|
239
|
+
if (font) {
|
|
240
|
+
run.font = font;
|
|
241
|
+
if (isMonospaceFamily(font)) run.monospace = true;
|
|
242
|
+
}
|
|
243
|
+
const size = number(properties.sz);
|
|
244
|
+
if (size !== undefined && size > 0) run.size = round(size / 100);
|
|
245
|
+
const bold = bool(properties.b), italic = bool(properties.i);
|
|
246
|
+
const named = font ? familyWeight(font) : undefined;
|
|
247
|
+
// Defaults are omitted: bold, italic and monospace are absent when false, weight when 400.
|
|
248
|
+
if (bold === true) run.bold = true;
|
|
249
|
+
if (italic === true) run.italic = true;
|
|
250
|
+
const weight = bold === true ? 700 : named;
|
|
251
|
+
if (weight !== undefined && weight !== 400) run.weight = weight;
|
|
252
|
+
if (properties.u !== undefined && properties.u !== 'none') run.underline = true;
|
|
253
|
+
if (properties.strike !== undefined && properties.strike !== 'noStrike') run.strike = true;
|
|
254
|
+
const baseline = number(properties.baseline);
|
|
255
|
+
if (baseline) run.baseline = baseline > 0 ? 'superscript' : 'subscript';
|
|
256
|
+
if (properties.cap === 'all' || properties.cap === 'small') run.caps = properties.cap;
|
|
257
|
+
if (properties['a:solidFill']) Object.assign(run, resolveColor(properties['a:solidFill'], context) ?? {});
|
|
258
|
+
const link = properties['a:hlinkClick'];
|
|
259
|
+
if (link) {
|
|
260
|
+
const target = relationships.get(link['r:id']);
|
|
261
|
+
if (target?.type.endsWith('/hyperlink') && target.targetMode === 'External' && target.target) run.link = clip(target.target, MAX_URL);
|
|
262
|
+
else if (link.action) run.linkAction = clip(String(link.action), 80);
|
|
263
|
+
}
|
|
264
|
+
Object.assign(run, extra);
|
|
265
|
+
return run;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const sameStyle = (a, b) => {
|
|
269
|
+
for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) if (key !== 'text' && a[key] !== b[key]) return false;
|
|
270
|
+
return true;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/** Read a txBody. Returns {text, summary} for shapes and table cells alike. */
|
|
274
|
+
function readTextBody(txBody, shape, context, relationships, limits, budget, report) {
|
|
275
|
+
const bodyKids = kidsOf(txBody);
|
|
276
|
+
const lstStyle = propsOf(findKid(bodyKids, 'a:lstStyle')) ?? {};
|
|
277
|
+
const paragraphNodes = filterKids(bodyKids, 'a:p');
|
|
278
|
+
const ph = shape.ph;
|
|
279
|
+
const layoutPh = ph ? findPlaceholder(context.layoutPlaceholders, ph) : undefined;
|
|
280
|
+
const masterPh = ph ? findPlaceholder(context.masterPlaceholders, layoutPh?.ph ?? ph) : undefined;
|
|
281
|
+
const styleKey = !ph ? undefined : ['title', 'ctrTitle'].includes(ph.type) ? 'p:titleStyle' : ['dt', 'ftr', 'sldNum'].includes(ph.type) ? 'p:otherStyle' : 'p:bodyStyle';
|
|
282
|
+
const sources = [
|
|
283
|
+
context.defaultTextStyle, styleKey ? context.txStyles?.[styleKey] : undefined,
|
|
284
|
+
masterPh?.sp?.['p:txBody']?.['a:lstStyle'], layoutPh?.sp?.['p:txBody']?.['a:lstStyle'], lstStyle
|
|
285
|
+
];
|
|
286
|
+
|
|
287
|
+
const paragraphs = [];
|
|
288
|
+
let chars = 0, monoChars = 0, maxSize = 0, bullets = 0, emitted = 0;
|
|
289
|
+
const sizeCounts = new Map(), fontCounts = new Map();
|
|
290
|
+
for (const paragraphNode of paragraphNodes) {
|
|
291
|
+
const pKids = kidsOf(paragraphNode);
|
|
292
|
+
const pPr = propsOf(findKid(pKids, 'a:pPr')) ?? {};
|
|
293
|
+
const level = Math.max(0, Math.min(8, number(pPr.lvl) ?? 0));
|
|
294
|
+
const levelKey = `a:lvl${level + 1}pPr`;
|
|
295
|
+
const merged = mergeParagraphLevels([...sources.map(source => source?.[levelKey]), pPr]);
|
|
296
|
+
const runs = [];
|
|
297
|
+
let text = '';
|
|
298
|
+
for (const node of pKids) {
|
|
299
|
+
const tag = tagOf(node);
|
|
300
|
+
if (tag !== 'a:r' && tag !== 'a:fld' && tag !== 'a:br') continue;
|
|
301
|
+
const rPr = propsOf(findKid(kidsOf(node), 'a:rPr')) ?? {};
|
|
302
|
+
const properties = mergeNativeRunProperties(merged.rpr, rPr);
|
|
303
|
+
const value = tag === 'a:br' ? '\n' : textOf(kidsOf(findKid(kidsOf(node), 'a:t')));
|
|
304
|
+
if (tag !== 'a:br' && value === '') continue;
|
|
305
|
+
const extra = tag === 'a:fld' ? {field: attrsOf(node).type ?? 'field'} : tag === 'a:br' ? {lineBreak: true} : {};
|
|
306
|
+
const signal = runSignal(value, properties, context, relationships, extra);
|
|
307
|
+
// Fall back to the theme body font when no part of the chain names a Latin face.
|
|
308
|
+
if (signal.font === undefined) {
|
|
309
|
+
const fallback = context.fonts?.['a:minorFont']?.['a:latin']?.typeface;
|
|
310
|
+
if (fallback) { signal.font = fallback; if (isMonospaceFamily(fallback)) signal.monospace = true; }
|
|
311
|
+
}
|
|
312
|
+
text += value;
|
|
313
|
+
const last = runs.at(-1);
|
|
314
|
+
if (last && !extra.field && !extra.lineBreak && !last.field && !last.lineBreak && sameStyle(last, signal)) last.text += value;
|
|
315
|
+
else runs.push(signal);
|
|
316
|
+
}
|
|
317
|
+
const visible = text.replace(/\n/g, '');
|
|
318
|
+
chars += text.length;
|
|
319
|
+
for (const run of runs) {
|
|
320
|
+
if (run.lineBreak) continue;
|
|
321
|
+
const count = run.text.replace(/\s/g, '').length;
|
|
322
|
+
if (run.monospace) monoChars += count;
|
|
323
|
+
if (run.size !== undefined) { maxSize = Math.max(maxSize, run.size); sizeCounts.set(run.size, (sizeCounts.get(run.size) ?? 0) + count); }
|
|
324
|
+
if (run.font) fontCounts.set(run.font, (fontCounts.get(run.font) ?? 0) + count);
|
|
325
|
+
}
|
|
326
|
+
const bullet = bulletOf(merged.bullet);
|
|
327
|
+
if (bullet && visible !== '') bullets += 1;
|
|
328
|
+
const marginLeft = number(merged.attrs.marL), indent = number(merged.attrs.indent);
|
|
329
|
+
const align = ALIGNMENTS[merged.attrs.algn] ?? 'left';
|
|
330
|
+
const paragraph = {
|
|
331
|
+
text, ...(level ? {level} : {}), ...(align !== 'left' ? {align} : {}), ...(bullet ? {bullet} : {}),
|
|
332
|
+
...(marginLeft ? {marginLeftPx: round(marginLeft / EMU_PER_PX)} : {}),
|
|
333
|
+
...(indent ? {indentPx: round(indent / EMU_PER_PX)} : {}),
|
|
334
|
+
...spacing(merged['a:lnSpc'], 'lineSpacing'), ...spacing(merged['a:spcBef'], 'spaceBefore'), ...spacing(merged['a:spcAft'], 'spaceAfter'),
|
|
335
|
+
runs
|
|
336
|
+
};
|
|
337
|
+
if (emitted < limits.maxParagraphsPerShape && budget.chars > 0) {
|
|
338
|
+
emitted += 1;
|
|
339
|
+
let remaining = Math.min(limits.maxTextCharsPerShape - paragraphs.reduce((sum, item) => sum + item.text.length, 0), budget.chars);
|
|
340
|
+
if (remaining <= 0) { report('text'); paragraph.text = ''; paragraph.runs = []; paragraph.truncated = true; }
|
|
341
|
+
else if (paragraph.text.length > remaining) {
|
|
342
|
+
paragraph.text = paragraph.text.slice(0, remaining); paragraph.truncated = true; report('text');
|
|
343
|
+
let left = remaining;
|
|
344
|
+
paragraph.runs = runs.flatMap(run => { if (left <= 0) return []; const piece = {...run, text: run.text.slice(0, left)}; left -= piece.text.length; return [piece]; });
|
|
345
|
+
}
|
|
346
|
+
budget.chars -= paragraph.text.length;
|
|
347
|
+
paragraphs.push(paragraph);
|
|
348
|
+
} else report(emitted >= limits.maxParagraphsPerShape ? 'paragraphs' : 'text');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const dominant = counts => [...counts].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))[0]?.[0];
|
|
352
|
+
const bodyPr = propsOf(findKid(bodyKids, 'a:bodyPr')) ?? {};
|
|
353
|
+
const inheritedBodyPr = key => [bodyPr, layoutPh?.sp?.['p:txBody']?.['a:bodyPr'], masterPh?.sp?.['p:txBody']?.['a:bodyPr']].map(item => item?.[key]).find(value => value !== undefined);
|
|
354
|
+
const fitSource = [bodyPr, layoutPh?.sp?.['p:txBody']?.['a:bodyPr'], masterPh?.sp?.['p:txBody']?.['a:bodyPr']].find(item => item?.['a:normAutofit'] || item?.['a:spAutoFit'] || item?.['a:noAutofit']);
|
|
355
|
+
const autofit = fitSource?.['a:normAutofit'] ? 'shrink' : fitSource?.['a:spAutoFit'] ? 'resize' : fitSource?.['a:noAutofit'] ? 'none' : undefined;
|
|
356
|
+
const anchor = inheritedBodyPr('anchor');
|
|
357
|
+
const total = paragraphNodes.length;
|
|
358
|
+
return {
|
|
359
|
+
paragraphs,
|
|
360
|
+
paragraphCount: total,
|
|
361
|
+
chars,
|
|
362
|
+
maxFontSize: maxSize || undefined,
|
|
363
|
+
dominantFont: dominant(fontCounts),
|
|
364
|
+
dominantSize: dominant(sizeCounts),
|
|
365
|
+
...(monoChars ? {monospaceShare: round(monoChars / Math.max(1, [...fontCounts.values()].reduce((sum, value) => sum + value, 0)), 3)} : {}),
|
|
366
|
+
...(bullets ? {bulletedParagraphs: bullets} : {}),
|
|
367
|
+
...(anchor ? {anchor: {t: 'top', ctr: 'middle', b: 'bottom'}[anchor] ?? anchor} : {}),
|
|
368
|
+
...(autofit ? {autofit} : {}),
|
|
369
|
+
...(inheritedBodyPr('wrap') === 'none' ? {wrap: 'none'} : {}),
|
|
370
|
+
...(inheritedBodyPr('vert') && inheritedBodyPr('vert') !== 'horz' ? {vertical: inheritedBodyPr('vert')} : {}),
|
|
371
|
+
...(number(inheritedBodyPr('numCol')) > 1 ? {columns: number(inheritedBodyPr('numCol'))} : {})
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// Fill and outline
|
|
377
|
+
|
|
378
|
+
function fillSignal(spPr, chain, styleNode, context) {
|
|
379
|
+
const kinds = [['a:noFill', 'none'], ['a:solidFill', 'solid'], ['a:gradFill', 'gradient'], ['a:blipFill', 'picture'], ['a:pattFill', 'pattern'], ['a:grpFill', 'group']];
|
|
380
|
+
for (const owner of [spPr, ...chain]) {
|
|
381
|
+
if (!owner) continue;
|
|
382
|
+
const entry = kinds.find(([key]) => owner[key] !== undefined);
|
|
383
|
+
if (!entry) continue;
|
|
384
|
+
const [key, kind] = entry;
|
|
385
|
+
const fill = {kind, ...(owner === spPr ? {} : {source: 'placeholder'})};
|
|
386
|
+
if (kind === 'solid') Object.assign(fill, resolveColor(owner[key], context) ?? {});
|
|
387
|
+
if (kind === 'gradient') {
|
|
388
|
+
const stops = asArray(owner[key]['a:gsLst']?.['a:gs']).map(stop => ({position: round((number(stop.pos) ?? 0) / 100000, 4), ...(resolveColor(stop, context) ?? {})}));
|
|
389
|
+
if (stops.length) fill.stops = stops.slice(0, 8);
|
|
390
|
+
const angle = number(owner[key]['a:lin']?.ang);
|
|
391
|
+
if (angle !== undefined) fill.angle = round(angle / 60000, 1);
|
|
392
|
+
}
|
|
393
|
+
if (kind === 'pattern') { fill.pattern = owner[key].prst; Object.assign(fill, resolveColor(owner[key]['a:fgClr'], context) ?? {}); }
|
|
394
|
+
return fill;
|
|
395
|
+
}
|
|
396
|
+
const ref = styleNode?.['a:fillRef'];
|
|
397
|
+
const idx = number(ref?.idx);
|
|
398
|
+
if (idx === undefined) return undefined;
|
|
399
|
+
if (idx === 0) return {kind: 'none', source: 'style'};
|
|
400
|
+
return {kind: 'style', source: 'style', styleIndex: idx, ...(resolveColor(ref, context) ?? {})};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function outlineSignal(spPr, chain, styleNode, context) {
|
|
404
|
+
for (const owner of [spPr, ...chain]) {
|
|
405
|
+
const ln = owner?.['a:ln'];
|
|
406
|
+
if (!ln) continue;
|
|
407
|
+
const width = number(ln.w);
|
|
408
|
+
const outline = {kind: ln['a:noFill'] ? 'none' : ln['a:gradFill'] ? 'gradient' : ln['a:solidFill'] ? 'solid' : ln['a:pattFill'] ? 'pattern' : 'inherited', ...(owner === spPr ? {} : {source: 'placeholder'})};
|
|
409
|
+
if (ln['a:solidFill']) Object.assign(outline, resolveColor(ln['a:solidFill'], context) ?? {});
|
|
410
|
+
if (width !== undefined) outline.widthPt = round(width / 12700, 3);
|
|
411
|
+
if (ln['a:prstDash']?.val && ln['a:prstDash'].val !== 'solid') outline.dash = ln['a:prstDash'].val;
|
|
412
|
+
if (ln['a:headEnd']?.type && ln['a:headEnd'].type !== 'none') outline.headArrow = ln['a:headEnd'].type;
|
|
413
|
+
if (ln['a:tailEnd']?.type && ln['a:tailEnd'].type !== 'none') outline.tailArrow = ln['a:tailEnd'].type;
|
|
414
|
+
if (outline.kind !== 'inherited' || width !== undefined) return outline;
|
|
415
|
+
}
|
|
416
|
+
const ref = styleNode?.['a:lnRef'];
|
|
417
|
+
const idx = number(ref?.idx);
|
|
418
|
+
if (idx === undefined) return undefined;
|
|
419
|
+
if (idx === 0) return {kind: 'none', source: 'style'};
|
|
420
|
+
const themed = asArray(context.format?.['a:lnStyleLst']?.['a:ln'])[idx - 1];
|
|
421
|
+
const width = number(themed?.w);
|
|
422
|
+
return {kind: 'style', source: 'style', styleIndex: idx, ...(width !== undefined ? {widthPt: round(width / 12700, 3)} : {}), ...(resolveColor(ref, context) ?? {})};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
// Per shape readers
|
|
427
|
+
|
|
428
|
+
const GRAPHIC_TABLE = 'http://schemas.openxmlformats.org/drawingml/2006/table';
|
|
429
|
+
const GRAPHIC_CHART = 'http://schemas.openxmlformats.org/drawingml/2006/chart';
|
|
430
|
+
const GRAPHIC_DIAGRAM = 'http://schemas.openxmlformats.org/drawingml/2006/diagram';
|
|
431
|
+
|
|
432
|
+
function chartSignal(frameKids, relationships, archive) {
|
|
433
|
+
const graphicData = findKid(kidsOf(findKid(frameKids, 'a:graphic')), 'a:graphicData');
|
|
434
|
+
const chartNode = findKid(kidsOf(graphicData), 'c:chart') ?? findKid(kidsOf(graphicData), 'cx:chart');
|
|
435
|
+
const relId = attrsOf(chartNode)['r:id'];
|
|
436
|
+
const relationship = relId ? relationships.get(relId) : undefined;
|
|
437
|
+
const signal = {extended: Boolean(findKid(kidsOf(graphicData), 'cx:chart'))};
|
|
438
|
+
if (!relationship?.path || !archive.bytes(relationship.path)) return signal;
|
|
439
|
+
signal.part = relationship.path;
|
|
440
|
+
try {
|
|
441
|
+
const space = archive.part(relationship.path, parser);
|
|
442
|
+
const rootNode = space.find(node => Object.hasOwn(node, 'c:chartSpace'));
|
|
443
|
+
const root = propsOf(rootNode) ?? {};
|
|
444
|
+
const plotArea = root['c:chart']?.['c:plotArea'];
|
|
445
|
+
if (plotArea) {
|
|
446
|
+
const types = Object.keys(plotArea).filter(key => /^c:\w+Chart$/.test(key));
|
|
447
|
+
if (types.length) {
|
|
448
|
+
signal.chartTypes = types.map(key => key.slice(2, -5));
|
|
449
|
+
const first = plotArea[types[0]];
|
|
450
|
+
const series = asArray(first?.['c:ser']);
|
|
451
|
+
signal.seriesCount = types.reduce((sum, key) => sum + asArray(plotArea[key]?.['c:ser']).length, 0);
|
|
452
|
+
const points = number(series[0]?.['c:val']?.['c:numRef']?.['c:numCache']?.['c:ptCount']?.val) ?? number(series[0]?.['c:cat']?.['c:strRef']?.['c:strCache']?.['c:ptCount']?.val);
|
|
453
|
+
if (points !== undefined) signal.pointCount = points;
|
|
454
|
+
if (first?.['c:barDir']?.val) signal.barDirection = first['c:barDir'].val;
|
|
455
|
+
if (first?.['c:grouping']?.val) signal.grouping = first['c:grouping'].val;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const titleRuns = [];
|
|
459
|
+
const walk = nodes => { for (const node of nodes ?? []) { if (node['a:t'] !== undefined) titleRuns.push(textOf(node['a:t'])); else walk(kidsOf(node)); } };
|
|
460
|
+
const chartKids = kidsOf(rootNode);
|
|
461
|
+
const titleNode = findKid(kidsOf(findKid(chartKids, 'c:chart')), 'c:title');
|
|
462
|
+
if (titleNode) walk(kidsOf(titleNode));
|
|
463
|
+
if (titleRuns.length) signal.title = clip(titleRuns.join(''), 200);
|
|
464
|
+
} catch { signal.unreadable = true; }
|
|
465
|
+
return signal;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function tableSignal(frameKids, limits, budget, report) {
|
|
469
|
+
const graphicData = findKid(kidsOf(findKid(frameKids, 'a:graphic')), 'a:graphicData');
|
|
470
|
+
const table = findKid(kidsOf(graphicData), 'a:tbl');
|
|
471
|
+
const kids = kidsOf(table);
|
|
472
|
+
const tblPr = attrsOf(findKid(kids, 'a:tblPr'));
|
|
473
|
+
const columns = filterKids(kidsOf(findKid(kids, 'a:tblGrid')), 'a:gridCol').map(node => number(attrsOf(node).w));
|
|
474
|
+
const rows = filterKids(kids, 'a:tr');
|
|
475
|
+
const cells = [];
|
|
476
|
+
let spanned = false, count = 0, clipped = false;
|
|
477
|
+
for (const row of rows) {
|
|
478
|
+
const line = [];
|
|
479
|
+
for (const cell of filterKids(kidsOf(row), 'a:tc')) {
|
|
480
|
+
const attrs = attrsOf(cell);
|
|
481
|
+
if (attrs.gridSpan || attrs.rowSpan || attrs.hMerge || attrs.vMerge) spanned = true;
|
|
482
|
+
let text = '';
|
|
483
|
+
const body = findKid(kidsOf(cell), 'a:txBody');
|
|
484
|
+
const paragraphs = filterKids(kidsOf(body), 'a:p').map(paragraph => kidsOf(paragraph).map(node => {
|
|
485
|
+
const tag = tagOf(node);
|
|
486
|
+
return tag === 'a:r' || tag === 'a:fld' ? textOf(kidsOf(findKid(kidsOf(node), 'a:t'))) : tag === 'a:br' ? '\n' : '';
|
|
487
|
+
}).join(''));
|
|
488
|
+
text = paragraphs.join('\n');
|
|
489
|
+
if (count >= limits.maxTableCells || budget.chars <= 0) { clipped = true; line.push(null); continue; }
|
|
490
|
+
count += 1;
|
|
491
|
+
const room = Math.min(limits.maxTableCellChars, budget.chars);
|
|
492
|
+
if (text.length > room) { text = text.slice(0, room); clipped = true; }
|
|
493
|
+
budget.chars -= text.length;
|
|
494
|
+
line.push(text);
|
|
495
|
+
}
|
|
496
|
+
cells.push(line);
|
|
497
|
+
}
|
|
498
|
+
if (clipped) report('table');
|
|
499
|
+
return {
|
|
500
|
+
rows: rows.length, columns: columns.length,
|
|
501
|
+
...(columns.every(value => value !== undefined) ? {columnWidthsPx: columns.map(value => round(value / EMU_PER_PX))} : {}),
|
|
502
|
+
firstRow: bool(tblPr.firstRow) === true, firstColumn: bool(tblPr.firstCol) === true, bandedRows: bool(tblPr.bandRow) === true,
|
|
503
|
+
...(spanned ? {merged: true} : {}),
|
|
504
|
+
cells: cells.map(line => line.map(value => value ?? '')),
|
|
505
|
+
...(clipped ? {truncated: true} : {})
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function pictureSignal(picProps, picKids, relationships, archive) {
|
|
510
|
+
const blip = picProps['p:blipFill']?.['a:blip'];
|
|
511
|
+
const relId = blip?.['r:embed'];
|
|
512
|
+
const relationship = relId ? relationships.get(relId) : undefined;
|
|
513
|
+
const signal = {};
|
|
514
|
+
if (relationship?.path && archive.bytes(relationship.path)) {
|
|
515
|
+
const bytes = archive.bytes(relationship.path);
|
|
516
|
+
const metadata = rasterMetadata(bytes);
|
|
517
|
+
const extension = relationship.path.toLowerCase().split('.').pop();
|
|
518
|
+
signal.part = relationship.path;
|
|
519
|
+
signal.mediaType = metadata?.mediaType ?? ({png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', emf: 'image/x-emf', wmf: 'image/x-wmf', tif: 'image/tiff', tiff: 'image/tiff', bmp: 'image/bmp'}[extension]);
|
|
520
|
+
signal.bytes = bytes.length;
|
|
521
|
+
if (metadata) signal.naturalPx = {w: metadata.width, h: metadata.height};
|
|
522
|
+
} else if (relId) signal.unresolved = true;
|
|
523
|
+
const crop = picProps['p:blipFill']?.['a:srcRect'];
|
|
524
|
+
if (crop && ['l', 'r', 't', 'b'].some(key => number(crop[key]))) signal.crop = Object.fromEntries(['l', 't', 'r', 'b'].map(key => [key, round((number(crop[key]) ?? 0) / 1000, 2)]));
|
|
525
|
+
if (picProps['p:blipFill']?.['a:tile']) signal.tiled = true;
|
|
526
|
+
return signal;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function mediaKind(nvPr) {
|
|
530
|
+
const kids = kidsOf(nvPr);
|
|
531
|
+
if (findKid(kids, 'a:videoFile')) return 'video';
|
|
532
|
+
if (findKid(kids, 'a:audioFile') || findKid(kids, 'a:wavAudioFile')) return 'audio';
|
|
533
|
+
const ext = findKid(kids, 'p:extLst');
|
|
534
|
+
if (JSON.stringify(ext ?? '').includes('p14:media')) return 'video';
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
// The slide walker
|
|
540
|
+
|
|
541
|
+
/** Indexes matching nativeTextShapes / nativePictures: a level's own elements first, then each group's, depth first. */
|
|
542
|
+
function indexByTag(nodes, tag) {
|
|
543
|
+
const map = new Map();
|
|
544
|
+
const visit = list => {
|
|
545
|
+
for (const node of list ?? []) if (Object.hasOwn(node, tag)) map.set(node, map.size);
|
|
546
|
+
for (const node of list ?? []) if (Object.hasOwn(node, 'p:grpSp')) visit(node['p:grpSp']);
|
|
547
|
+
};
|
|
548
|
+
visit(nodes);
|
|
549
|
+
return map;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function nvOf(node) {
|
|
553
|
+
const kids = kidsOf(node);
|
|
554
|
+
const nv = kids.find(child => (tagOf(child) ?? '').startsWith('p:nv'));
|
|
555
|
+
const kidsOfNv = kidsOf(nv);
|
|
556
|
+
const cNvPr = attrsOf(findKid(kidsOfNv, 'p:cNvPr'));
|
|
557
|
+
const nvPr = findKid(kidsOfNv, 'p:nvPr');
|
|
558
|
+
const ph = findKid(kidsOf(nvPr), 'p:ph');
|
|
559
|
+
const cNvSpPr = attrsOf(findKid(kidsOfNv, 'p:cNvSpPr'));
|
|
560
|
+
return {cNvPr, nvPr, ph: ph ? attrsOf(ph) : undefined, txBox: cNvSpPr.txBox === '1' || cNvSpPr.txBox === 'true'};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function extractSlide(slidePath, index, archive, limits, budget, links, record) {
|
|
564
|
+
const slideTree = archive.part(slidePath, parser);
|
|
565
|
+
const slideNode = slideTree.find(node => Object.hasOwn(node, 'p:sld'));
|
|
566
|
+
const slideKids = kidsOf(slideNode);
|
|
567
|
+
const cSld = findKid(slideKids, 'p:cSld');
|
|
568
|
+
const spTree = findKid(kidsOf(cSld), 'p:spTree');
|
|
569
|
+
const treeKids = kidsOf(spTree);
|
|
570
|
+
const relationships = archive.relationships(slidePath);
|
|
571
|
+
const context = buildContext(slidePath, archive);
|
|
572
|
+
|
|
573
|
+
const slide = {
|
|
574
|
+
index, part: slidePath,
|
|
575
|
+
...(attrsOf(cSld).name ? {name: attrsOf(cSld).name} : {}),
|
|
576
|
+
...(attrsOf(slideNode).show === '0' ? {hidden: true} : {}),
|
|
577
|
+
layout: {part: context.layoutPath ?? null, name: context.layout?.['p:cSld']?.name ?? null, ...(context.layout?.type ? {type: context.layout.type} : {})},
|
|
578
|
+
master: {part: context.masterPath ?? null, name: context.master?.['p:cSld']?.name ?? null, theme: context.themeName ?? null},
|
|
579
|
+
provenance: record?.structure ?? 'untagged',
|
|
580
|
+
shapes: []
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const shapeLimit = Math.min(limits.maxShapesPerSlide, budget.shapes);
|
|
584
|
+
const truncated = {};
|
|
585
|
+
const note = (kind) => { truncated[kind] = (truncated[kind] ?? 0) + 1; };
|
|
586
|
+
const spIndex = indexByTag(treeKids, 'p:sp'), picIndex = indexByTag(treeKids, 'p:pic');
|
|
587
|
+
const frameNodes = filterKids(treeKids, 'p:graphicFrame');
|
|
588
|
+
const alternateNodes = filterKids(treeKids, 'mc:AlternateContent');
|
|
589
|
+
const nodeKey = node => spIndex.has(node) ? `sp:${spIndex.get(node)}` : picIndex.has(node) ? `pic:${picIndex.get(node)}` : undefined;
|
|
590
|
+
|
|
591
|
+
let zOrder = 0;
|
|
592
|
+
const ids = [];
|
|
593
|
+
|
|
594
|
+
const walk = (nodes, parentId, transform, depth, topLevel) => {
|
|
595
|
+
for (const node of nodes ?? []) {
|
|
596
|
+
let tag = tagOf(node), target = node, key;
|
|
597
|
+
if (tag === 'mc:AlternateContent') {
|
|
598
|
+
const alternate = alternateNodes.indexOf(node);
|
|
599
|
+
const choice = findKid(kidsOf(node), 'mc:Choice'), fallback = findKid(kidsOf(node), 'mc:Fallback');
|
|
600
|
+
const inner = kidsOf(choice).find(child => tagOf(child)?.startsWith('p:')) ?? kidsOf(fallback).find(child => tagOf(child)?.startsWith('p:'));
|
|
601
|
+
if (!inner) continue;
|
|
602
|
+
target = inner; tag = tagOf(inner);
|
|
603
|
+
key = alternate >= 0 ? `alt:${alternate}` : undefined;
|
|
604
|
+
} else if (tag === 'p:graphicFrame') {
|
|
605
|
+
const frame = frameNodes.indexOf(node);
|
|
606
|
+
key = topLevel && frame >= 0 ? `frame:${frame}` : undefined;
|
|
607
|
+
} else key = nodeKey(node);
|
|
608
|
+
if (!['p:sp', 'p:cxnSp', 'p:pic', 'p:graphicFrame', 'p:grpSp', 'p:contentPart'].includes(tag)) continue;
|
|
609
|
+
const z = zOrder++;
|
|
610
|
+
const id = `s${z}`;
|
|
611
|
+
if (slide.shapes.length >= shapeLimit) { note('shapes'); if (tag === 'p:grpSp') walkIgnored(kidsOf(target), true); continue; }
|
|
612
|
+
let shape;
|
|
613
|
+
try { shape = readShape(target, tag, id, z, parentId, transform, depth, key, topLevel); }
|
|
614
|
+
catch (error) { shape = {id, zOrder: z, kind: 'unknown', parent: parentId, depth, unreadable: String(error?.message ?? error).slice(0, 200)}; }
|
|
615
|
+
slide.shapes.push(shape);
|
|
616
|
+
ids.push(shape);
|
|
617
|
+
if (tag === 'p:grpSp' && depth + 1 < limits.maxGroupDepth) {
|
|
618
|
+
const xfrm = readXfrm(propsOf(findKid(kidsOf(target), 'p:grpSpPr'))?.['a:xfrm']);
|
|
619
|
+
const inner = xfrm ? childTransform(transform, xfrm) : transform;
|
|
620
|
+
const before = slide.shapes.length;
|
|
621
|
+
walk(kidsOf(target), id, inner, depth + 1, false);
|
|
622
|
+
shape.children = slide.shapes.slice(before).filter(child => child.parent === id).map(child => child.id);
|
|
623
|
+
} else if (tag === 'p:grpSp') { shape.children = []; note('depth'); walkIgnored(kidsOf(target), false); }
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
// Shapes that are not reported still take a z-order slot, so ids of the shapes that are stay stable under a lower limit.
|
|
627
|
+
const walkIgnored = (nodes, counted) => {
|
|
628
|
+
for (const node of nodes ?? []) {
|
|
629
|
+
const tag = tagOf(node);
|
|
630
|
+
if (!['p:sp', 'p:cxnSp', 'p:pic', 'p:graphicFrame', 'p:grpSp', 'p:contentPart', 'mc:AlternateContent'].includes(tag)) continue;
|
|
631
|
+
zOrder += 1;
|
|
632
|
+
if (counted) note('shapes');
|
|
633
|
+
if (tag === 'p:grpSp') walkIgnored(kidsOf(node), counted);
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
const readShape = (node, tag, id, z, parentId, transform, depth, key, topLevel) => {
|
|
638
|
+
const kids = kidsOf(node);
|
|
639
|
+
const props = propsOf(node) ?? {};
|
|
640
|
+
const nv = nvOf(node);
|
|
641
|
+
const shape = {id, zOrder: z, nativeId: number(nv.cNvPr.id), name: nv.cNvPr.name ?? '', kind: 'unknown', parent: parentId ?? null, depth};
|
|
642
|
+
if (nv.cNvPr.descr) shape.alt = clip(nv.cNvPr.descr, 500);
|
|
643
|
+
if (nv.cNvPr.title) shape.title = clip(nv.cNvPr.title, 200);
|
|
644
|
+
if (nv.cNvPr.hidden === '1' || nv.cNvPr.hidden === 'true') shape.hidden = true;
|
|
645
|
+
if (key) shape.nativeKey = key;
|
|
646
|
+
const tagged = Boolean(findKid(kidsOf(nv.nvPr), 'p:custDataLst'));
|
|
647
|
+
if (tagged) shape.opfTagged = true;
|
|
648
|
+
|
|
649
|
+
const spPr = props['p:spPr'] ?? props['p:grpSpPr'];
|
|
650
|
+
let xfrm = readXfrm(tag === 'p:graphicFrame' ? props['p:xfrm'] : spPr?.['a:xfrm']);
|
|
651
|
+
let source = 'own';
|
|
652
|
+
let layoutPh, masterPh;
|
|
653
|
+
if (nv.ph) {
|
|
654
|
+
shape.placeholder = {type: nv.ph.type ?? 'obj', ...(number(nv.ph.idx) !== undefined ? {idx: number(nv.ph.idx)} : {}), ...(nv.ph.sz ? {size: nv.ph.sz} : {}), ...(nv.ph.orient ? {orientation: nv.ph.orient} : {})};
|
|
655
|
+
layoutPh = findPlaceholder(context.layoutPlaceholders, nv.ph);
|
|
656
|
+
masterPh = findPlaceholder(context.masterPlaceholders, layoutPh?.ph ?? nv.ph);
|
|
657
|
+
if (!xfrm) {
|
|
658
|
+
const fromLayout = readXfrm(layoutPh?.sp?.['p:spPr']?.['a:xfrm']);
|
|
659
|
+
const fromMaster = readXfrm(masterPh?.sp?.['p:spPr']?.['a:xfrm']);
|
|
660
|
+
xfrm = fromLayout ?? fromMaster; source = fromLayout ? 'layout' : fromMaster ? 'master' : 'own';
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (xfrm) {
|
|
664
|
+
shape.box = boxOf(xfrm, source === 'own' ? transform : IDENTITY, source);
|
|
665
|
+
if (xfrm.rot) shape.rotation = round(xfrm.rot / 60000, 3);
|
|
666
|
+
if (xfrm.flipH) shape.flipH = true;
|
|
667
|
+
if (xfrm.flipV) shape.flipV = true;
|
|
668
|
+
} else shape.box = null;
|
|
669
|
+
|
|
670
|
+
const chain = [layoutPh?.sp?.['p:spPr'], masterPh?.sp?.['p:spPr']];
|
|
671
|
+
const style = props['p:style'];
|
|
672
|
+
const finishVisuals = () => {
|
|
673
|
+
const fill = fillSignal(spPr, chain, style, context), outline = outlineSignal(spPr, chain, style, context);
|
|
674
|
+
if (fill) shape.fill = fill;
|
|
675
|
+
if (outline) shape.outline = outline;
|
|
676
|
+
const geometry = spPr?.['a:prstGeom']?.prst ?? (spPr?.['a:custGeom'] ? 'custom' : undefined);
|
|
677
|
+
if (geometry) shape.geometry = geometry;
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
if (tag === 'p:grpSp') { shape.kind = 'group'; shape.children = []; return shape; }
|
|
681
|
+
if (tag === 'p:cxnSp') { shape.kind = 'connector'; finishVisuals(); return shape; }
|
|
682
|
+
if (tag === 'p:pic') {
|
|
683
|
+
const media = mediaKind(findKid(kidsOf(kids.find(child => tagOf(child) === 'p:nvPicPr')), 'p:nvPr'));
|
|
684
|
+
shape.kind = media ? 'media' : 'picture';
|
|
685
|
+
if (media) shape.mediaKind = media;
|
|
686
|
+
Object.assign(shape, {picture: pictureSignal(props, kids, relationships, archive)});
|
|
687
|
+
finishVisuals();
|
|
688
|
+
return shape;
|
|
689
|
+
}
|
|
690
|
+
if (tag === 'p:graphicFrame') {
|
|
691
|
+
const uri = attrsOf(findKid(kidsOf(findKid(kids, 'a:graphic')), 'a:graphicData')).uri;
|
|
692
|
+
if (findKid(kidsOf(findKid(kidsOf(findKid(kids, 'a:graphic')), 'a:graphicData')), 'a:tbl')) {
|
|
693
|
+
shape.kind = 'table'; shape.table = tableSignal(kids, limits, budget, note);
|
|
694
|
+
} else if (uri === GRAPHIC_CHART || uri?.includes('chartex') || findKid(kidsOf(findKid(kidsOf(findKid(kids, 'a:graphic')), 'a:graphicData')), 'c:chart')) {
|
|
695
|
+
shape.kind = 'chart'; shape.chart = chartSignal(kids, relationships, archive);
|
|
696
|
+
} else if (uri === GRAPHIC_DIAGRAM) shape.kind = 'smartart';
|
|
697
|
+
else if (findKid(kidsOf(findKid(kidsOf(findKid(kids, 'a:graphic')), 'a:graphicData')), 'p:oleObj')) shape.kind = 'ole';
|
|
698
|
+
else { shape.kind = 'unknown'; if (uri) shape.graphicType = clip(uri, 120); }
|
|
699
|
+
return shape;
|
|
700
|
+
}
|
|
701
|
+
if (tag === 'p:contentPart') { shape.kind = 'unknown'; return shape; }
|
|
702
|
+
|
|
703
|
+
// p:sp
|
|
704
|
+
const txBody = findKid(kids, 'p:txBody');
|
|
705
|
+
const info = {ph: nv.ph};
|
|
706
|
+
const text = txBody ? readTextBody(txBody, info, context, relationships, limits, budget, note) : undefined;
|
|
707
|
+
const geometry = spPr?.['a:prstGeom']?.prst;
|
|
708
|
+
const hasText = text && text.chars > 0;
|
|
709
|
+
if (hasText || nv.txBox) shape.kind = 'text';
|
|
710
|
+
else if (nv.ph) shape.kind = 'placeholder';
|
|
711
|
+
else if (geometry === 'line' || geometry === 'straightConnector1') shape.kind = 'line';
|
|
712
|
+
else shape.kind = 'shape';
|
|
713
|
+
if (text) shape.text = text;
|
|
714
|
+
finishVisuals();
|
|
715
|
+
return shape;
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
walk(treeKids, null, IDENTITY, 0, true);
|
|
719
|
+
|
|
720
|
+
// Links to the imported OPF, then groups: a group links where all its members do.
|
|
721
|
+
for (const shape of slide.shapes) {
|
|
722
|
+
const link = shape.nativeKey ? links.get(shape.nativeKey) : undefined;
|
|
723
|
+
shape.opf = link ?? null;
|
|
724
|
+
delete shape.nativeKey;
|
|
725
|
+
}
|
|
726
|
+
const byId = new Map(slide.shapes.map(shape => [shape.id, shape]));
|
|
727
|
+
const settle = shape => {
|
|
728
|
+
if (shape.kind !== 'group' || shape.opf) return shape.opf;
|
|
729
|
+
const members = (shape.children ?? []).map(id => settle(byId.get(id)) ?? null);
|
|
730
|
+
const first = members[0];
|
|
731
|
+
if (first && first.path && members.every(member => member && member.path === first.path)) shape.opf = {...first};
|
|
732
|
+
return shape.opf;
|
|
733
|
+
};
|
|
734
|
+
for (const shape of slide.shapes) if (shape.kind === 'group') settle(shape);
|
|
735
|
+
|
|
736
|
+
budget.shapes -= slide.shapes.length;
|
|
737
|
+
slide.stats = {
|
|
738
|
+
shapes: slide.shapes.length,
|
|
739
|
+
text: slide.shapes.filter(shape => shape.kind === 'text').length,
|
|
740
|
+
pictures: slide.shapes.filter(shape => shape.kind === 'picture').length,
|
|
741
|
+
tables: slide.shapes.filter(shape => shape.kind === 'table').length,
|
|
742
|
+
charts: slide.shapes.filter(shape => shape.kind === 'chart').length,
|
|
743
|
+
groups: slide.shapes.filter(shape => shape.kind === 'group').length
|
|
744
|
+
};
|
|
745
|
+
if (Object.keys(truncated).length) slide.truncated = truncated;
|
|
746
|
+
return slide;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
// Links from native shapes to the imported OPF
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Resolve a slide's recorded sources (see importSlide) to OPF paths.
|
|
754
|
+
* `contentPaths` maps a flat imported block index to its path under the slide
|
|
755
|
+
* when document provenance rebuilt the authored structure.
|
|
756
|
+
*/
|
|
757
|
+
export function resolveSlideLinks(slideIndex, recorded, contentPaths) {
|
|
758
|
+
const links = new Map();
|
|
759
|
+
if (!recorded) return links;
|
|
760
|
+
const put = (key, link) => { if (!links.has(key)) links.set(key, link); };
|
|
761
|
+
for (const [key, role] of recorded.roles ?? []) put(key, {role});
|
|
762
|
+
for (const role of ['tag', 'title', 'subtitle']) for (const key of recorded[role] ?? []) links.set(key, {role, path: `slides.${slideIndex}.${role}`});
|
|
763
|
+
(recorded.blocks ?? []).forEach((block, index) => {
|
|
764
|
+
const relative = contentPaths?.get(index) ?? `blocks.${index}`;
|
|
765
|
+
for (const key of block.sources ?? []) links.set(key, {role: 'block', path: `slides.${slideIndex}.${relative}`, blockType: block.type});
|
|
766
|
+
});
|
|
767
|
+
return links;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ---------------------------------------------------------------------------
|
|
771
|
+
// Deck
|
|
772
|
+
|
|
773
|
+
export function extractSignals({archive, presentationRoot, slidePaths, themeFacts, limits, recorded, slideProvenance, contentPaths}) {
|
|
774
|
+
const budget = {chars: limits.maxTextCharsTotal, shapes: limits.maxTotalShapes};
|
|
775
|
+
const slides = [];
|
|
776
|
+
const reported = slidePaths.slice(0, limits.maxSlides);
|
|
777
|
+
for (const [index, path] of reported.entries()) {
|
|
778
|
+
const links = resolveSlideLinks(index, recorded[index], contentPaths?.[index]);
|
|
779
|
+
slides.push(extractSlide(path, index, archive, limits, budget, links, slideProvenance?.[index]));
|
|
780
|
+
}
|
|
781
|
+
const cx = number(presentationRoot['p:sldSz']?.cx), cy = number(presentationRoot['p:sldSz']?.cy);
|
|
782
|
+
const tagged = (slideProvenance ?? []).filter(entry => entry && entry.structure !== 'untagged').length;
|
|
783
|
+
const deck = {
|
|
784
|
+
slideCount: slidePaths.length,
|
|
785
|
+
...(cx && cy ? {dimensions: {widthEmu: cx, heightEmu: cy, widthPx: round(cx / EMU_PER_PX), heightPx: round(cy / EMU_PER_PX)}} : {}),
|
|
786
|
+
referencePxPerInch: 96,
|
|
787
|
+
theme: themeFacts,
|
|
788
|
+
provenance: {opfTaggedSlides: tagged, opf: tagged > 0}
|
|
789
|
+
};
|
|
790
|
+
const truncated = {};
|
|
791
|
+
if (slidePaths.length > reported.length) truncated.slides = slidePaths.length - reported.length;
|
|
792
|
+
if (budget.chars <= 0) truncated.text = true;
|
|
793
|
+
const signals = {version: SIGNALS_VERSION, deck, limits: {...limits}, slides, ...(Object.keys(truncated).length ? {truncated} : {})};
|
|
794
|
+
// Canonical JSON: drops undefined keys and guarantees the value is serializable.
|
|
795
|
+
return JSON.parse(JSON.stringify(signals));
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** Theme facts for the deck block, from the first slide's resolved theme (name, fonts, twelve colours). */
|
|
799
|
+
export function themeFactsFor(slidePath, archive) {
|
|
800
|
+
const theme = readSlideTheme(slidePath, archive);
|
|
801
|
+
const {colors} = theme.colors ? readThemeSlotColors(theme.colors) : {colors: {}};
|
|
802
|
+
const latin = key => theme.fonts?.[key]?.['a:latin']?.typeface || undefined;
|
|
803
|
+
return {
|
|
804
|
+
...(theme.colors?.name ? {colorSchemeName: theme.colors.name} : {}),
|
|
805
|
+
...(theme.fonts?.name ? {fontSchemeName: theme.fonts.name} : {}),
|
|
806
|
+
majorFont: latin('a:majorFont'), minorFont: latin('a:minorFont'),
|
|
807
|
+
colors: Object.fromEntries(Object.entries(colors).map(([slot, hex]) => [slot, `#${hex}`]))
|
|
808
|
+
};
|
|
809
|
+
}
|