@openpresentation/opf-pptx 0.13.1 → 0.14.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
- import {validatePresentation} from '@openpresentation/opf';
2
+ import {checkFormat, isValidFormat} from './format-check.js';
3
3
  import {decodeTextTag, encodeTextTag} from './code-provenance.js';
4
- import {chartUsesDataFields, isDatasetRef, numberFormatFromExcel, resolveTableData, tableCellDisplayValue, tableUsesDataFields} from './chart-data.js';
4
+ import {chartUsesDataFields, isDatasetRef, fromExcelNumberFormat, resolveTableData, tableCellDisplayValue, tableUsesDataFields} 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
  //
@@ -13,11 +13,13 @@ import {chartUsesDataFields, isDatasetRef, numberFormatFromExcel, resolveTableDa
13
13
  //
14
14
  // OPF_DATASETS_V1 on p:presentation (beside OPF_DOCUMENT_V1): the whole `datasets` map, unused datasets included.
15
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
16
+ // chart {v: 1, kind: 'chart', data, mapping?, highlight?, dataset?: hash, evidence: [hash]} `data` is the authored
17
17
  // chart.data (a dataset reference, or inline columns with DataColumn objects and `source`); `evidence`
18
18
  // hashes the native caches (series names, categories, values and format codes) of the chart part(s) the frame
19
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.
20
+ // pasted into a deck whose dataset of the same id holds other data keeps its values inline. `highlight` (FA-14) is
21
+ // the authored chart.highlight: the native chart carries only the accent and muted colours it produced, which no
22
+ // exact inference turns back into series and category names, and a chart with only a highlight records its inline `data`.
21
23
  // table {v: 1, kind: 'table', table} a dataset-backed table, as authored
22
24
  // {v: 1, kind: 'table', headers: [[column, header, text]], cells: [[row, column, value, format, text]]}
23
25
  // an inline table: the DataColumn or formatted
@@ -79,13 +81,13 @@ const canonicalValue = text => NUMBER.test(text.trim()) && Number.isFinite(Numbe
79
81
  // A General format code is left out: on a plain save PowerPoint writes <c:formatCode>General</c:formatCode> into every
80
82
  // cache that has none (PptxGenJS writes none on pie and doughnut series), so General and no code are the same evidence.
81
83
  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
84
+ // A format code is hashed in core's canonical form (fromExcelNumberFormat), because a plain PowerPoint save re-spells codes
83
85
  // without changing what they show: `$#,##0.0` becomes `\$#,##0.0` and `#,##0 "units"` becomes `#,##0\ "units"` (native
84
86
  // check opf#385). Core reads quoted and backslash-escaped literals and literal-safe characters to the same NumberFormat, so
85
87
  // both spellings hash equal, while a real change (another number of decimals, another unit) still changes the hash. A code
86
88
  // core cannot map is hashed as written; the `opf:` prefix keeps a mapped form apart from a verbatim code of the same text.
87
89
  const formatEvidence = code => {
88
- const format = numberFormatFromExcel(code);
90
+ const format = fromExcelNumberFormat(code);
89
91
  return typeof format === 'string' ? `format:opf:${format}` : `format:${code}`;
90
92
  };
91
93
  function hash(text) {
@@ -119,9 +121,9 @@ const datasetHash = (datasets, id) => own(datasets, id) ? hash(canonical(dataset
119
121
 
120
122
  /** The record of a chart that uses an RR-54 data field, or undefined (the frame then carries no tag). */
121
123
  export function chartDataRecord(chart, datasets) {
122
- if (!chartUsesDataFields(chart)) return undefined;
124
+ if (!chartUsesDataFields(chart) && !object(chart?.highlight)) return undefined;
123
125
  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} : {})};
126
+ 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} : {})};
125
127
  }
126
128
 
127
129
  /** The record of a table that is dataset-backed or formats numbers, or undefined. `layout` is the exported table layout. */
