@openpresentation/opf-pptx 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import {XMLParser} from 'fast-xml-parser';
2
2
  import {checkFormat, isValidFormat} from './format-check.js';
3
3
  import {decodeTextTag, encodeTextTag} from './code-provenance.js';
4
- import {chartUsesDataFields, isDatasetRef, fromExcelNumberFormat, resolveTableData, tableCellDisplayValue, tableUsesDataFields} from './chart-data.js';
4
+ import {chartUsesDataFields, isDatasetRef, fromExcelNumberFormat, resolveTableData, tableCellDisplayValue} from './chart-data.js';
5
5
 
6
6
  // RR-54: chart and table data survive a PPTX round trip (core docs/chart-table-data.md).
7
7
  //
@@ -27,7 +27,9 @@ import {chartUsesDataFields, isDatasetRef, fromExcelNumberFormat, resolveTableDa
27
27
  //
28
28
  // Import restores a record only while its native evidence is unchanged: a chart while its caches hash the same (an edit in
29
29
  // PowerPoint's Edit Data changes them), a dataset table while every native cell still shows the dataset's display text,
30
- // and an inline table cell by cell while the native cell shows the recorded text. Otherwise the native values stay (with
30
+ // and an inline table cell by cell while its native text, formatting and grid evidence is unchanged (RR-59).
31
+ // RR-59 adds `authored`, per-cell `evidence` and an `environment` hash of theme/table style parts; the legacy headers/cells
32
+ // fields remain readable by older importers. Exact authored style absence is restored only with all of this evidence. Otherwise the native values stay (with
31
33
  // the format codes core maps back, src/chart-data.js) and a diagnostic names what was not restored. Tags are untrusted
32
34
  // input: they are size-limited (16 MiB, the limit of the document tag), depth- and shape-checked, validated as OPF, and
33
35
  // never executed; a record that fails a check, or that a restore cannot use, is reported and the native values stay.
@@ -113,6 +115,32 @@ export function chartEvidence(xml) {
113
115
  return hash(parts.join('\u0000'));
114
116
  }
115
117
 
118
+ /** Native cell text, formatting and table geometry, canonicalized independently of XML attribute order. */
119
+ export function tableCellEvidence(table, relationships = new Map()) {
120
+ const related = value => {
121
+ const ids = new Set(), stack = [value];
122
+ while (stack.length) {
123
+ const item = stack.pop();
124
+ if (!item || typeof item !== 'object') continue;
125
+ for (const [key, child] of Object.entries(item)) {
126
+ if (/^r:(id|embed|link)$/.test(key) && typeof child === 'string') ids.add(child);
127
+ else if (child && typeof child === 'object') stack.push(child);
128
+ }
129
+ }
130
+ return ids.size ? {relationships: Object.fromEntries([...ids].sort().map(id => {
131
+ const link = relationships.get(id);
132
+ return [id, link ? {type: link.type, target: link.target, targetMode: link.targetMode} : null];
133
+ }))} : {};
134
+ };
135
+ const properties = {properties: table?.['a:tblPr'], grid: table?.['a:tblGrid']};
136
+ return array(table?.['a:tr']).map(row => array(row?.['a:tc']).map(cell => hash(canonical({properties, row: Object.fromEntries(Object.entries(row).filter(([key]) => key !== 'a:tc')), cell, ...related({properties, cell})}))));
137
+ }
138
+
139
+ /** Theme and table-style edits can change effective cell formatting without changing the cell XML. */
140
+ export function tableEnvironmentEvidence(entries) {
141
+ return hash(canonical(Object.fromEntries(Object.keys(entries).filter(path => /^ppt\/theme\/[^/]+\.xml$/.test(path) || path === 'ppt/tableStyles.xml').sort().map(path => [path, parser.parse(dec.decode(entries[path]))]))));
142
+ }
143
+
116
144
  // ---------------------------------------------------------------------------
117
145
  // Export
118
146
 
@@ -126,19 +154,16 @@ export function chartDataRecord(chart, datasets) {
126
154
  return {v: 1, kind: 'chart', data: clone(chart.data), ...(chart.mapping !== undefined ? {mapping: clone(chart.mapping)} : {}), ...(object(chart.highlight) ? {highlight: clone(chart.highlight)} : {}), ...(dataset ? {dataset} : {})};
127
155
  }
128
156
 
129
- /** The record of a table that is dataset-backed or formats numbers, or undefined. `layout` is the exported table layout. */
157
+ /** Full table provenance: authored inline cells (with legacy numeric recovery fields), or a dataset reference. */
130
158
  export function tableDataRecord(table, layout) {
131
- if (!tableUsesDataFields(table)) return undefined;
132
159
  if (isDatasetRef(table)) return {v: 1, kind: 'table', table: clone(table)};
133
160
  const hasHeaders = Array.isArray(table.columns) && table.columns.length > 0;
134
161
  const shown = (row, column) => cellText(layout?.rows?.[row]?.cells?.find(cell => cell.column === column)?.value);
135
162
  // A header's format: a DataColumn's or a header StyledTableCell's (both are the column format).
136
163
  const formatOf = value => object(value) && typeof value.format === 'string' ? value.format : undefined;
137
164
  const headers = [], cells = [];
138
- const columnFormats = [];
139
165
  (hasHeaders ? table.columns : []).forEach((header, column) => {
140
166
  const format = formatOf(header);
141
- columnFormats[column] = format;
142
167
  const dataColumn = object(header) && typeof header.name === 'string' && !own(header, 'value');
143
168
  if (dataColumn || format !== undefined) headers.push([column, clone(header), shown(0, column)]);
144
169
  });
@@ -147,11 +172,11 @@ export function tableDataRecord(table, layout) {
147
172
  row.forEach((cell, column) => {
148
173
  const value = own(cell, 'value') ? cell.value : cell;
149
174
  const format = own(cell, 'value') && typeof cell.format === 'string' ? cell.format : undefined;
150
- if (typeof value !== 'number' || (format === undefined && columnFormats[column] === undefined)) return;
175
+ if (typeof value !== 'number') return;
151
176
  cells.push([rowIndex, column, value, format ?? null, shown(rowIndex + (hasHeaders ? 1 : 0), column)]);
152
177
  });
153
178
  });
154
- return {v: 1, kind: 'table', headers, cells};
179
+ return {v: 1, kind: 'table', headers, cells, authored: clone(table)};
155
180
  }
