@openpresentation/opf-pptx 0.12.4 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,385 @@
1
+ import {XMLParser} from 'fast-xml-parser';
2
+ import {validatePresentation} from '@openpresentation/opf';
3
+ import {decodeTextTag, encodeTextTag} from './code-provenance.js';
4
+ import {chartUsesDataFields, isDatasetRef, numberFormatFromExcel, resolveTableData, tableCellDisplayValue, tableUsesDataFields} from './chart-data.js';
5
+
6
+ // RR-54: chart and table data survive a PPTX round trip (core docs/chart-table-data.md).
7
+ //
8
+ // A native chart holds plotted values and a number format code per series; a native table holds cell text. Neither has a
9
+ // field for what the OPF author wrote: the top-level `datasets`, a chart's or table's dataset reference (`{dataset,
10
+ // fields}`), `chart.mapping`, DataColumn objects, `data.source`, or a table cell's number value behind its formatted text.
11
+ // In `full` provenance mode the exporter records them as PresentationML customer-data tags, the mechanism of the other
12
+ // OPF records (uppercase-hex UTF-8 JSON, because PowerPoint's Tags API is case-insensitive):
13
+ //
14
+ // OPF_DATASETS_V1 on p:presentation (beside OPF_DOCUMENT_V1): the whole `datasets` map, unused datasets included.
15
+ // OPF_DATA_V1 on each chart or table graphic frame that uses one of the fields above:
16
+ // chart {v: 1, kind: 'chart', data, mapping?, dataset?: hash, evidence: [hash]} `data` is the authored
17
+ // chart.data (a dataset reference, or inline columns with DataColumn objects and `source`); `evidence`
18
+ // hashes the native caches (series names, categories, values and format codes) of the chart part(s) the frame
19
+ // shows (the chartex part and its classic fallback); `dataset` hashes the referenced dataset, so a slide
20
+ // pasted into a deck whose dataset of the same id holds other data keeps its values inline.
21
+ // table {v: 1, kind: 'table', table} a dataset-backed table, as authored
22
+ // {v: 1, kind: 'table', headers: [[column, header, text]], cells: [[row, column, value, format, text]]}
23
+ // an inline table: the DataColumn or formatted
24
+ // headers and every number cell shown through a format, each with the text the native cell shows.
25
+ //
26
+ // Import restores a record only while its native evidence is unchanged: a chart while its caches hash the same (an edit in
27
+ // PowerPoint's Edit Data changes them), a dataset table while every native cell still shows the dataset's display text,
28
+ // and an inline table cell by cell while the native cell shows the recorded text. Otherwise the native values stay (with
29
+ // the format codes core maps back, src/chart-data.js) and a diagnostic names what was not restored. Tags are untrusted
30
+ // input: they are size-limited (16 MiB, the limit of the document tag), depth- and shape-checked, validated as OPF, and
31
+ // never executed; a record that fails a check, or that a restore cannot use, is reported and the native values stay.
32
+
33
+ export const DATA_TAG = 'OPF_DATA_V1';
34
+ export const DATASETS_TAG = 'OPF_DATASETS_V1';
35
+ const REL_TAGS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
36
+ const NS = 'http://schemas.openxmlformats.org/presentationml/2006/main';
37
+ const TAGS_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.tags+xml';
38
+ const MAX_TAG_CHARS = 16 * 1024 * 1024;
39
+ // Datasets and records nest a few levels (datasets, id, rows, row, cell; a table cell's runs and style). A deeper value is
40
+ // not one the exporter writes, and is refused before anything recurses into it.
41
+ const MAX_DEPTH = 32;
42
+
43
+ const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal: true});
44
+ const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, trimValues: false});
45
+ const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
46
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
47
+ const own = (value, key) => object(value) && Object.hasOwn(value, key);
48
+ const clone = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
49
+ const index = value => Number.isSafeInteger(value) && value >= 0;
50
+ // Key-sorted JSON, so the dataset hash does not depend on key order.
51
+ const canonical = value => JSON.stringify(value, (_key, item) => object(item) ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
52
+ function tooDeep(value) {
53
+ const stack = [[value, 0]];
54
+ while (stack.length) {
55
+ const [item, depth] = stack.pop();
56
+ if (item === null || typeof item !== 'object') continue;
57
+ if (depth >= MAX_DEPTH) return true;
58
+ for (const child of Object.values(item)) stack.push([child, depth + 1]);
59
+ }
60
+ return false;
61
+ }
62
+
63
+ /** The text a table cell value shows: strings and numbers as written, rich runs joined, null and absent as ''. */
64
+ export function cellText(value) {
65
+ if (value === null || value === undefined) return '';
66
+ if (Array.isArray(value)) return value.map(run => typeof run === 'string' ? run : object(run) ? String(run.text ?? '') : '').join('');
67
+ if (object(value)) return own(value, 'value') ? cellText(value.value) : typeof value.name === 'string' ? value.name : '';
68
+ return String(value);
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Native evidence
73
+
74
+ const ENTITIES = {amp: '&', lt: '<', gt: '>', quot: '"', apos: "'"};
75
+ const decodeEntities = text => text.replace(/&(?:#(\d+)|#x([0-9a-f]+)|(amp|lt|gt|quot|apos));/gi, (_match, decimal, hex, name) => decimal ? String.fromCodePoint(Number(decimal)) : hex ? String.fromCodePoint(parseInt(hex, 16)) : ENTITIES[name.toLowerCase()]);
76
+ const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
77
+ // A cached number in Excel's 15 significant digits, so a resave that rewrites 0.30000000000000004 as 0.3 keeps the evidence.
78
+ const canonicalValue = text => NUMBER.test(text.trim()) && Number.isFinite(Number(text)) ? String(Number(Number(text).toPrecision(15))) : text;
79
+ // A General format code is left out: on a plain save PowerPoint writes <c:formatCode>General</c:formatCode> into every
80
+ // cache that has none (PptxGenJS writes none on pie and doughnut series), so General and no code are the same evidence.
81
+ const generalCode = code => /^\s*general\s*$/i.test(code);
82
+ // A format code is hashed in core's canonical form (numberFormatFromExcel), because a plain PowerPoint save re-spells codes
83
+ // without changing what they show: `$#,##0.0` becomes `\$#,##0.0` and `#,##0 "units"` becomes `#,##0\ "units"` (native
84
+ // check opf#385). Core reads quoted and backslash-escaped literals and literal-safe characters to the same NumberFormat, so
85
+ // both spellings hash equal, while a real change (another number of decimals, another unit) still changes the hash. A code
86
+ // core cannot map is hashed as written; the `opf:` prefix keeps a mapped form apart from a verbatim code of the same text.
87
+ const formatEvidence = code => {
88
+ const format = numberFormatFromExcel(code);
89
+ return typeof format === 'string' ? `format:opf:${format}` : `format:${code}`;
90
+ };
91
+ function hash(text) {
92
+ let h1 = 0xdeadbeef, h2 = 0x41c6ce57;
93
+ for (let index = 0; index < text.length; index++) {
94
+ const code = text.charCodeAt(index);
95
+ h1 = Math.imul(h1 ^ code, 2654435761);
96
+ h2 = Math.imul(h2 ^ code, 1597334677);
97
+ }
98
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
99
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
100
+ return (h2 >>> 0).toString(16).padStart(8, '0') + (h1 >>> 0).toString(16).padStart(8, '0');
101
+ }
102
+
103
+ /** The hash of a chart part's cached data: series names, categories, values and number format codes, in order. */
104
+ export function chartEvidence(xml) {
105
+ const parts = [];
106
+ for (const match of String(xml).matchAll(/<(c:v|c:formatCode|cx:pt|cx:v)\b[^>]*>([^<]*)<\/\1>|<cx:lvl\b[^>]*?\bformatCode="([^"]*)"/g)) {
107
+ const code = match[3] !== undefined ? decodeEntities(match[3]) : match[1] === 'c:formatCode' ? decodeEntities(match[2]) : undefined;
108
+ if (code === undefined) parts.push(`${match[1]}:${canonicalValue(decodeEntities(match[2]))}`);
109
+ else if (!generalCode(code)) parts.push(formatEvidence(code));
110
+ }
111
+ return hash(parts.join('\u0000'));
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Export
116
+
117
+ /** The hash of a dataset, or undefined when the map does not hold the id. */
118
+ const datasetHash = (datasets, id) => own(datasets, id) ? hash(canonical(datasets[id])) : undefined;
119
+
120
+ /** The record of a chart that uses an RR-54 data field, or undefined (the frame then carries no tag). */
121
+ export function chartDataRecord(chart, datasets) {
122
+ if (!chartUsesDataFields(chart)) return undefined;
123
+ const dataset = isDatasetRef(chart.data) ? datasetHash(datasets, chart.data.dataset) : undefined;
124
+ return {v: 1, kind: 'chart', data: clone(chart.data), ...(chart.mapping !== undefined ? {mapping: clone(chart.mapping)} : {}), ...(dataset ? {dataset} : {})};
125
+ }
126
+
127
+ /** The record of a table that is dataset-backed or formats numbers, or undefined. `layout` is the exported table layout. */
128
+ export function tableDataRecord(table, layout) {
129
+ if (!tableUsesDataFields(table)) return undefined;
130
+ if (isDatasetRef(table)) return {v: 1, kind: 'table', table: clone(table)};
131
+ const hasHeaders = Array.isArray(table.columns) && table.columns.length > 0;
132
+ const shown = (row, column) => cellText(layout?.rows?.[row]?.cells?.find(cell => cell.column === column)?.value);
133
+ // A header's format: a DataColumn's or a header StyledTableCell's (both are the column format).
134
+ const formatOf = value => object(value) && typeof value.format === 'string' ? value.format : undefined;
135
+ const headers = [], cells = [];
136
+ const columnFormats = [];
137
+ (hasHeaders ? table.columns : []).forEach((header, column) => {
138
+ const format = formatOf(header);
139
+ columnFormats[column] = format;
140
+ const dataColumn = object(header) && typeof header.name === 'string' && !own(header, 'value');
141
+ if (dataColumn || format !== undefined) headers.push([column, clone(header), shown(0, column)]);
142
+ });
143
+ (table.rows ?? []).forEach((row, rowIndex) => {
144
+ if (!Array.isArray(row)) return;
145
+ row.forEach((cell, column) => {
146
+ const value = own(cell, 'value') ? cell.value : cell;
147
+ const format = own(cell, 'value') && typeof cell.format === 'string' ? cell.format : undefined;
148
+ if (typeof value !== 'number' || (format === undefined && columnFormats[column] === undefined)) return;
149
+ cells.push([rowIndex, column, value, format ?? null, shown(rowIndex + (hasHeaders ? 1 : 0), column)]);
150
+ });
151
+ });
152
+ return {v: 1, kind: 'table', headers, cells};
153
+ }
154
+
155
+ function relationshipsXml(entries, path) {
156
+ const slash = path.lastIndexOf('/');
157
+ return `${path.slice(0, slash + 1)}_rels/${path.slice(slash + 1)}.rels`;
158
+ }
159
+ const freeId = (rels, base) => { let id = base, index = 1; while (rels.includes(`Id="${id}"`)) id = `${base}${++index}`; return id; };
160
+ const tagPart = (name, value) => enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${name}" val="${encodeTextTag(value)}"/></p:tagLst>`);
161
+
162
+ /**
163
+ * Attach the data records to the finished package (`entries`: part path -> bytes, changed in place): one OPF_DATA_V1 tag
164
+ * per recorded chart or table frame (both frames of a chartex chart share it) and OPF_DATASETS_V1 beside the document
165
+ * tag. `records` maps generated frame names to records; `datasets` is the document's datasets map.
166
+ */
167
+ export function attachDataProvenance(entries, {records, paths = new Map(), datasets, parseRelationships, report}) {
168
+ // A record the importer would refuse (MAX_TAG_CHARS) is not written; the native values still export and import. `paths`
169
+ // maps a frame name to the document path of its chart or table, which the diagnostic names.
170
+ const fits = (value, path) => {
171
+ if (enc.encode(JSON.stringify(value)).byteLength * 2 <= MAX_TAG_CHARS) return true;
172
+ report?.({code: 'data-provenance-omitted', path, message: `The recorded ${path === 'datasets' ? 'datasets map' : 'chart or table data'} exceeds the ${MAX_TAG_CHARS / (1024 * 1024)} MiB tag limit, so it is not stored; the native values export and re-import, without the dataset references, mapping and formats.`});
173
+ return false;
174
+ };
175
+ for (const [name, record] of [...records]) if (!fits(record, paths.get(name) ?? name)) records.delete(name);
176
+ const storedDatasets = object(datasets) && Object.keys(datasets).length && fits(datasets, 'datasets') ? datasets : undefined;
177
+ if (!records.size && !storedDatasets) return;
178
+ const types = [];
179
+ const slides = Object.keys(entries).filter(path => /^ppt\/slides\/slide\d+\.xml$/.test(path)).sort((a, b) => Number(/(\d+)\.xml$/.exec(a)[1]) - Number(/(\d+)\.xml$/.exec(b)[1]));
180
+ let count = 0;
181
+ const seen = new Set();
182
+ for (const path of slides) {
183
+ let xml = dec.decode(entries[path]);
184
+ const frames = [...xml.matchAll(/<p:graphicFrame>[\s\S]*?<\/p:graphicFrame>/g)].map(match => match[0]);
185
+ const named = frames.map(frame => frame.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1]).filter(name => records.has(name));
186
+ if (!named.length) continue;
187
+ const relationships = parseRelationships(entries, path);
188
+ // Evidence first: every chart part a frame of that name shows (a chartex choice and its classic fallback).
189
+ const evidence = new Map();
190
+ for (const frame of frames) {
191
+ const name = frame.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
192
+ const record = records.get(name);
193
+ if (record?.kind !== 'chart') continue;
194
+ const id = frame.match(/<(?:c|cx):chart\b[^>]*\br:id="([^"]+)"/)?.[1];
195
+ const part = relationships.get(id)?.path;
196
+ if (!part || !entries[part]) throw Error('Generated chart frame has no chart part.');
197
+ evidence.set(name, [...(evidence.get(name) ?? []), chartEvidence(dec.decode(entries[part]))]);
198
+ }
199
+ const relsPath = relationshipsXml(entries, path);
200
+ let rels = dec.decode(entries[relsPath]);
201
+ const ids = new Map();
202
+ for (const name of new Set(named)) {
203
+ if (seen.has(name)) throw Error(`Duplicate generated frame name ${name}.`);
204
+ seen.add(name);
205
+ const record = records.get(name);
206
+ const value = record.kind === 'chart' ? {...record, evidence: evidence.get(name) ?? []} : record;
207
+ const part = `ppt/tags/opfData${++count}.xml`;
208
+ if (entries[part]) throw Error('Colliding data tag part.');
209
+ entries[part] = tagPart(DATA_TAG, value);
210
+ types.push(part);
211
+ const id = freeId(rels, `rIdOpfData${count}`);
212
+ rels = rels.replace('</Relationships>', `<Relationship Id="${id}" Type="${REL_TAGS}" Target="../tags/opfData${count}.xml"/></Relationships>`);
213
+ ids.set(name, id);
214
+ }
215
+ xml = xml.replace(/<p:graphicFrame>[\s\S]*?<\/p:graphicFrame>/g, frame => {
216
+ const id = ids.get(frame.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1]);
217
+ if (!id) return frame;
218
+ const list = `<p:custDataLst><p:tags r:id="${id}"/></p:custDataLst>`;
219
+ // CT_ApplicationNonVisualDrawingProps: custDataLst precedes extLst.
220
+ return frame.replace(/<p:nvPr\s*\/>|<p:nvPr>([\s\S]*?)<\/p:nvPr>/, (_match, inner = '') => {
221
+ if (/<p:custDataLst\b/.test(inner)) throw Error('Generated frame already has customer data.');
222
+ const at = inner.search(/<p:extLst\b/);
223
+ return `<p:nvPr>${at < 0 ? inner + list : inner.slice(0, at) + list + inner.slice(at)}</p:nvPr>`;
224
+ });
225
+ });
226
+ entries[path] = enc.encode(xml);
227
+ entries[relsPath] = enc.encode(rels);
228
+ }
229
+ if (seen.size !== records.size) throw Error('A recorded chart or table frame is missing from the package.');
230
+ if (storedDatasets) {
231
+ const presentationXml = dec.decode(entries['ppt/presentation.xml']);
232
+ const tag = `<p:tag name="${DATASETS_TAG}" val="${encodeTextTag(storedDatasets)}"/>`;
233
+ const existing = /<p:custDataLst>\s*<p:tags r:id="([^"]+)"\s*\/>\s*<\/p:custDataLst>/.exec(presentationXml);
234
+ if (existing) {
235
+ // Join the document tag list (OPF_DOCUMENT_V1): CT_CustomerDataList holds one p:tags per element.
236
+ const target = parseRelationships(entries, 'ppt/presentation.xml').get(existing[1]);
237
+ if (target?.type !== REL_TAGS || !target.path || !entries[target.path]) throw Error('Presentation tag relationship is missing.');
238
+ entries[target.path] = enc.encode(dec.decode(entries[target.path]).replace('</p:tagLst>', `${tag}</p:tagLst>`));
239
+ } else {
240
+ if (/<p:custDataLst\b/.test(presentationXml) || entries['ppt/tags/opfDatasets.xml']) throw Error('Colliding presentation customer data.');
241
+ let presentationRels = dec.decode(entries['ppt/_rels/presentation.xml.rels']);
242
+ const id = freeId(presentationRels, 'rIdOpfDatasets');
243
+ // CT_Presentation: custDataLst precedes kinsoku, defaultTextStyle, modifyVerifier and extLst.
244
+ const anchor = presentationXml.search(/<p:(kinsoku|defaultTextStyle|modifyVerifier|extLst)\b|<\/p:presentation>/);
245
+ if (anchor < 0) throw Error('Generated presentation part has no closing element.');
246
+ entries['ppt/presentation.xml'] = enc.encode(`${presentationXml.slice(0, anchor)}<p:custDataLst><p:tags r:id="${id}"/></p:custDataLst>${presentationXml.slice(anchor)}`);
247
+ presentationRels = presentationRels.replace('</Relationships>', `<Relationship Id="${id}" Type="${REL_TAGS}" Target="tags/opfDatasets.xml"/></Relationships>`);
248
+ entries['ppt/_rels/presentation.xml.rels'] = enc.encode(presentationRels);
249
+ entries['ppt/tags/opfDatasets.xml'] = enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}">${tag}</p:tagLst>`);
250
+ types.push('ppt/tags/opfDatasets.xml');
251
+ }
252
+ }
253
+ if (types.length) {
254
+ const contentTypes = dec.decode(entries['[Content_Types].xml']);
255
+ entries['[Content_Types].xml'] = enc.encode(contentTypes.replace('</Types>', types.map(part => `<Override PartName="/${part}" ContentType="${TAGS_TYPE}"/>`).join('') + '</Types>'));
256
+ }
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Import
261
+
262
+ function readTags(entries, container, relationships, name) {
263
+ const found = [];
264
+ for (const link of array(container?.['p:tags'])) {
265
+ const rel = relationships.get(link?.['r:id']);
266
+ if (rel?.type !== REL_TAGS || rel.targetMode === 'External' || !rel.path || !entries[rel.path]) continue;
267
+ found.push(...array(parser.parse(dec.decode(entries[rel.path]))?.['p:tagLst']?.['p:tag']).filter(tag => String(tag?.name ?? '').toUpperCase() === name));
268
+ }
269
+ if (!found.length) return undefined;
270
+ if (found.length > 1) throw Error(`Multiple ${name} tags.`);
271
+ const value = found[0].val;
272
+ if (typeof value !== 'string' || value.length > MAX_TAG_CHARS) throw Error(`Oversized or empty ${name} tag.`);
273
+ const decoded = decodeTextTag(value);
274
+ if (tooDeep(decoded)) throw Error(`The ${name} tag nests deeper than ${MAX_DEPTH} levels.`);
275
+ return decoded;
276
+ }
277
+
278
+ /** The stored datasets map, or undefined. An unreadable or invalid record is reported and ignored. */
279
+ export function readDatasetsTag(entries, presentationRoot, relationships, report) {
280
+ try {
281
+ const datasets = readTags(entries, presentationRoot?.['p:custDataLst'], relationships, DATASETS_TAG);
282
+ if (datasets === undefined) return undefined;
283
+ if (!object(datasets) || !Object.keys(datasets).length) throw Error('Invalid datasets record.');
284
+ const result = validatePresentation({slides: [{title: 'Datasets'}], datasets});
285
+ if (!result.valid) throw Error(`The stored datasets do not validate (${result.errors[0]?.path ?? ''} ${result.errors[0]?.message ?? ''}).`);
286
+ return datasets;
287
+ } catch (error) {
288
+ report?.({code: 'invalid-data-provenance', path: 'datasets', message: `${error instanceof Error ? error.message : String(error)} The datasets recorded at export were not restored.`});
289
+ return undefined;
290
+ }
291
+ }
292
+
293
+ /** The OPF_DATA_V1 record of a graphic frame (parsed), or undefined. Throws on a malformed record. */
294
+ export function readDataTag(entries, frame, relationships) {
295
+ const record = readTags(entries, frame?.['p:nvGraphicFramePr']?.['p:nvPr']?.['p:custDataLst'], relationships, DATA_TAG);
296
+ if (record === undefined) return undefined;
297
+ if (!object(record) || record.v !== 1 || !['chart', 'table'].includes(record.kind)) throw Error('Unsupported data provenance version.');
298
+ if (record.kind === 'chart' && (!object(record.data) || !Array.isArray(record.evidence) || !record.evidence.every(item => typeof item === 'string'))) throw Error('Invalid chart data record.');
299
+ if (record.kind === 'chart' && record.dataset !== undefined && typeof record.dataset !== 'string') throw Error('Invalid chart data record.');
300
+ if (record.kind === 'table' && !(object(record.table) || (Array.isArray(record.headers) && Array.isArray(record.cells)))) throw Error('Invalid table data record.');
301
+ // Inline table entries are [column, header, text] and [row, column, value, format, text]; their indices are array
302
+ // indices (non-negative integers), never a key such as "__proto__" or "length".
303
+ if (record.kind === 'table' && !object(record.table)) {
304
+ const header = entry => Array.isArray(entry) && entry.length === 3 && index(entry[0]) && object(entry[1]) && typeof entry[2] === 'string';
305
+ const cell = entry => Array.isArray(entry) && entry.length === 5 && index(entry[0]) && index(entry[1]) && typeof entry[2] === 'number' && Number.isFinite(entry[2])
306
+ && (entry[3] === null || typeof entry[3] === 'string') && typeof entry[4] === 'string';
307
+ if (!record.headers.every(header) || !record.cells.every(cell)) throw Error('Invalid table data record.');
308
+ }
309
+ return record;
310
+ }
311
+
312
+ const validWith = (payload, datasets) => validatePresentation({slides: [{title: 'Data', ...payload}], ...(datasets ? {datasets} : {})}).valid;
313
+
314
+ /**
315
+ * The authored chart data while the native caches are unchanged: the imported chart (type and options) with the recorded
316
+ * `data` and `mapping`. Otherwise the imported chart, and a diagnostic.
317
+ */
318
+ export function restoreChartData(chart, record, evidence, {datasets, report}) {
319
+ if (!record.evidence.includes(evidence)) {
320
+ report({code: 'chart-data-provenance-changed', message: 'The chart\'s cached data or number formats changed after export (for example in Edit Data), so the dataset reference, column mapping, column formats and data source recorded at export were not restored; the chart imports the values and formats it now shows.'});
321
+ return chart;
322
+ }
323
+ if (isDatasetRef(record.data) && !own(datasets, record.data.dataset)) {
324
+ report({code: 'chart-dataset-unavailable', message: `The chart referenced dataset '${record.data.dataset}', which this package's datasets record does not hold (a slide pasted from another deck, or a missing record); the chart imports its values inline.`});
325
+ return chart;
326
+ }
327
+ if (isDatasetRef(record.data) && record.dataset !== undefined && datasetHash(datasets, record.data.dataset) !== record.dataset) {
328
+ report({code: 'chart-dataset-unavailable', message: `The chart referenced dataset '${record.data.dataset}', but this package's dataset of that id holds other data than at export (a slide pasted from another deck); the chart imports its values inline.`});
329
+ return chart;
330
+ }
331
+ const {mapping: _mapping, ...rest} = chart;
332
+ const restored = {...rest, data: clone(record.data), ...(record.mapping !== undefined ? {mapping: clone(record.mapping)} : {})};
333
+ if (!validWith({chart: restored}, isDatasetRef(record.data) ? datasets : undefined)) {
334
+ report({code: 'invalid-data-provenance', message: 'The chart data recorded at export does not form a valid chart; the chart imports the values it shows.'});
335
+ return chart;
336
+ }
337
+ return restored;
338
+ }
339
+
340
+ /** The authored table data where the native cells still show the recorded text; the imported table otherwise. */
341
+ export function restoreTableData(table, record, {datasets, report}) {
342
+ if (!object(table)) return table;
343
+ const columns = Array.isArray(table.columns) ? table.columns : undefined;
344
+ const rows = Array.isArray(table.rows) ? table.rows : [];
345
+ if (record.table) {
346
+ const id = record.table.dataset;
347
+ if (!own(datasets, id)) {
348
+ report({code: 'table-dataset-unavailable', message: `The table referenced dataset '${id}', which this package's datasets record does not hold; the table imports its cells inline.`});
349
+ return table;
350
+ }
351
+ const resolved = resolveTableData(record.table, {datasets});
352
+ const expectedHeaders = (resolved.columns ?? []).map(cellText);
353
+ const expectedRows = resolved.rows.map(row => row.map((cell, column) => cellText(tableCellDisplayValue(cell, resolved.formats[column]))));
354
+ const same = expectedHeaders.length === (columns?.length ?? 0) && expectedHeaders.every((text, index) => text === cellText(columns[index]))
355
+ && expectedRows.length === rows.length && expectedRows.every((row, index) => row.length === rows[index].length && row.every((text, column) => text === cellText(rows[index][column])));
356
+ if (!same || !validWith({table: record.table}, datasets)) {
357
+ report({code: 'table-data-provenance-changed', message: `The table's cells no longer show dataset '${id}' as exported, so the dataset reference was not restored; the table imports the cells it shows, inline.`});
358
+ return table;
359
+ }
360
+ return clone(record.table);
361
+ }
362
+ const next = {...table, ...(columns ? {columns: [...columns]} : {}), rows: rows.map(row => Array.isArray(row) ? [...row] : row)};
363
+ let changed = 0;
364
+ for (const entry of record.headers) {
365
+ const [column, header, text] = Array.isArray(entry) ? entry : [];
366
+ const current = next.columns?.[column];
367
+ if (current === undefined || cellText(current) !== text) { changed++; continue; }
368
+ // A DataColumn returns as authored; a formatted header cell keeps its imported value and style and regains its format.
369
+ next.columns[column] = own(header, 'value') ? {...(object(current) && own(current, 'value') ? current : {value: current}), format: header.format} : clone(header);
370
+ }
371
+ for (const entry of record.cells) {
372
+ const [row, column, value, format, text] = Array.isArray(entry) ? entry : [];
373
+ const current = next.rows[row]?.[column];
374
+ if (current === undefined || typeof value !== 'number' || cellText(current) !== text) { changed++; continue; }
375
+ next.rows[row][column] = object(current) && own(current, 'value')
376
+ ? {...current, value, ...(typeof format === 'string' ? {format} : {})}
377
+ : typeof format === 'string' ? {value, format} : value;
378
+ }
379
+ if (!validWith({table: next})) {
380
+ report({code: 'invalid-data-provenance', message: 'The table number formats recorded at export do not form a valid table; the table imports the cells it shows.'});
381
+ return table;
382
+ }
383
+ if (changed) report({code: 'table-data-provenance-changed', message: `${changed} recorded table ${changed === 1 ? 'cell no longer shows' : 'cells no longer show'} the text exported for ${changed === 1 ? 'it' : 'them'}, so ${changed === 1 ? 'its' : 'their'} number value and format were not restored; ${changed === 1 ? 'it imports' : 'they import'} the text shown.`});
384
+ return next;
385
+ }
package/dist/index.d.ts CHANGED
@@ -36,7 +36,7 @@ export interface ImageResolverContext {
36
36
  export interface FontSchemeDiagnostic { code: "unresolved-font-scheme"; path: string; message: string; id: string; fallback: string }
37
37
  export interface MediaProvenanceDiagnostic { code: "media-provenance-omitted"; path: string; message: string }
38
38
  /** The chart data cannot be plotted, so a placeholder frame stands in for the chart. */
39
- export interface ChartDataUnplottableDiagnostic { code: "chart-data-unplottable"; path: string; message: string; reason: "data-not-inline" | "no-rows" | "no-columns" | "single-column-not-numeric" }
39
+ export interface ChartDataUnplottableDiagnostic { code: "chart-data-unplottable"; path: string; message: string; reason: "data-not-inline" | "dataset-unknown" | "no-rows" | "no-columns" | "single-column-not-numeric" }
40
40
  /** Content with no PowerPoint export (an empty table, an unsupported payload) is replaced by a plain-language placeholder frame. */
41
41
  export interface ContentPlaceholderDiagnostic { code: "content-placeholder"; path: string; message: string; reason: "table-has-no-rows" | "unsupported-payload" }
42
42
  /**
@@ -63,6 +63,12 @@ export interface WatermarkNotExportedDiagnostic { code: "watermark-not-exported"
63
63
  export interface ChartDataAdaptedDiagnostic { code: "chart-data-adapted"; path: string; message: string; adaptation: "histogram-binned" | "row-numbers" | "series-dropped" | "chartex-fallback" }
64
64
  /** With `chartex: "native"`, a map chart (`world`) is exported as a chartex regionMap (`Requires="cx4"`) without cached geography: PowerPoint must fetch the region shapes from its online map service and shows "There was a problem getting the information for your map chart" with an empty chart until it does. */
65
65
  export interface ChartMapGeodataDiagnostic { code: "chart-map-geodata"; path: string; message: string }
66
+ /** RR-54: chart values that core `chartNumber` rejects (not a number or a plain decimal string) were exported as gaps; one per chart, with their count. */
67
+ export interface ChartValueNotNumericDiagnostic { code: "chart-value-not-numeric"; path: string; message: string; count: number }
68
+ /** RR-54: core adapted a `chart.mapping` (an X column on a chart without an X axis, a series that repeats the category or X column); `pointer` is core's JSON Pointer. */
69
+ export interface ChartMappingAdaptedDiagnostic { code: "chart-mapping-adapted"; path: string; message: string; pointer: string }
70
+ /** RR-54 (`full` provenance): a chart or table data record, or the datasets map (`path: "datasets"`), is over the 16 MiB tag limit and is not stored; the native values still export. */
71
+ export interface DataProvenanceOmittedDiagnostic { code: "data-provenance-omitted"; path: string; message: string }
66
72
 
67
73
  /** A template variable was unfilled and its `example` was used (template export only). */
68
74
  export interface VariableExampleUsedDiagnostic {
@@ -110,7 +116,7 @@ export interface ToPptxOptions {
110
116
  /** Match preview/pagination clearance around supplied vector text outlines; default 1. */
111
117
  textRasterPadding?: number;
112
118
  /** Layout diagnostics, `media-provenance-omitted` when video data cannot be stored, plus `unresolved-font-scheme` (once per reference path) when a font-scheme id matches no record and the default `aptos` scheme is used as the base. */
113
- onDiagnostic?: (diagnostic: LayoutDiagnostic | FontSchemeDiagnostic | MediaProvenanceDiagnostic | ChartDataUnplottableDiagnostic | ChartDataAdaptedDiagnostic | ChartMapGeodataDiagnostic | ContentPlaceholderDiagnostic | UnresolvedAssetDiagnostic | WatermarkNotExportedDiagnostic | SvgSanitizedDiagnostic | SvgImageRasterizedDiagnostic | VariableExampleUsedDiagnostic) => void;
119
+ onDiagnostic?: (diagnostic: LayoutDiagnostic | FontSchemeDiagnostic | MediaProvenanceDiagnostic | ChartDataUnplottableDiagnostic | ChartDataAdaptedDiagnostic | ChartMapGeodataDiagnostic | ChartValueNotNumericDiagnostic | ChartMappingAdaptedDiagnostic | DataProvenanceOmittedDiagnostic | ContentPlaceholderDiagnostic | UnresolvedAssetDiagnostic | WatermarkNotExportedDiagnostic | SvgSanitizedDiagnostic | SvgImageRasterizedDiagnostic | VariableExampleUsedDiagnostic) => void;
114
120
  baseDir?: string;
115
121
  compressionLevel?: number;
116
122
  imageResolver?: (src: string, context: ImageResolverContext) => ImageResolverResult | Promise<ImageResolverResult | null | undefined> | null | undefined;
@@ -450,6 +456,8 @@ export interface TypefaceEntry {
450
456
  export interface TypefaceInventory {
451
457
  typefaces: TypefaceEntry[];
452
458
  themes: Record<string, {major: Partial<Record<"latin" | "ea" | "cs", string>>; minor: Partial<Record<"latin" | "ea" | "cs", string>>}>;
459
+ /** Every theme part's fonts by part path (several themes: one slide master per script profile, opf-pptx#168, and the notes master's). */
460
+ themeParts: Record<string, {major: Partial<Record<"latin" | "ea" | "cs", string>>; minor: Partial<Record<"latin" | "ea" | "cs", string>>}>;
453
461
  /** docProps/app.xml "Fonts Used", or null when the package has no readable list. */
454
462
  fontsUsed: string[] | null;
455
463
  }
@@ -463,8 +471,14 @@ export interface CheckPptxTypefacesOptions {
463
471
  allowEmptyThemeScripts?: boolean;
464
472
  /** Allowed theme script supplements; defaults to THEME_SCRIPT_SUPPLEMENTS. */
465
473
  scriptSupplements?: Readonly<Record<"major" | "minor", Readonly<Record<string, string>>>>;
474
+ /** FF-49: the East Asian / complex-script families selected for the theme slots; a selected slot that is empty or different is a `theme-script-slot` violation. */
475
+ themeScripts?: ThemeScriptSelection;
476
+ /** opf-pptx#168: per theme part path (for example `ppt/theme/theme3.xml`), overriding `themeScripts` for that part. */
477
+ themeScriptsByPart?: Record<string, ThemeScriptSelection>;
466
478
  }
467
479
 
480
+ export type ThemeScriptSelection = Partial<Record<"major" | "minor", Partial<Record<"ea" | "cs", string>>>>;
481
+
468
482
  export type TypefaceViolationReason =
469
483
  | "foreign-typeface"
470
484
  | "foreign-theme-reference"
@@ -477,7 +491,8 @@ export type TypefaceViolationReason =
477
491
  | "inconsistent-pitch-family"
478
492
  | "missing-fonts-used"
479
493
  | "foreign-fonts-used"
480
- | "fonts-used-mismatch";
494
+ | "fonts-used-mismatch"
495
+ | "theme-script-slot";
481
496
 
482
497
  export interface TypefaceViolation {
483
498
  reason: TypefaceViolationReason;