@@ -281,8 +283,9 @@ export function readDatasetsTag(entries, presentationRoot, relationships, report
281
283
  const datasets = readTags(entries, presentationRoot?.['p:custDataLst'], relationships, DATASETS_TAG);
282
284
  if (datasets === undefined) return undefined;
283
285
  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
+ const report = checkFormat({slides: [{title: 'Datasets'}], datasets});
287
+ const [first] = report.findings.filter(finding => finding.severity === 'error');
288
+ if (!report.valid) throw Error(`The stored datasets do not validate (${first?.path ?? ''} ${first?.message ?? ''}).`);
286
289
  return datasets;
287
290
  } catch (error) {
288
291
  report?.({code: 'invalid-data-provenance', path: 'datasets', message: `${error instanceof Error ? error.message : String(error)} The datasets recorded at export were not restored.`});
@@ -297,6 +300,7 @@ export function readDataTag(entries, frame, relationships) {
297
300
  if (!object(record) || record.v !== 1 || !['chart', 'table'].includes(record.kind)) throw Error('Unsupported data provenance version.');
298
301
  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
302
  if (record.kind === 'chart' && record.dataset !== undefined && typeof record.dataset !== 'string') throw Error('Invalid chart data record.');
303
+ if (record.kind === 'chart' && record.highlight !== undefined && !object(record.highlight)) throw Error('Invalid chart data record.');
300
304
  if (record.kind === 'table' && !(object(record.table) || (Array.isArray(record.headers) && Array.isArray(record.cells)))) throw Error('Invalid table data record.');
301
305
  // Inline table entries are [column, header, text] and [row, column, value, format, text]; their indices are array
302
306
  // indices (non-negative integers), never a key such as "__proto__" or "length".
@@ -309,7 +313,7 @@ export function readDataTag(entries, frame, relationships) {
309
313
  return record;
310
314
  }
311
315
 
312
- const validWith = (payload, datasets) => validatePresentation({slides: [{title: 'Data', ...payload}], ...(datasets ? {datasets} : {})}).valid;
316
+ const validWith = (payload, datasets) => isValidFormat({slides: [{title: 'Data', ...payload}], ...(datasets ? {datasets} : {})});
313
317
 
314
318
  /**
315
319
  * The authored chart data while the native caches are unchanged: the imported chart (type and options) with the recorded
@@ -317,7 +321,7 @@ const validWith = (payload, datasets) => validatePresentation({slides: [{title:
317
321
  */
318
322
  export function restoreChartData(chart, record, evidence, {datasets, report}) {
319
323
  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.'});
324
+ 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.' + (record.highlight ? ' The chart highlight (series and categories named at export) was not restored either: its names refer to the data as exported.' : '')});
321
325
  return chart;
322
326
  }
323
327
  if (isDatasetRef(record.data) && !own(datasets, record.data.dataset)) {
@@ -328,8 +332,8 @@ export function restoreChartData(chart, record, evidence, {datasets, report}) {
328
332
  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
333
  return chart;
330
334
  }
331
- const {mapping: _mapping, ...rest} = chart;
332
- const restored = {...rest, data: clone(record.data), ...(record.mapping !== undefined ? {mapping: clone(record.mapping)} : {})};
335
+ const {mapping: _mapping, highlight: _highlight, ...rest} = chart;
336
+ const restored = {...rest, data: clone(record.data), ...(record.mapping !== undefined ? {mapping: clone(record.mapping)} : {}), ...(record.highlight !== undefined ? {highlight: clone(record.highlight)} : {})};
333
337
  if (!validWith({chart: restored}, isDatasetRef(record.data) ? datasets : undefined)) {
334
338
  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
339
  return chart;
@@ -2,8 +2,8 @@ import {XMLParser} from 'fast-xml-parser';
2
2
  import {decodeTextTag, encodeTextTag} from './code-provenance.js';
3
3
  import {contentTopology, rebuildContent, validateTopology, listLineBreaks, validateListBreaks} from './content-topology.js';
4
4
  import {runColorRecord, validateRunColors} from './run-colors.js';
5
- // Namespace import: cores before FF-34 do not export resolveSocialProfile.
6
- import * as opfCore from '@openpresentation/opf';
5
+ import {catalogs as bundledCatalogs, validateCatalogRecord} from '@openpresentation/opf';
6
+ import {resolveSocialProfile} from '@openpresentation/opf/composition';
7
7
 
8
8
  // FF-32: document and slide references survive a PPTX round trip.
9
9
  //
@@ -61,11 +61,11 @@ const own = (value, key) => object(value) && Object.hasOwn(value, key) && value[
61
61
  // still shows exactly what the stored authored value formats to, keep the
62
62
  // authored form (a handle stays a handle); an edited line keeps its new URL.
63
63
  function authoredSocials(stored, observed, records) {
64
- if (!object(stored) || !object(observed) || typeof opfCore.resolveSocialProfile !== 'function') return observed;
64
+ if (!object(stored) || !object(observed)) return observed;
65
65
  return Object.fromEntries(Object.entries(observed).map(([platform, value]) => {
66
66
  const authored = stored[platform];
67
67
  if (typeof authored !== 'string') return [platform, value];
68
- const profile = opfCore.resolveSocialProfile(platform, authored, records, 'organization');
68
+ const profile = resolveSocialProfile(platform, authored, records, 'organization');
69
69
  const shown = profile.href && !/^[a-z][a-z0-9+.-]*:/i.test(profile.text) ? `https://${profile.text}` : profile.text;
70
70
  return [platform, shown === value ? authored : value];
71
71
  }));
@@ -370,7 +370,7 @@ const layoutRecords = catalogs => array(Array.isArray(catalogs?.layouts) ? catal
370
370
  function validLayoutRecord(value) {
371
371
  // Inline catalogs already identify the kind. Supply only an omitted
372
372
  // standalone identifier for validation; never change the authored record.
373
- try { return object(value) && opfCore.validateCatalogRecord('layouts', {$schema: 'https://openpresentation.org/schema/opf-layout/v1', ...value}).valid; }
373
+ try { return object(value) && validateCatalogRecord('layouts', {$schema: 'https://openpresentation.org/schema/opf-layout/v1', ...value}).valid; }
374
374
  catch { return false; }
375
375
  }
376
376
 
@@ -872,7 +872,7 @@ export function slideListBreaks(entries, path, root, rels) {
872
872
  * `nativeSections` is the package's native section list as one name (or
873
873
  * undefined) per slide, or null when the package has no list.
874
874
  */
875
- export function restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, slides, organizationConflict = false, socialPlatformRecords, nativeSections = null}, report) {
875
+ export function restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, slides, organizationConflict = false, speakerConflict = false, socialPlatformRecords, nativeSections = null}, report) {
876
876
  const invalid = message => report({code: 'invalid-document-provenance', path: '', message: `${message} Ordinary import keeps the values observed in the PPTX.`});
877
877
  let document;
878
878
  try {
@@ -1109,6 +1109,10 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
1109
1109
  report({code: 'metadata-reference-changed', path: 'organization', message: 'Slides now show different organization names in their furniture, so the stored organization was not restored; each slide keeps its visible text.'});
1110
1110
  continue;
1111
1111
  }
1112
+ if (key === 'speaker' && speakerConflict) {
1113
+ report({code: 'metadata-reference-changed', path: 'speaker', message: 'Slides now show different speaker names in their furniture, so the stored speaker was not restored; each slide keeps its visible text.'});
1114
+ continue;
1115
+ }
1112
1116
  if (key === 'author') {
1113
1117
  // Native evidence: the stored authored form only stands for the creator it was joined into.
1114
1118
  if (joinAuthors(imported.author) === joinAuthors(metadata.author)) group('author', [set(['author'], clone(metadata.author))]);
@@ -1120,7 +1124,7 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
1120
1124
  if (key === 'organization' && object(imported.organization)) {
1121
1125
  const observed = imported.organization;
1122
1126
  // Same order as export: inline records, then the document source, host catalogs and bundled records.
1123
- const hostRecords = typeof socialPlatformRecords === 'function' ? socialPlatformRecords(document.catalogs) : (opfCore.catalogs?.socialPlatforms ?? []);
1127
+ const hostRecords = typeof socialPlatformRecords === 'function' ? socialPlatformRecords(document.catalogs) : bundledCatalogs.socialPlatforms;
1124
1128
  const records = [...array(document.catalogs?.socialPlatforms?.records ?? document.catalogs?.socialPlatforms), ...hostRecords].filter(object);
1125
1129
  const merge = (stored, current) => object(current.socials) && object(stored?.socials) ? {...current, socials: authoredSocials(stored.socials, current.socials, records)} : current;
1126
1130
  const list = array(value);
@@ -1129,6 +1133,14 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
1129
1133
  else if (Array.isArray(value)) value[index] = {...list[index], ...merge(list[index], clone(observed))};
1130
1134
  else value = {...value, ...merge(value, clone(observed))};
1131
1135
  }
1136
+ if (key === 'speaker' && object(imported.speaker)) {
1137
+ // The furniture speaker line (name and title) is native content and wins over the stored speaker with the same id.
1138
+ const observed = imported.speaker, list = array(value), index = list.findIndex(item => object(item) && item.id === observed.id);
1139
+ const merged = stored => { const {title: _title, ...rest} = stored; return {...rest, ...clone(observed)}; };
1140
+ if (index < 0) value = clone(observed);
1141
+ else if (Array.isArray(value)) value[index] = merged(list[index]);
1142
+ else value = merged(value);
1143
+ }
1132
1144
  group(key, [set([key], value)]);
1133
1145
  }
1134
1146
 
@@ -1167,7 +1179,7 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
1167
1179
  * that the imported document references.
1168
1180
  */
1169
1181
  function layoutIntent(documentRecords, hasDocument, slideRecords, entries, group, report, rejectedDocumentIds) {
1170
- const bundled = id => array(opfCore.catalogs?.layouts).find(item => item?.id === id);
1182
+ const bundled = id => array(bundledCatalogs.layouts).find(item => item?.id === id);
1171
1183
  const owns = slideRecords.map(entry => {
1172
1184
  if (!entry) return null;
1173
1185
  const {record, native: observed} = entry;
@@ -0,0 +1,10 @@
1
+ // The one question the provenance and import code asks core: does this value satisfy the OPF format (the schema, and the
2
+ // checks that make a deck structurally usable)? It is core's cheap `format` category: no layout is built, so it is safe on
3
+ // every import and export path.
4
+ import {validate} from '@openpresentation/opf';
5
+
6
+ /** Core's `format` report for a deck or a fragment (`findings`, `valid`). */
7
+ export const checkFormat = value => validate(value, {only: ['format']});
8
+
9
+ /** True when the value has no `format` error. */
10
+ export const isValidFormat = value => checkFormat(value).valid;
@@ -15,7 +15,7 @@ const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal: true});
15
15
  const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, trimValues: false});
16
16
  const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
17
17
  const kinds = ['header', 'footer'], zones = ['left', 'center', 'right'];
18
- const fields = ['text', 'image', 'organization', 'socials', 'section', 'slideNumber', 'date'];
18
+ const fields = ['text', 'image', 'organization', 'speaker', 'socials', 'section', 'slideNumber', 'date'];
19
19
  const settings = ['slideNumberFormat', 'dateFormat'];
20
20
  const dateFormatForField = Object.fromEntries(Object.entries(NATIVE_DATE_FIELDS).map(([format, type]) => [type, format]));
21
21
  // Platform keys follow the Socials schema's propertyNames pattern exactly, so
@@ -97,6 +97,7 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
97
97
  if (!Object.keys(definitions).length) return null;
98
98
  const organizations = array(presentation.organization);
99
99
  const organization = organizations.find(item => item.role === 'primary') ?? organizations[0];
100
+ const speaker = array(presentation.speaker)[0];
100
101
  // Generated deck logos (logo: true) are listed beside the topology, never inside it: the part list and the field
101
102
  // definitions are validated strictly by every released importer (0.11.6 and earlier reject an unknown field there),
102
103
  // so they keep describing only what those importers know. `drawn` is false when no logo resolved at export.
@@ -109,9 +110,12 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
109
110
  }
110
111
  return {v: 1, role: 'slide', group: String(slideIndex), definitions, ...(Object.keys(formats).length ? {formats} : {}),
111
112
  ...(layout.parts.some(part => part.field === 'organization' || part.field === 'socials') ? {organizationId: organization?.id} : {}),
113
+ ...(layout.parts.some(part => part.field === 'speaker') ? {speakerId: speaker?.id} : {}),
112
114
  parts: layout.parts.flatMap((part, index) => part.field === 'logo' ? [] : [{kind: part.kind, zone: part.zone, field: part.field,
113
115
  type: part.type, count: part.type === 'image' ? 1 : part.fit.lines.length,
114
116
  ...(part.field === 'socials' ? {socials: socialLines(part)} : {}),
117
+ // The speaker part is one line, "Name, Title" or "Name": the name's length lets import split it again (lengths, never words).
118
+ ...(part.field === 'speaker' ? {nameLength: speaker.name.length} : {}),
115
119
  ...(staticDates.has(index) ? {staticDate: staticDates.get(index)} : {}),
116
120
  // RR-11: this part is a native PowerPoint placeholder (dt, ftr or sldNum); deleting it in PowerPoint's dialog is then intent.
117
121
  ...(natives.has(index) ? {ph: natives.get(index)} : {})}]),
@@ -209,8 +213,12 @@ function validateManifest(manifest) {
209
213
  && part.socials.every(line => object(line) && platformId.test(line.platform) && schemes.includes(line.scheme))
210
214
  && new Set(part.socials.map(line => line.platform)).size === part.socials.length, 'Invalid social profile lines.');
211
215
  else check(part.socials === undefined, 'Unexpected social profile lines.');
216
+ if (part.field === 'speaker') check(Number.isSafeInteger(part.nameLength) && part.nameLength >= 1 && part.nameLength <= 100000, 'Invalid speaker name length.');
217
+ else check(part.nameLength === undefined, 'Unexpected speaker name length.');
212
218
  expected.delete(key(part));
213
219
  }
220
+ if (manifest.parts.some(part => part.field === 'speaker')) check(typeof manifest.speakerId === 'string' && /^[a-zA-Z0-9_-]+$/.test(manifest.speakerId), 'Invalid speaker identity.');
221
+ else check(manifest.speakerId === undefined, 'Unexpected speaker identity.');
214
222
  if (manifest.parts.some(part => part.field === 'organization' || part.field === 'socials')) check(typeof manifest.organizationId === 'string' && /^[a-zA-Z0-9_-]+$/.test(manifest.organizationId), 'Invalid organization identity.');
215
223
  }
216
224
 
@@ -269,7 +277,7 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
269
277
  if (value !== false) for (const zone of zones) if (definition.value[zone] !== undefined) {
270
278
  value[zone] = Object.fromEntries(Object.entries(definition.value[zone]).filter(([, flag]) => flag === false));
271
279
  }
272
- const candidate = {scope: definition.scope, value, text: [], pictures: [], organizations: [], socials: [], sections: []};
280
+ const candidate = {scope: definition.scope, value, text: [], pictures: [], organizations: [], speakers: [], socials: [], sections: []};
273
281
  // logo: true returns as its flag; the generated picture is consumed, never content or an image field.
274
282
  for (const logo of (manifest.logos ?? []).filter(item => item.kind === kind)) {
275
283
  const found = logoPictures.filter(item => item.data.v === 1 && item.data.group === manifest.group && item.data.furniture === kind && item.data.zone === logo.zone);
@@ -305,6 +313,7 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
305
313
  }
306
314
  if (part.field === 'section') candidate.sections.push(text);
307
315
  if (part.field === 'organization') candidate.organizations.push({id: manifest.organizationId, name: text});
316
+ if (part.field === 'speaker') candidate.speakers.push(importedSpeaker(manifest.speakerId, text, part.nameLength));
308
317
  if (part.field === 'socials') {
309
318
  const lines = text.split('\n');
310
319
  check(lines.length === part.socials.length && lines.every(line => line.trim()), 'Current social profile lines no longer match their platforms.');
@@ -330,6 +339,14 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
330
339
  return candidates;
331
340
  }
332
341
 
342
+ // The generated speaker line is "Name, Title" (or "Name"). The recorded name length splits it again; a line edited in
343
+ // PowerPoint that no longer has the separator at that position is the visible text as the name, with no title.
344
+ function importedSpeaker(id, text, nameLength) {
345
+ const rest = text.slice(nameLength);
346
+ if (text.length > nameLength && rest.startsWith(', ') && rest.length > 2) return {id, name: text.slice(0, nameLength), title: rest.slice(2)};
347
+ return {id, name: text};
348
+ }
349
+
333
350
  // A current native date field wins. Full-mode static-wrap evidence can recover
334
351
  // generated OPF intent without turning the existing PPTX text into a live field. A fixed
335
352
  // date keeps its ISO value and pattern only when the current text round-trips.
@@ -368,6 +385,13 @@ export function importFurniture(contexts, entries, onDiagnostic) {
368
385
  delete slide[kind]; report(index, `${kind}: Current organization metadata disagrees across repeated fields.`);
369
386
  }
370
387
  }
388
+ const speakers = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.speakers));
389
+ const speakerConflict = speakers.some(item => !same(item, speakers[0]));
390
+ if (speakerConflict) {
391
+ for (const [index, slide] of candidates.entries()) for (const kind of kinds) if (slide[kind]?.speakers.length) {
392
+ delete slide[kind]; report(index, `${kind}: Current speaker metadata disagrees across repeated fields.`);
393
+ }
394
+ }
371
395
  for (const [index, slide] of candidates.entries()) {
372
396
  const sections = Object.values(slide).flatMap(candidate => candidate.sections);
373
397
  if (sections.some(section => section !== sections[0])) for (const kind of kinds) if (slide[kind]?.sections.length) {
@@ -398,7 +422,7 @@ export function importFurniture(contexts, entries, onDiagnostic) {
398
422
  }
399
423
  if (!accepted.length) return;
400
424
  const merged = footerValue(accepted), indexes = accepted.map(item => item.index);
401
- if (!existing) slide.footer = {scope: 'native', value: merged, text: indexes, pictures: [], organizations: [], socials: [], sections: []};
425
+ if (!existing) slide.footer = {scope: 'native', value: merged, text: indexes, pictures: [], organizations: [], speakers: [], socials: [], sections: []};
402
426
  else {
403
427
  if (existing.value === false) existing.value = {};
404
428
  for (const zone of Object.keys(merged)) existing.value[zone] = {...existing.value[zone], ...merged[zone]};
@@ -417,6 +441,9 @@ export function importFurniture(contexts, entries, onDiagnostic) {
417
441
  if (result.organization && validSocials.length) result.organization = {...result.organization, socials: validSocials[0].socials};
418
442
  // Stored document metadata (FF-32) must not override disagreeing visible names.
419
443
  if (organizationConflict) result.organizationConflict = true;
444
+ const validSpeakers = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.speakers));
445
+ if (validSpeakers.length) result.speaker = validSpeakers[0];
446
+ if (speakerConflict) result.speakerConflict = true;
420
447
  for (const kind of kinds) {
421
448
  const inherited = candidates.map(slide => slide[kind]).filter(candidate => candidate?.scope === 'global');
422
449
  if (inherited.length && candidates.every(slide => slide[kind]) && inherited.every(candidate => same(candidate.value, inherited[0].value))) {
@@ -1,6 +1,7 @@
1
1
  import {XMLParser} from 'fast-xml-parser';
2
2
  import {attachTextTags,decodeTextTag} from './code-provenance.js';
3
3
  import {sourceLineParagraphs} from './text-provenance.js';
4
+ import {cleanBase,headingValue,joinRichLines} from './rich-heading.js';
4
5
  const TAG='OPF_HEADING_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
5
6
  const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
6
7
  const decoder=new TextDecoder('utf-8',{fatal:true}),array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
@@ -8,7 +9,8 @@ export const attachHeadingTags=(entries,records)=>attachTextTags(entries,records
8
9
 
9
10
  // Tags carry only roles and line ordering. Current native text always wins;
10
11
  // neither original source text nor old geometry can be restored from these tags.
11
- export function importHeadingGroups(shapes,paragraphs,relationships,entries,report) {
12
+ // `readBody` (optional) is the native body reader: with it a group also returns `richText`, the heading as string | TextRun[] (FA-10).
13
+ export function importHeadingGroups(shapes,paragraphs,relationships,entries,report,readBody) {
12
14
  const groups=new Map(),consumed=new Set(),items=[];
13
15
  for(const [index,shape] of shapes.entries()) {
14
16
  const tags=[];let unreadable=false;
@@ -37,8 +39,13 @@ export function importHeadingGroups(shapes,paragraphs,relationships,entries,repo
37
39
  }
38
40
  const restored=sourceLineParagraphs(ordered,paragraphs,{legacy:true});
39
41
  for(const item of ordered)consumed.add(item.shape);
40
- items.push({field:first.field,shapes:ordered.map(item=>item.shape),paragraphs:restored});
41
- report({code:'heading-import-reflow',message:'Complete tagged heading lines retain their roles, order and current native text. New boundary tags also retain authored whitespace and line endings; legacy tags retain native line breaks. Formatting and geometry are not reconstructed.'});
42
+ // The same boundaries as the plain text: a new tag's separator, or a native line break between legacy-tagged lines.
43
+ const source=ordered.some(item=>item.data.boundary!==undefined||item.data.separator!==undefined);
44
+ // Only a rich export records the heading's own look (tag.base); a heading exported as a plain string stays one.
45
+ const base=cleanBase(ordered[0].data.base);
46
+ const parts=base?joinRichLines(ordered,readBody,(item,last)=>source?item.data.separator:last?'':'\n'):undefined;
47
+ items.push({field:first.field,shapes:ordered.map(item=>item.shape),paragraphs:restored,...(parts?{richText:headingValue(parts,base)}:{})});
48
+ report({code:'heading-import-reflow',message:'Complete tagged heading lines retain their roles, order and current native text. New boundary tags also retain authored whitespace and line endings; legacy tags retain native line breaks. Run formatting that differs between words is read back as TextRun[]; geometry is not reconstructed.'});
42
49
  }catch(error){report({code:'invalid-heading-provenance',message:`${error.message} Ordinary import retains visible native text.`});}
43
50
  return {consumed,items};
44
51
  }
@@ -11,6 +11,9 @@ export async function webpToPng(bytes) {
11
11
  // is deterministic (no system fonts, no network or file access for the SVG's own references). `text` selects the
12
12
  // bundled fonts; an SVG with no text needs none. Rejects with code `svg-rasterizer-unavailable` when opf-render is
13
13
  // not installed.
14
+ //
15
+ // This is the one place the exporter calls the renderer. opf-render's svgToPng(svg, {fonts, scale, background}) takes the
16
+ // fonts handle for the faces it may draw with: here only the bundled ones, never the system's.
14
17
  export async function svgToPng(svg, {scale, text}) {
15
18
  let render;
16
19
  try {
@@ -20,7 +23,7 @@ export async function svgToPng(svg, {scale, text}) {
20
23
  unavailable.code = 'svg-rasterizer-unavailable';
21
24
  throw unavailable;
22
25
  }
23
- return new Uint8Array(await render(svg, {scale, background: 'rgba(0, 0, 0, 0)', useBundledFonts: text === true, loadSystemFonts: false}));
26
+ return new Uint8Array(await render(svg, {fonts: {useBundledFonts: text === true, loadSystemFonts: false}, scale, background: 'rgba(0, 0, 0, 0)'}));
24
27
  }
25
28
 
26
29
  // A local SVG file (a path source), read as bytes; PptxGenJS reads raster paths itself.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { LayoutDiagnostic, TextMeasurement } from "@openpresentation/opf/composition";
1
+ import type { Finding, FontSchemeDiagnostic, Fonts, LayoutDiagnostic, SlideContextReferenceDiagnostic } from "@openpresentation/opf";
2
2
  export declare const packageName = "@openpresentation/opf-pptx";
3
3
 
4
4
  export declare const releaseLane: Readonly<{
@@ -33,10 +33,9 @@ export interface ImageResolverContext {
33
33
  path: string;
34
34
  }
35
35
 
36
- export interface FontSchemeDiagnostic { code: "unresolved-font-scheme"; path: string; message: string; id: string; fallback: string }
37
36
  export interface MediaProvenanceDiagnostic { code: "media-provenance-omitted"; path: string; message: string }
38
37
  /** 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" | "dataset-unknown" | "no-rows" | "no-columns" | "single-column-not-numeric" }
38
+ export interface ChartDataUnplottableDiagnostic { code: "chart-data-unplottable"; path: string; message: string; reason: "dataset-unknown" | "no-rows" | "no-columns" | "single-column-not-numeric" }
40
39
  /** Content with no PowerPoint export (an empty table, an unsupported payload) is replaced by a plain-language placeholder frame. */
41
40
  export interface ContentPlaceholderDiagnostic { code: "content-placeholder"; path: string; message: string; reason: "table-has-no-rows" | "unsupported-payload" }
42
41
  /**
@@ -53,7 +52,6 @@ export interface UnresolvedAssetDiagnostic { code: "unresolved-asset"; path: str
53
52
  export interface SvgSanitizedDiagnostic { code: "svg-sanitized"; path: string; message: string }
54
53
  /** An SVG slide image with a duotone recolor or a non-rectangular shape exports as its PNG raster, not as a native SVG picture, so the effect applies as in the preview (PowerPoint applies opacity, grayscale and a border to an SVG picture, and those stay native). */
55
54
  export interface SvgImageRasterizedDiagnostic { code: "svg-image-rasterized"; path: string; message: string }
56
- export interface WatermarkNotExportedDiagnostic { code: "watermark-not-exported"; path: string; message: string }
57
55
  /**
58
56
  * The chart data was reshaped to export a native chart: a single value column was plotted against row numbers; a one-series construct
59
57
  * (pie, doughnut, and with `chartex: "native"` treemap, histogram, pareto, waterfall, funnel, map) kept only its first series; by default
@@ -70,9 +68,9 @@ export interface ChartMappingAdaptedDiagnostic { code: "chart-mapping-adapted";
70
68
  /** 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
69
  export interface DataProvenanceOmittedDiagnostic { code: "data-provenance-omitted"; path: string; message: string }
72
70
 
73
- /** A template variable was unfilled and its `example` was used (template export only). */
71
+ /** A template variable was unfilled and its `example` was used (template export only), or a built-in variable such as `{{speaker.name}}` has no source value in the document (`variable-builtin-missing`; it exports as nothing). */
74
72
  export interface VariableExampleUsedDiagnostic {
75
- code: "variable-example-used";
73
+ code: "variable-example-used" | "variable-builtin-missing";
76
74
  /** JSON pointer of the variable's declaration. */
77
75
  path: string;
78
76
  message: string;
@@ -112,11 +110,17 @@ export interface ToPptxOptions {
112
110
  * unfilled required variable throws `unfilled-variables`, and a value of the wrong kind throws `invalid-variables`.
113
111
  */
114
112
  variables?: Record<string, unknown>;
115
- textMeasurement?: TextMeasurement;
113
+ /**
114
+ * The fonts handle: what the renderer's `loadFonts()` returns (`@openpresentation/opf-render/fonts-node`), or any object with a
115
+ * `textMeasurement` (core's `Fonts`). The exporter lays text out with `fonts.textMeasurement` and always names the chosen
116
+ * families, never a substitute face. Without it, layout uses core's portable estimate. This is the only way to pass a measurement:
117
+ * a top-level `textMeasurement` option is not read.
118
+ */
119
+ fonts?: Fonts;
116
120
  /** Match preview/pagination clearance around supplied vector text outlines; default 1. */
117
121
  textRasterPadding?: number;
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. */
119
- onDiagnostic?: (diagnostic: LayoutDiagnostic | FontSchemeDiagnostic | MediaProvenanceDiagnostic | ChartDataUnplottableDiagnostic | ChartDataAdaptedDiagnostic | ChartMapGeodataDiagnostic | ChartValueNotNumericDiagnostic | ChartMappingAdaptedDiagnostic | DataProvenanceOmittedDiagnostic | ContentPlaceholderDiagnostic | UnresolvedAssetDiagnostic | WatermarkNotExportedDiagnostic | SvgSanitizedDiagnostic | SvgImageRasterizedDiagnostic | VariableExampleUsedDiagnostic) => void;
122
+ /** 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, and, from core's slide context, `unresolved-theme`, `unresolved-color-scheme` and `unresolved-layout` (once per reference path) when an id matches no record: the default theme (`minimal`) and colour scheme (`cool-horizon`) are used, and a slide whose layout is unknown, like one with no layout, is composed with no layout record. None of them refuses the export. */
123
+ onDiagnostic?: (diagnostic: LayoutDiagnostic | FontSchemeDiagnostic | SlideContextReferenceDiagnostic | MediaProvenanceDiagnostic | ChartDataUnplottableDiagnostic | ChartDataAdaptedDiagnostic | ChartMapGeodataDiagnostic | ChartValueNotNumericDiagnostic | ChartMappingAdaptedDiagnostic | DataProvenanceOmittedDiagnostic | ContentPlaceholderDiagnostic | UnresolvedAssetDiagnostic | SvgSanitizedDiagnostic | SvgImageRasterizedDiagnostic | VariableExampleUsedDiagnostic) => void;
120
124
  baseDir?: string;
121
125
  compressionLevel?: number;
122
126
  imageResolver?: (src: string, context: ImageResolverContext) => ImageResolverResult | Promise<ImageResolverResult | null | undefined> | null | undefined;
@@ -148,7 +152,7 @@ export interface ToPptxOptions {
148
152
  date?: string;
149
153
  /** Host-supplied catalog records, as in opf-render. Currently consulted for socialPlatforms (generated socials furniture). */
150
154
  catalogs?: Record<string, { records?: unknown[] } | unknown[]>;
151
- /** Records for document `catalogs.<kind>.source` URLs (a single source or each entry of an ordered search path), as in opf-render. Currently consulted for socialPlatforms. */
155
+ /** Records for presentation `catalogs.<kind>.source` URLs (a single source or each entry of an ordered search path), as in opf-render. Currently consulted for socialPlatforms. */
152
156
  catalogSources?: Record<string, { records?: unknown[] } | unknown[]>;
153
157
  }
154
158
 
@@ -159,12 +163,12 @@ export interface FromPptxOptions {
159
163
  schema?: string;
160
164
  /** The export's host catalog records (as in ToPptxOptions). Currently used to recognize unedited socials lines, so authored handles return. */
161
165
  catalogs?: Record<string, { records?: unknown[] } | unknown[]>;
162
- /** The export's records for document `catalogs.<kind>.source` URLs (as in ToPptxOptions). Currently used for socialPlatforms. */
166
+ /** The export's records for presentation `catalogs.<kind>.source` URLs (as in ToPptxOptions). Currently used for socialPlatforms. */
163
167
  catalogSources?: Record<string, { records?: unknown[] } | unknown[]>;
164
168
  /**
165
169
  * Opt in to raw per-shape signals (see PptxSignals). `true` uses the default limits; an object lowers or raises them.
166
- * With it, `fromPptx` resolves to `{document, signals}`; without it (the default, also `false`/`null`) it resolves
167
- * to the OPF document exactly as before. The document is the same either way.
170
+ * With it, `fromPptx` resolves to `{presentation, signals}`; without it (the default, also `false`/`null`) it resolves
171
+ * to the presentation alone. The presentation is the same either way.
168
172
  */
169
173
  signals?: boolean | PptxSignalOptions | null;
170
174
  }
@@ -172,7 +176,10 @@ export interface FromPptxOptions {
172
176
  export declare class OPFPptxError extends Error {
173
177
  readonly code: string;
174
178
  readonly details: Record<string, unknown>;
175
- readonly issues?: unknown[];
179
+ /** The `error` findings of the `format` check that refused the input or the imported presentation (`invalid-opf`, `invalid-import-opf`), or the variable errors (`unfilled-variables`, `invalid-variables`). */
180
+ readonly findings?: Finding[];
181
+ /** The layout diagnostics behind a `layout-overflow` error. */
182
+ readonly diagnostics?: LayoutDiagnostic[];
176
183
  readonly path?: string;
177
184
  constructor(code: string, message: string, details?: Record<string, unknown>);
178
185
  }
@@ -195,7 +202,7 @@ export declare function fromPptx(input: Uint8Array | ArrayBuffer, options?: From
195
202
 
196
203
  /** Bounds on the signals. Each is an integer from 1 up to the cap in parentheses; the defaults are DEFAULT_SIGNAL_LIMITS. */
197
204
  export interface PptxSignalOptions {
198
- /** Slides reported (5000). The document always has every slide. Default 300. */
205
+ /** Slides reported (5000). The presentation always has every slide. Default 300. */
199
206
  maxSlides?: number;
200
207
  /** Shapes reported per slide, groups and their members each counting one (5000). Default 300. */
201
208
  maxShapesPerSlide?: number;
@@ -221,8 +228,8 @@ export declare const SIGNALS_VERSION: 1;
221
228
  export declare function isMonospaceFamily(family: string | undefined): boolean;
222
229
 
223
230
  export interface PptxImportWithSignals {
224
- /** The same OPF document `fromPptx` returns without the option. */
225
- document: Record<string, unknown>;
231
+ /** The same presentation `fromPptx` returns without the option. */
232
+ presentation: Record<string, unknown>;
226
233
  signals: PptxSignals;
227
234
  }
228
235
 
@@ -252,7 +259,7 @@ export interface PptxSignals {
252
259
  }
253
260
 
254
261
  export interface PptxSlideSignals {
255
- /** Slide position, the same index as `document.slides`. */
262
+ /** Slide position, the same index as `presentation.slides`. */
256
263
  index: number;
257
264
  /** Package part, for example "ppt/slides/slide1.xml". */
258
265
  part: string;
@@ -379,7 +386,7 @@ export interface PptxOutlineSignal extends PptxColor {
379
386
  tailArrow?: string;
380
387
  }
381
388
 
382
- /** Where a shape went in the OPF document `fromPptx` returned. */
389
+ /** Where a shape went in the presentation `fromPptx` returned. */
383
390
  export interface PptxOpfLink {
384
391
  /**
385
392
  * "block" fed a block (`path`, `blockType`); "title", "subtitle" and "tag" fed that slide field. Other roles name a shape the
@@ -387,7 +394,7 @@ export interface PptxOpfLink {
387
394
  * and the members of an OPF-tagged group ("code", "metric", "quote", "timeline", "media", "card-frame").
388
395
  */
389
396
  role: string;
390
- /** Path in the document, for example "slides.2.blocks.1", "slides.0.title", or "slides.1.left" after a tagged round trip. */
397
+ /** Path in the presentation, for example "slides.2.blocks.1", "slides.0.title", or "slides.1.left" after a tagged round trip. */
391
398
  path?: string;
392
399
  blockType?: string;
393
400
  }
@@ -462,9 +469,9 @@ export interface TypefaceInventory {
462
469
  fontsUsed: string[] | null;
463
470
  }
464
471
 
465
- export interface CheckPptxTypefacesOptions {
466
- /** Family names the document chose (heading, body, code, run fonts). Required. */
467
- fonts: string[];
472
+ export interface CheckTypefacesOptions {
473
+ /** Family names the presentation chose (heading, body, code, run fonts). Required. */
474
+ families: string[];
468
475
  /** Chosen families that are monospace; their pitchFamily must be fixed pitch, and only theirs. */
469
476
  monospace?: string[];
470
477
  /** Allow empty theme ea/cs slots and references to them (FF-05). Default true. */
@@ -504,11 +511,11 @@ export interface TypefaceViolation {
504
511
 
505
512
  export declare const THEME_SCRIPT_SUPPLEMENTS: Readonly<Record<"major" | "minor", Readonly<Record<string, string>>>>;
506
513
 
507
- export declare function inventoryPptxTypefaces(input: Uint8Array | ArrayBuffer | Record<string, Uint8Array>, options?: {nested?: boolean}): TypefaceInventory;
514
+ export declare function inventoryTypefaces(input: Uint8Array | ArrayBuffer | Record<string, Uint8Array>, options?: {nested?: boolean}): TypefaceInventory;
508
515
 
509
516
  export declare function packageFontsUsed(inventory: TypefaceInventory): string[];
510
517
 
511
- export declare function checkPptxTypefaces(input: Uint8Array | ArrayBuffer | Record<string, Uint8Array>, options: CheckPptxTypefacesOptions): {
518
+ export declare function checkTypefaces(input: Uint8Array | ArrayBuffer | Record<string, Uint8Array>, options: CheckTypefacesOptions): {
512
519
  ok: boolean;
513
520
  violations: TypefaceViolation[];
514
521
  inventory: TypefaceInventory;