156
181
 
157
182
  function relationshipsXml(entries, path) {
@@ -205,7 +230,9 @@ export function attachDataProvenance(entries, {records, paths = new Map(), datas
205
230
  if (seen.has(name)) throw Error(`Duplicate generated frame name ${name}.`);
206
231
  seen.add(name);
207
232
  const record = records.get(name);
208
- const value = record.kind === 'chart' ? {...record, evidence: evidence.get(name) ?? []} : record;
233
+ const native = record.kind === 'table' ? parser.parse(frames.find(frame => frame.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1] === name))?.['p:graphicFrame']?.['a:graphic']?.['a:graphicData']?.['a:tbl'] : undefined;
234
+ const value = record.kind === 'chart' ? {...record, evidence: evidence.get(name) ?? []} : {...record, evidence: tableCellEvidence(native, relationships), environment: tableEnvironmentEvidence(entries)};
235
+ if (!fits(value, paths.get(name) ?? name)) {records.delete(name); seen.delete(name); continue;}
209
236
  const part = `ppt/tags/opfData${++count}.xml`;
210
237
  if (entries[part]) throw Error('Colliding data tag part.');
211
238
  entries[part] = tagPart(DATA_TAG, value);
@@ -310,6 +337,9 @@ export function readDataTag(entries, frame, relationships) {
310
337
  && (entry[3] === null || typeof entry[3] === 'string') && typeof entry[4] === 'string';
311
338
  if (!record.headers.every(header) || !record.cells.every(cell)) throw Error('Invalid table data record.');
312
339
  }
340
+ if (record.kind === 'table' && record.authored !== undefined && !object(record.authored)) throw Error('Invalid authored table record.');
341
+ if (record.kind === 'table' && record.evidence !== undefined && (!Array.isArray(record.evidence) || !record.evidence.every(row => Array.isArray(row) && row.every(cell => typeof cell === 'string')) || typeof record.environment !== 'string')) throw Error('Invalid table evidence record.');
342
+ if (record.kind === 'table' && record.authored !== undefined && record.evidence === undefined) throw Error('Authored table record has no native evidence.');
313
343
  return record;
314
344
  }
315
345
 
@@ -341,12 +371,17 @@ export function restoreChartData(chart, record, evidence, {datasets, report}) {
341
371
  return restored;
342
372
  }
343
373
 
344
- /** The authored table data where the native cells still show the recorded text; the imported table otherwise. */
345
- export function restoreTableData(table, record, {datasets, report}) {
374
+ /** The authored table data where the native evidence is unchanged; edited native cells otherwise. */
375
+ export function restoreTableData(table, record, {datasets, report, evidence, environment}) {
346
376
  if (!object(table)) return table;
347
377
  const columns = Array.isArray(table.columns) ? table.columns : undefined;
348
378
  const rows = Array.isArray(table.rows) ? table.rows : [];
379
+ const sameCell = (row, column) => record.environment === environment && record.evidence?.[row]?.[column] === evidence?.[row]?.[column];
349
380
  if (record.table) {
381
+ if (record.evidence && (record.environment !== environment || canonical(record.evidence) !== canonical(evidence))) {
382
+ report({code: 'table-data-provenance-changed', message: 'The table text, formatting or geometry changed after export; the dataset reference was not restored and native cells are retained.'});
383
+ return table;
384
+ }
350
385
  const id = record.table.dataset;
351
386
  if (!own(datasets, id)) {
352
387
  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.`});
@@ -363,6 +398,28 @@ export function restoreTableData(table, record, {datasets, report}) {
363
398
  }
364
399
  return clone(record.table);
365
400
  }
401
+ if (record.authored) {
402
+ if (!validWith({table: record.authored})) {
403
+ report({code: 'invalid-data-provenance', message: 'The authored table recorded at export is invalid; native cells are retained.'});
404
+ return table;
405
+ }
406
+ const authored = record.authored, headerOffset = authored.columns?.length ? 1 : 0;
407
+ const expected = [...(headerOffset ? [authored.columns] : []), ...authored.rows];
408
+ if (record.environment === environment && canonical(record.evidence) === canonical(evidence)) return clone(authored);
409
+ const next = {...table, ...(columns ? {columns: [...columns]} : {}), rows: rows.map(row => [...row])};
410
+ let changed = 0;
411
+ expected.forEach((row, r) => row.forEach((cell, c) => {
412
+ const target = r < headerOffset ? next.columns : next.rows[r - headerOffset];
413
+ if (target && c < target.length && sameCell(r, c)) target[c] = clone(cell);
414
+ else changed++;
415
+ }));
416
+ if (!validWith({table: next})) {
417
+ report({code: 'table-data-provenance-changed', message: 'The authored cells no longer form a valid table with the native grid; native cells are retained.'});
418
+ return table;
419
+ }
420
+ if (changed) report({code: 'table-data-provenance-changed', message: `${changed} authored table ${changed === 1 ? 'cell was' : 'cells were'} not restored because ${changed === 1 ? 'its' : 'their'} native text, formatting or geometry changed; native edits are retained.`});
421
+ return next;
422
+ }
366
423
  const next = {...table, ...(columns ? {columns: [...columns]} : {}), rows: rows.map(row => Array.isArray(row) ? [...row] : row)};
367
424
  let changed = 0;
368
425
  for (const entry of record.headers) {