@openpresentation/opf-pptx 0.19.0 → 0.20.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.
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # OPF PPTX
2
2
 
3
- Catalogs (OPF 0.19): export resolves the document's embedded records and host-registered catalogs (`Catalog[]`, never fetched). Register gallery 2 explicitly for export: `import { gallery } from "@openpresentation/gallery"; toPptx(deck, { catalogs: [gallery] })`. Bare ids resolve in the document's custom/default records, then the registered default catalog; `catalogs.default:false` closes that fallback. When import `catalogs` is omitted, foreign PowerPoint layout type/name mapping uses the default catalog from installed `@openpresentation/gallery`; theme and colour-scheme recovery still requires explicitly registered catalogs; `fromPptx(bytes, { catalogs: [] })` explicitly opts out. An unresolved reference emits core's `unresolved-reference` diagnostic and uses engine defaults; `strictReferences:true` refuses the export with `OPFPptxError`. Native layout tags preserve valid catalog sources and opt-outs, and damaged identity/source metadata emits diagnostics instead of creating invalid catalog groups.
3
+ Catalogs (OPF 0.20): export resolves the document's embedded records and host-registered catalogs (`Catalog[]`, never fetched). Register Gallery 3 explicitly for export: `import { gallery } from "@openpresentation/gallery"; toPptx(deck, { catalogs: [gallery] })`. Bare ids resolve in the document's custom/default records, then the registered default catalog; `catalogs.default:false` closes that fallback. When import `catalogs` is omitted, foreign PowerPoint layout type/name mapping uses the default catalog from installed `@openpresentation/gallery`; theme and colour-scheme recovery still requires explicitly registered catalogs; `fromPptx(bytes, { catalogs: [] })` explicitly opts out. An unresolved reference emits core's `unresolved-reference` diagnostic and uses engine defaults; `strictReferences:true` refuses the export with `OPFPptxError`. Native layout tags preserve valid catalog sources and opt-outs, and damaged identity/source metadata emits diagnostics instead of creating invalid catalog groups.
4
4
 
5
- API names (RR-55, unreleased; requires core 0.14, and opf-render 0.14 for the optional peer): `toPptx(presentation, { fonts })` takes the fonts handle that opf-render's `loadFonts()` returns (`@openpresentation/opf-render/fonts-node`; core's `Fonts`, an object with at least `textMeasurement`) and no longer reads a top-level `textMeasurement`. `fromPptx(bytes, { signals: true })` resolves to `{ presentation, signals }` (it was `{ document, signals }`). `OPFPptxError` carries `findings` (the `error` findings of core's `format` check, or the variable errors) and `diagnostics` (the layout diagnostics of a `layout-overflow`), not `issues`. `inventoryPptxTypefaces` and `checkPptxTypefaces` are `inventoryTypefaces` and `checkTypefaces`, and `checkTypefaces` takes `{ families }` where it took `{ fonts }`. Each slide is composed with the options of core's `resolveSlideContext`, so the deck's resolved font families (slide design, deck design, theme, then the engine default) are the ones the layout is measured in; a reference that matches no record was reported per kind and never refused the export (0.15 replaces this with one `unresolved-reference` diagnostic and core's engine defaults; see Catalogs above): the default `minimal` theme and `cool-horizon` colour scheme were used, and a slide with no layout, or an unknown one, is composed with no layout record (automatic composition, as in the preview; it used to compose against the `blank` layout, so a layout-less cover is now centred vertically). The package no longer falls back when core lacks a function: it requires core 0.14.
5
+ Generated slide masters carry the display name `pptx.gallery`, including masters copied for per-slide script fonts. Individual slide layouts keep their Gallery or authored custom names. The master label is separate from the theme name: authored theme identities stay exact so import can recover them. Archive tests verify the emitted names; PowerPoint menu display is a separate native observation.
6
+
7
+ Current API (PPTX 0.20.0; core `^0.20.1` and optional renderer peer `^0.20.0`): `toPptx(presentation, { fonts })` takes the fonts handle returned by `@openpresentation/opf-render/fonts-node`'s `loadFonts()` (core's `Fonts`, with at least `textMeasurement`). `fromPptx(bytes, { signals: true })` returns `{ presentation, signals }`. `OPFPptxError` carries `findings` (core format or variable errors) and `diagnostics` (layout-overflow diagnostics). Typeface helpers are `inventoryTypefaces` and `checkTypefaces`; the latter takes `{ families }`. These API names replaced the earlier top-level `textMeasurement`, `{ document, signals }`, `issues`, `inventoryPptxTypefaces`, `checkPptxTypefaces` and `{ fonts }` check options in the historical RR-55 migration. Each slide is composed with core's `resolveSlideContext` options and its resolved design fonts. Unresolved references report `unresolved-reference` and use engine defaults unless `strictReferences: true` refuses the export. A slide without a layout, or with `layout: "auto"`, composes automatically.
6
8
 
7
9
  Version 0.12.0 requires `@openpresentation/opf` ^0.12.0 (composed font sizes on PowerPoint's 0.01 pt grid, hanging wrap whitespace, promoted regions in reading order, right-to-left decks composed mirrored) and exports what that core composes, plus the export and import changes listed in the changelog; use it with opf-render 0.12.0 (optional peer ^0.12.0) and opf-editor 0.11.0 so preview and export resolve one core. Version 0.11.9 exports an SVG image as a native SVG picture over a PNG fallback (rasterized in Node by the optional `@openpresentation/opf-render` peer or `options.svgRasterizer`; without one it stays the placeholder), writes a deck footer's first text, date and slide number as native PowerPoint Header & Footer placeholders (output change for decks with a footer; every export now carries the footer placeholders on its master, layout and notes master; see [native header and footer](docs/native-header-footer.md)) and adds the opt-in `fromPptx(bytes, {signals: true})`, which also returns the raw per-shape signals of every slide (see Import signals; the option leaves the default import unchanged; core floor `^0.11.4` unchanged, no API removed). Version 0.11.8 re-imports rich text that wraps over several native lines as the one authored payload instead of reporting `content-structure-changed` (the export records the line count; decks exported by earlier versions import as before; core floor `^0.11.4` unchanged, no API change). Version 0.11.7 requires `@openpresentation/opf` ^0.11.4 and exports the design fields natively (cover and section logos, header and footer logos, picture bullets, the accent font), writes every chart's text at the size the preview draws, round-trips the slide content structure, sections, extensions, assets and brand images through PPTX provenance (a fresh export of a plain deck re-imports in its authored form, so read a root payload field before `blocks[0]`), writes slide sections as PowerPoint's section list, and degrades an SVG image to the preview's placeholder instead of aborting; install it with renderer 0.11.9 and editor 0.10.6 so export and preview resolve the same core (no API removed). Version 0.11.6 exports the treemap, histogram, pareto, box-and-whisker, waterfall and funnel charts as native chartex parts by default (`toPptx({chartex: 'auto'})`; `world` stays a clustered column with `chart-data-adapted`; pass `chartex: 'fallback'` for the previous output) and gives chartex text the deck's label colour and font (no API removed). Version 0.11.5 writes the slide tag run as a theme color reference (`a:schemeClr accent1`) where the deck theme holds the primary there (no API change; the color is unchanged). Version 0.11.4 re-imports quote payloads (each native quote line carries an `OPF_QUOTE_V1` tag) and slide-level image payloads from an unchanged export, writes the theme's `a:ea` and `a:cs` only for slots a script font is selected for, and requires core `^0.11.3` (the 70 legacy gallery layout ids and their geometry; install it with renderer 0.11.6 and editor 0.10.4 so export and preview resolve the same core; no API removed). Version 0.11.3 adds the opt-in `toPptx({ chartex: 'native' })` export of the seven chartex chart types and always imports chartex charts (no API removed; the default output is unchanged). Version 0.11.2 exports the classic chart types as native chart constructs and writes named and default table colours, text pairs and hyperlink colours as theme references (no API or dependency change; resolved colours are unchanged). Version 0.11.1 exports `design.watermark` as a native picture (it re-imports) and embeds byte-identical media parts once, with no API or dependency change. Version 0.11.0 requires core `^0.11.2` (cover slides are centered between header and footer furniture) and the optional `@openpresentation/opf-render` peer `^0.11.0`; install them together so export and preview resolve the same core. Version 0.10.0 required core `^0.11.1` and the peer `^0.10.0`. It adds native slide-number and date fields, socials, slide-level image treatments, theme color schemes, script-slot fonts and RTL, re-import of design and metadata references, native underline and current-body formatting on import, and UTC-canonical `zipDate` (see the changelog for the intentional contract changes).
8
10
 
@@ -102,7 +104,7 @@ The first exporter keeps the public API stable while using `pptxgenjs` internall
102
104
  - Root payloads, `blocks[]`, and promoted region keys become editable slide objects in deterministic regions. Promoted keys use the OPF 3x3 region vocabulary (`top`, `middle`, `bottom`, `left`, `center`, `right`).
103
105
  - Text, lists, metrics, quotes, timelines, code, tables, and inline-data charts are emitted as editable PowerPoint text, table, and chart objects. Content ColorRef values (hex, scheme slots/roles, and `var:<id>`) resolve through core `resolveColorRef()`. Slot and role names become native `a:schemeClr` references when the exported theme holds that exact color; hex, `var:<id>` and slide-override colors stay `a:srgbClr` (see [Theme color scheme](#theme-color-scheme)). Each classic chart type the core catalog keeps (one per Aspose.Slides `ChartType`: clustered/stacked/100% stacked column and bar, line and stacked line with or without markers, area/stacked/100% area, pie, doughnut, scatter with markers, radar/radar with markers/filled radar) is written as its exact native construct; deprecated core ids export as their replacement; other ids keep the previous best-effort mapping. The chartex types treemap, histogram, pareto, box & whisker, waterfall and funnel are written as native Office 2016 chartex parts (confirmed in desktop PowerPoint on 2026-09-30; see [Chartex charts](#chartex-charts-ff-22b)); the map (`world`) stays a clustered column chart by default and reports `chart-data-adapted` (`chartex-fallback`) until PowerPoint accepts its regionMap part (`chartex: "native"` writes it anyway, `chartex: "fallback"` writes clustered columns for every chartex id). A pie or doughnut chart, or a one-series chartex construct, plots only its first series and reports `chart-data-adapted` (`series-dropped`) when it has more. Every classic chart part follows the `dml-chart.xsd` child sequences (opf-pptx#175): each series is written in its `CT_*Ser` order (a line series has `c:marker` before `c:dLbls`), a radar series carries no `c:invertIfNegative`, and the scatter X `c:valAx` carries no category-axis elements.
104
106
  - A timeline event's `status` (`done`, `current`, `planned`) is drawn from the deck's colors with core's shared shapes (the same as the preview): a filled `ellipse` marker for `done` and for no status, a `current` marker inside a ring (a second `ellipse`, tagged with its marker) with a bold label, and a `planned` marker that is an `ellipse` with the background fill and an accent outline, with muted text kept at 4.5:1 or more. Import restores `status` from the marker tags, not from the shapes' look, and drops the timeline provenance (keeping plain text) when a current event's ring is missing or duplicated.
105
- - A chart whose data is one column of values (a histogram or dot plot) has no category column. A histogram or Pareto chart bins the values itself (PowerPoint's histogram with an explicit automatic bin count, no diagnostic) and re-imports as authored; any other chart type plots the values against their row numbers and reports `chart-data-adapted` (`row-numbers`). With `chartex: "fallback"` a histogram is binned into equal-width bins (Sturges' count, at most 50) and written as a column chart of the counts (`histogram-binned`; the binned counts do not round-trip). Cells parse as in every chart (core `chartNumber`: numbers and plain decimal strings; "12%", "$5" and "1,234" are not numbers) and cells that hold no number are skipped, not plotted as 0. Chart data that cannot be plotted (no numbers, no rows, or an external data source), an empty table and unsupported content keep a placeholder frame with a plain-language description (never a dump of the source data or URLs) and report `chart-data-unplottable` or `content-placeholder` with a `reason`; content is never dropped without a diagnostic.
107
+ - A chart whose data is one column of values (a histogram or dot plot) has no category column. A histogram or Pareto chart bins the values itself (PowerPoint's histogram with an explicit automatic bin count, no diagnostic) and re-imports as authored; any other chart type plots the values against their row numbers and reports `chart-data-adapted` (`row-numbers`). Full provenance restores the authored data form while the native caches are unchanged; edited caches, `references-only` and no-provenance imports retain the observed row-number categories and values. With `chartex: "fallback"` a histogram is binned into equal-width bins (Sturges' count, at most 50) and written as a column chart of the counts (`histogram-binned`; the binned counts do not round-trip). Cells parse as in every chart (core `chartNumber`: numbers and plain decimal strings; "12%", "$5" and "1,234" are not numbers) and cells that hold no number are skipped, not plotted as 0. Chart data that cannot be plotted (no numbers, no rows, or an external data source), an empty table and unsupported content keep a placeholder frame with a plain-language description (never a dump of the source data or URLs) and report `chart-data-unplottable` or `content-placeholder` with a `reason`; content is never dropped without a diagnostic.
106
108
  - Image assets are embedded only when supplied as data URIs, local paths, or host-resolved bytes/paths. Remote asset URLs are never fetched by the runtime path. A picture's native description is the authored `alt` (never a stand-in such as `preencoded.png` or the file path), and a distinct asset `title` is the picture's native `title`; `fromPptx` reads both back.
107
109
  - A chart cell that holds no number (`null`, an empty or non-numeric string, a boolean) is a gap in the native chart: its cache has no point at that row (`c:ptCount` keeps the row count), its workbook cell is blank, and it re-imports as `null`. An actual zero stays zero.
108
110
  - ZIP entries, generated chart/workbook part names, core-property timestamps, and nested chart workbook timestamps are normalized for reproducible bytes. [Export determinism](docs/export-determinism.md) lists the tested time zone, locale, clock and host-font controls and each known variance (WebP conversion, font registries, ICU segmentation, timestamps).
@@ -478,6 +480,8 @@ Version 0.8.0 exports editable native lines with explicit four-space tab stops.
478
480
 
479
481
  Version 0.9.1 exports core's accepted header/footer text and picture geometry as editable tagged slide shapes. Since RR-11 the footer's first text, date and slide number are native PowerPoint Header & Footer placeholders ([core issue87](https://github.com/OpenPresentation/opf/issues/87), [native header and footer](docs/native-header-footer.md)); releases up to 0.11.7 wrote every part as a tagged shape. Dedicated `OPF_FURNITURE_V1` tags describe roles, line boundaries, inactive flags and global/local scope. A slide manifest represents empty or disabled definitions without adding a visible shape. Tags contain no original text, image bytes or alt text. Reimport reads those values from current native shapes; complete groups recover literal dates and the authored zone `text` with its slide-scoped tokens while the current words still match this slide.
480
482
 
483
+ A deck-level header or footer that every slide overrides or suppresses has no current native words to recover. Its dormant literal defaults are not stored or restored; each slide's disabled, empty or inactive definition still round-trips. Re-exporting the imported deck keeps those definitions suppressed. Keep the original OPF if you need to edit or reuse never-rendered global defaults later. Adding native furniture imports the new current content rather than bringing back those hidden defaults.
484
+
481
485
  A header or footer zone's `text` is the author's own string (FA-31): generated values are `{{ }}` variables in it. Each `{{slide.number}}` is a native PowerPoint slide-number field (`<a:fld type="slidenum">`) when its line fits one accepted text line, so PowerPoint renumbers it when slides move; the text around it (`"A-{{slide.number}}"`, `"Page {{slide.number}} of {{deck.slideCount}}"`) is ordinary runs. `{{deck.slideCount}}` (the exported slide count after pagination) and `{{slide.section}}` are fixed text, because PowerPoint has no slide-count or section field; the deck-wide variables (`{{organization.name}}`, `{{customer}}`) resolve before export like any other string. In body text every slide-scoped token is fixed text with the slide's value. Each slide is composed and drawn from core's substituted slide, so text is measured with the real value. A current date (`date: true`) needs the host's `date` option (ISO `YYYY-MM-DD`); it becomes a native `datetime1`–`datetime7` field when its `dateFormat` matches that en-US field type, and otherwise fixed text with a `furniture-date-fixed` diagnostic. An eligible date or number range that spans accepted text lines stays static and reports `furniture-field-fixed` at its source path; no native field is created by merging shapes. A string date with `dateFormat` is fixed text. The slide manifest records `dateFormat` settings and, for a zone whose `{{ }}` tokens are all built-in variables, that authored text as given to `toPptx`, before the deck-wide pass (`templates`); it never records rendered dates. Reimport keeps a format or the authored text only while the current native text still matches it (a fixed date that parses back to the same ISO date; the authored text drawn for this slide equals the current words: deck-wide built-ins resolved from the stored organization and speakers, then the slide's number, count and section), and a live date keeps the pattern of its current field type. Otherwise the current words import as literal text, with each native slide-number field as `{{slide.number}}` (typed words that spell a token are escaped as `\{{`). Hiding furniture on a title slide is a slide-level `design.footer: false`/`design.header: false`, which round-trips as before.
482
486
 
483
487
  When a supported current-date field wraps across lines, full provenance records a versioned static-date reason, its format and exact line fingerprints, without cached date words. Complete unchanged soft-wrapped lines can then recover `date: true` and the authored format on reimport, so a later OPF export can use a new host date. The original PPTX date remains static: this does not establish native refresh or Office compatibility. Actual current native date fields take precedence. Edited or cleared text, damaged evidence, older unmarked exports, `references-only` and `provenance: false` remain literal/current-content fallbacks. Fingerprints detect changed text; writable tags are not authentication. Native formatting and geometry are not reconstructed.
@@ -1,3 +1,4 @@
1
+ import {sourceRunStyles, validateSourceRunStyles, restoreSourceRunStyles} from './source-run-styles.js';
1
2
  // Content topology (spec-gap closure P1): the structure of a slide's content
2
3
  // survives a PPTX round trip through the OPF_SLIDE_V1 record.
3
4
  //
@@ -286,6 +287,7 @@ export function contentTopology(slide, items, slideIndex, report = () => {}) {
286
287
  const box = leafBox(path);
287
288
  if (box) leaf.box = box;
288
289
  if (block.bullets !== undefined && block.items === undefined && ['list', 'text'].includes(kind)) leaf.bullets = true;
290
+ if (kind === 'text') { const styles = sourceRunStyles(block.text); if (styles) leaf.sourceStyles = styles; }
289
291
  if (kind === 'text' && wrapped.has(`${path}.text`)) Object.assign(leaf, wrapped.get(`${path}.text`));
290
292
  return leaf;
291
293
  };
@@ -302,7 +304,8 @@ export function contentTopology(slide, items, slideIndex, report = () => {}) {
302
304
  const fields = ROOT_PAYLOAD_FIELDS.filter(name => slide[name] !== undefined).map(field => {
303
305
  const box = boxes.get(`${base}.${field}`);
304
306
  const lines = field === 'text' ? wrapped.get(`${base}.text`) : undefined;
305
- return box ? {field, box: [box.x, box.y, box.width, box.height].map(round1), ...lines} : {field};
307
+ const sourceStyles = field === 'text' ? sourceRunStyles(slide.text) : undefined;
308
+ return box ? {field, ...(sourceStyles ? {sourceStyles} : {}), box: [box.x, box.y, box.width, box.height].map(round1), ...lines} : {field, ...(sourceStyles ? {sourceStyles} : {})};
306
309
  });
307
310
  if (!fields.length) return undefined;
308
311
  if (fields.length === 1) return {form: 'root', ...fields[0]};
@@ -367,6 +370,43 @@ export function listLineBreaks(items, omit = () => {}) {
367
370
  return lists.length && lists.length <= MAX_LIST_PATHS && total <= MAX_LIST_BREAKS ? lists : undefined;
368
371
  }
369
372
 
373
+ // Line identity only: no source words or formatting. Each row is [entry, role, first].
374
+ export function listLineRoles(items, omit = () => {}) {
375
+ const lists = [];
376
+ for (const item of items ?? []) {
377
+ if (!['items', 'bullets'].includes(item?.field) || !Array.isArray(item?.text?.listEntries)) continue;
378
+ if (!item.text.listEntries.some(entry => entry.description?.richLines?.length)) continue;
379
+ const lines = [];
380
+ item.text.listEntries.forEach((entry, index) => {
381
+ for (const [role, text] of [['text', entry.text], ['description', entry.description]])
382
+ text?.richLines?.forEach((_, line) => lines.push([index, role, role === 'text' && line === 0]));
383
+ });
384
+ if (lines.length) lists.push([item.path, lines]);
385
+ }
386
+ if (!lists.length) return undefined;
387
+ try { return validateListRoles(lists); } catch {
388
+ for (const [path] of lists) omit(path, 'list line roles exceed the bounded record or cannot be recorded exactly');
389
+ return undefined;
390
+ }
391
+ }
392
+
393
+ export function validateListRoles(value) {
394
+ if (!Array.isArray(value) || !value.length || value.length > MAX_LIST_PATHS) throw Error('Invalid list roles.');
395
+ const paths = new Set(); let total = 0;
396
+ for (const row of value) {
397
+ if (!Array.isArray(row) || row.length !== 2 || typeof row[0] !== 'string' || !LIST_PATH.test(row[0]) || paths.has(row[0]) || !Array.isArray(row[1]) || !row[1].length) throw Error('Invalid list roles path.');
398
+ paths.add(row[0]); let entry = -1, description = false;
399
+ for (const line of row[1]) {
400
+ if (!Array.isArray(line) || line.length !== 3 || !Number.isSafeInteger(line[0]) || !['text', 'description'].includes(line[1]) || typeof line[2] !== 'boolean') throw Error('Invalid list role.');
401
+ if (line[2]) { if (line[0] !== entry + 1 || line[1] !== 'text') throw Error('Invalid list entry.'); entry++; description = false; }
402
+ else if (line[0] !== entry || entry < 0 || (description && line[1] === 'text')) throw Error('Invalid list continuation.');
403
+ if (line[1] === 'description') description = true;
404
+ if (++total > MAX_LIST_BREAKS) throw Error('Too many list roles.');
405
+ }
406
+ }
407
+ return value;
408
+ }
409
+
370
410
  /** Throws when `value` is not a well-formed `lists` record (untrusted input). Returns it otherwise. */
371
411
  export function validateListBreaks(value) {
372
412
  if (!Array.isArray(value) || !value.length || value.length > MAX_LIST_PATHS) throw Error('Invalid list breaks record.');
@@ -429,9 +469,9 @@ const validWrapOf = value => validLines(value.lines) && validWrap(value.wrap) &&
429
469
  export const wrapOf = node => node.wrap ?? (node.lines === undefined ? undefined : {lines: node.lines});
430
470
  /** The leaf fields that record `wrapped` ({lines, gaps?} from wrappedLines). */
431
471
  const wrapRecord = wrapped => wrapped.gaps ? {wrap: wrapped} : {lines: wrapped.lines};
432
- const validField = value => object(value) && ROOT_PAYLOAD_FIELDS.includes(value.field) && validBox(value.box) && validWrapOf(value);
472
+ const validField = value => { if (value?.sourceStyles !== undefined) validateSourceRunStyles(value.sourceStyles); return object(value) && ROOT_PAYLOAD_FIELDS.includes(value.field) && validBox(value.box) && validWrapOf(value); };
433
473
  /** The root payload leaves of a root-form record: [{field, box?}]. */
434
- export const rootFields = topology => topology.fields ?? [{field: topology.field, box: topology.box, lines: topology.lines, wrap: topology.wrap}];
474
+ export const rootFields = topology => topology.fields ?? [{field: topology.field, box: topology.box, lines: topology.lines, wrap: topology.wrap, sourceStyles: topology.sourceStyles}];
435
475
 
436
476
  /** Throws when `value` is not a well-formed topology record. Returns it otherwise. */
437
477
  export function validateTopology(value) {
@@ -456,6 +496,7 @@ export function validateTopology(value) {
456
496
  if (item.comp !== undefined) throw Error('A content leaf has no composition.');
457
497
  if (!validBox(item.box)) throw Error('Invalid content box.');
458
498
  if (item.bullets !== undefined && (item.bullets !== true || !['list', 'text'].includes(item.k))) throw Error('Invalid bullets flag.');
499
+ if (item.sourceStyles !== undefined) validateSourceRunStyles(item.sourceStyles);
459
500
  if (!validWrapOf(item)) throw Error('Invalid wrapped line record.');
460
501
  };
461
502
  switch (value.form) {
@@ -517,7 +558,7 @@ const textRuns = value => typeof value === 'string' || (Array.isArray(value) &&
517
558
  * {reason} when a block matches no leaf or several blocks land on one leaf.
518
559
  * Leaves with no block are dropped: empty payloads export nothing.
519
560
  */
520
- export function rebuildContent(topology, blocks, bounds, placed) {
561
+ export function rebuildContent(topology, blocks, bounds, placed, intactStyles = {}) {
521
562
  if (!Array.isArray(blocks) || !Array.isArray(bounds) || blocks.length !== bounds.length) return {reason: 'the imported blocks carry no native bounds'};
522
563
  const leaves = [];
523
564
  const collect = item => {
@@ -571,6 +612,7 @@ export function rebuildContent(topology, blocks, bounds, placed) {
571
612
  const payloadOf = leaf => {
572
613
  if (!leaf.matches.length) return undefined;
573
614
  const payload = clone(leaf.payload ?? blocks[leaf.matches[0]]);
615
+ if (leaf.kind === 'text' && leaf.node.sourceStyles) payload.text = restoreSourceRunStyles(payload.text, leaf.node.sourceStyles, intactStyles);
574
616
  // The imported block always names its type; the authored one may have left it implicit.
575
617
  if (!leaf.node.typed) delete payload.type;
576
618
  // The imported block names every list `items`; a payload authored as `bullets` takes its key back (a bullet has no description).
@@ -211,9 +211,12 @@ export function tableEnvironmentEvidence(entries) {
211
211
  /** The hash of a dataset, or undefined when the map does not hold the id. */
212
212
  const datasetHash = (datasets, id) => own(datasets, id) ? hash(canonical(datasets[id])) : undefined;
213
213
 
214
- /** The record of a chart that uses an RR-54 data field, or undefined (the frame then carries no tag). */
215
- export function chartDataRecord(chart, datasets) {
216
- if (!chartUsesDataFields(chart) && !object(chart?.highlight)) return undefined;
214
+ // Synthetic categories are recorded only when the exporter actually applied
215
+ // row-numbers. Binned histogram fallbacks do not opt in: their native values are
216
+ // counts, so restoring raw samples would give the imported column chart other data.
217
+ /** Authored chart data whose source form the native cache alone cannot preserve. */
218
+ export function chartDataRecord(chart, datasets, {rowNumbers = false} = {}) {
219
+ if (!rowNumbers && !chartUsesDataFields(chart) && !object(chart?.highlight)) return undefined;
217
220
  const dataset = isDatasetRef(chart.data) ? datasetHash(datasets, chart.data.dataset) : undefined;
218
221
  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} : {})};
219
222
  }
@@ -415,7 +418,7 @@ const validWith = (payload, datasets) => isValidFormat({slides: [{title: 'Data',
415
418
  */
416
419
  export function restoreChartData(chart, record, evidence, {datasets, report}) {
417
420
  if (!record.evidence.includes(evidence)) {
418
- 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.' : '')});
421
+ 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 authored data form, 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.' : '')});
419
422
  return chart;
420
423
  }
421
424
  if (isDatasetRef(record.data) && !own(datasets, record.data.dataset)) {
@@ -1,6 +1,7 @@
1
+ import {nativeRunStyleEvidence, hasSourceRunStyles} from './source-run-styles.js';
1
2
  import {XMLParser} from 'fast-xml-parser';
2
3
  import {decodeTextTag, encodeTextTag} from './code-provenance.js';
3
- import {contentTopology, rebuildContent, validateTopology, listLineBreaks, validateListBreaks, validateListRuns, MAX_LIST_PATHS} from './content-topology.js';
4
+ import {contentTopology, rebuildContent, validateTopology, listLineBreaks, validateListBreaks, validateListRuns, listLineRoles, validateListRoles, MAX_LIST_PATHS} from './content-topology.js';
4
5
  import {runColorRecord, validateRunColors} from './run-colors.js';
5
6
  import {validateCatalogRecord} from '@openpresentation/opf';
6
7
  import {parseReference, resolveReference, resolveSocialProfile} from '@openpresentation/opf/composition';
@@ -315,7 +316,12 @@ function styleSignature(tree) {
315
316
  return canonical({faces: [...faces].sort(), colors: [...colors].sort()});
316
317
  }
317
318
 
318
- function nativeSlide(entries, path) {
319
+ function nativeListIdentity(entries, path) {
320
+ const tree = parser.parse(dec.decode(entries[path]))?.['p:sld']?.['p:cSld']?.['p:spTree'];
321
+ return hash(canonical(array(tree?.['p:sp']).filter(node => /^OPF list /.test(node?.['p:nvSpPr']?.['p:cNvPr']?.name ?? '')).map(node => [node['p:nvSpPr']['p:cNvPr'].name, box(node?.['p:spPr']?.['a:xfrm'])])));
322
+ }
323
+
324
+ function nativeSlide(entries, path, includeRunStyles = true) {
319
325
  const root = parser.parse(dec.decode(entries[path]))?.['p:sld'];
320
326
  const cSld = root?.['p:cSld'], tree = cSld?.['p:spTree'];
321
327
  const rels = relationships(entries, path);
@@ -327,6 +333,7 @@ function nativeSlide(entries, path) {
327
333
  const background = backgroundObjects.length ? {bg: cSld?.['p:bg'] ?? null, objects: backgroundObjects} : cSld?.['p:bg'] ?? null;
328
334
  return {
329
335
  structure: hash(structure(tree)),
336
+ ...(includeRunStyles ? {runStyles: nativeRunStyleEvidence(withoutExtensions(tree ?? null), hash)} : {}),
330
337
  background: hash(canonical(withResolvedRelationships(withoutExtensions(background), rels, entries))),
331
338
  style: hash(styleSignature(withoutExtensions(tree ?? null)))
332
339
  };
@@ -545,6 +552,8 @@ export function recordContentTopology(provenance, slide, index, items) {
545
552
  const omittedLists = [];
546
553
  const lists = listLineBreaks(items, (path, reason) => omittedLists.push([path, reason]));
547
554
  if (lists) provenance.slides[index].lists = lists;
555
+ const roles = listLineRoles(items, (path, reason) => omittedLists.push([path, reason]));
556
+ if (roles) provenance.slides[index].listRoles = roles;
548
557
  if (omittedLists.length) provenance.slides[index].omittedLists = omittedLists;
549
558
  }
550
559
 
@@ -709,7 +718,7 @@ function storable(entries, provenance) {
709
718
  const result = {v: 1, slide: index};
710
719
  if (record.omittedContent) omit(`slides.${index}.content`, record.omittedContent);
711
720
  for (const [path, reason] of record.omittedLists ?? []) omit(path, reason);
712
- for (const key of ['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'colors', 'lists', 'listRuns']) {
721
+ for (const key of ['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'colors', 'lists', 'listRuns', 'listRoles']) {
713
722
  if (record[key] === undefined) continue;
714
723
  const value = prepare(`slides.${index}.${key}`, record[key]);
715
724
  if (value !== undefined) result[key] = value;
@@ -746,7 +755,7 @@ function storable(entries, provenance) {
746
755
  }
747
756
  for (const [index, record] of slides.entries()) {
748
757
  while (tagChars(record) > budget) {
749
- const candidates = [...['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'colors', 'lists', 'listRuns', 'layoutRecord'].filter(key => record[key] !== undefined).map(key => ({key, size: sizeOf(record[key])})),
758
+ const candidates = [...['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'colors', 'lists', 'listRuns', 'listRoles', 'layoutRecord'].filter(key => record[key] !== undefined).map(key => ({key, size: sizeOf(record[key])})),
750
759
  ...Object.keys(record.design ?? {}).map(key => ({key, design: true, size: sizeOf(record.design[key])}))];
751
760
  if (!candidates.length) break;
752
761
  const largest = candidates.sort((a, b) => b.size - a.size)[0];
@@ -796,7 +805,7 @@ export function attachDocumentProvenance(entries, provenance) {
796
805
 
797
806
  for (const [index, path] of paths.entries()) {
798
807
  const slideHashes = mediaHashes(prepared.slides[index], entries);
799
- const record = toStoredShape({...prepared.slides[index], ...(Object.keys(slideHashes).length ? {mediaHash: slideHashes} : {}), native: nativeSlide(entries, path)}, SLIDE_SUPPLEMENT);
808
+ const record = toStoredShape({...prepared.slides[index], ...(Object.keys(slideHashes).length ? {mediaHash: slideHashes} : {}), native: {...nativeSlide(entries, path, hasSourceRunStyles(prepared.slides[index].content)), ...(prepared.slides[index].listRoles ? {listIdentity: nativeListIdentity(entries, path)} : {})}}, SLIDE_SUPPLEMENT);
800
809
  const tag = `<p:tag name="${SLIDE_TAG}" val="${encodeTextTag(record)}"/>`;
801
810
  let xml = dec.decode(entries[path]);
802
811
  const slideRels = relsPath(path);
@@ -872,6 +881,7 @@ function validateDocument(stored) {
872
881
 
873
882
  function validateSlide(stored) {
874
883
  if (!object(stored) || stored.v !== 1 || !Number.isSafeInteger(stored.slide) || stored.slide < 0) throw Error('Unsupported slide provenance version.');
884
+ if (stored.native?.runStyles !== undefined && (!object(stored.native.runStyles) || Object.keys(stored.native.runStyles).length !== 3 || !['fontSize', 'fontFamily', 'color'].every(key => /^[a-f0-9]{14}$/.test(stored.native.runStyles[key])))) throw Error('Invalid native run style evidence.');
875
885
  if (!object(stored.native) || typeof stored.native.structure !== 'string' || typeof stored.native.background !== 'string' || typeof stored.native.style !== 'string') throw Error('Missing slide native evidence.');
876
886
  if (stored.design !== undefined && !object(stored.design)) throw Error('Invalid slide design record.');
877
887
  for (const key of SLIDE_SUPPLEMENT.design) if (object(stored.design) && stored.design[key] !== undefined) throw Error(`Supplement field design.${key} stored in the legacy section.`);
@@ -886,6 +896,7 @@ function validateSlide(stored) {
886
896
  if (value.colors !== undefined) validateRunColors(value.colors);
887
897
  if (value.lists !== undefined) validateListBreaks(value.lists);
888
898
  if (value.listRuns !== undefined) validateListRuns(value.listRuns);
899
+ if (value.listRoles !== undefined) { validateListRoles(value.listRoles); if (!/^[a-f0-9]{14}$/.test(value.native.listIdentity ?? '')) throw Error('Invalid native list identity.'); }
889
900
  validateOmitted(value.omitted);
890
901
  return value;
891
902
  }
@@ -931,6 +942,8 @@ function pruneDangling(value, dangling, path, removed) {
931
942
  * unreadable, or its arrangement changed since export (the lines are then no longer the exported ones);
932
943
  * - `runs` (opf-pptx#212): the look export wrote on every run of each list, Map(list path -> {text, description?}), or null. It
933
944
  * does not depend on the arrangement: import compares each run value with it, so an edited value is still observed.
945
+ * - `roles`: bounded line identities for lists with descriptions, only when native structure and named line positions/order
946
+ * still match. No words or style values are stored; those always come from the current native paragraphs.
934
947
  * Any damage is reported later by restoreDocumentProvenance.
935
948
  */
936
949
  export function slideListRecord(entries, path, root, rels) {
@@ -940,7 +953,8 @@ export function slideListRecord(entries, path, root, rels) {
940
953
  const record = validateSlide(found.value);
941
954
  const runs = record.listRuns === undefined ? null : new Map(record.listRuns);
942
955
  const breaks = record.lists === undefined || record.native.structure !== nativeSlide(entries, path).structure ? null : new Map(record.lists.map(([list, lines]) => [list, new Map(lines)]));
943
- return {breaks, runs};
956
+ const roles = record.listRoles === undefined || record.native.structure !== nativeSlide(entries, path).structure || record.native.listIdentity !== nativeListIdentity(entries, path) ? null : new Map(record.listRoles);
957
+ return {breaks, runs, roles};
944
958
  } catch {
945
959
  return {breaks: null, runs: null};
946
960
  }
@@ -1025,7 +1039,7 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
1025
1039
  return false;
1026
1040
  }
1027
1041
  let placedPaths;
1028
- const result = rebuildContent(record.content, imported.slides[index]?.blocks ?? [], slides[index]?.contentBounds ?? [], paths => { placedPaths = paths; });
1042
+ const result = rebuildContent(record.content, imported.slides[index]?.blocks ?? [], slides[index]?.contentBounds ?? [], paths => { placedPaths = paths; }, Object.fromEntries(['fontSize', 'fontFamily', 'color'].map(key => [key, record.native.runStyles?.[key] !== undefined && record.native.runStyles[key] === observed.runStyles?.[key]])));
1029
1043
  if (result.reason) {
1030
1044
  report({code: 'content-structure-changed', path: `slides.${index}`, message: `The slide's content no longer matches its stored structure (${result.reason}), so the authored groups, regions and block ids of slides.${index} were not restored; the imported slide keeps its flat blocks.${typeNote}`});
1031
1045
  return false;
package/dist/index.js CHANGED
@@ -576,6 +576,7 @@ export async function fromPptx(input, options = {}) {
576
576
  restoreCitations(imported, furnitureContexts.map(context => context.annotations?.notes ?? []), report);
577
577
  restoreLogoFallback(imported, furnitureContexts);
578
578
  applyRunColors(imported, slideProvenance, restoredGroups, report, options);
579
+ applyNativeImageFits(imported, furnitureContexts, restoredGroups, options);
579
580
  if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
580
581
  languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
581
582
 
@@ -879,8 +880,8 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
879
880
  }
880
881
  }
881
882
  const listRecord = slideListRecord(entries, slidePath, slideRoot, relationships);
882
- const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items, listRecord.breaks, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`}), listRecord.runs))
883
- .map((item) => ({payload: payloadFromSlideItem(item, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`})), bounds: item.visualBounds ?? item.bounds, sources: item.sources ?? []}))
883
+ const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items, listRecord.breaks, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`}), listRecord.runs, listRecord.roles))
884
+ .map((item) => ({payload: payloadFromSlideItem(item, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`})), bounds: item.visualBounds ?? item.bounds, sources: item.sources ?? [], inferredStretch: item.inferredStretch}))
884
885
  .filter((entry) => entry.payload);
885
886
  const blocks = content.map((entry) => entry.payload);
886
887
  // RR-81: the region of each block read from one region placeholder of the slide's 0.19 template layout (its p:ph idx).
@@ -921,6 +922,7 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
921
922
  // Which native shapes fed each field and block (import signals); never part of the document.
922
923
  nativeContext.signalSources = {tag: tagItem?.sources ?? [], title: titleItem?.sources ?? [], subtitle: subtitleItem?.sources ?? [],
923
924
  blocks: content.map(entry => ({sources: entry.sources, type: entry.payload.type})), roles: nativeContext.signalRoles ?? new Map()};
925
+ nativeContext.inferredImageFits = content.flatMap((entry,index)=>entry.inferredStretch?[index]:[]);
924
926
  nativeContext.contentBounds = content.map((entry) => entry.bounds ? {x: entry.bounds.x * 96, y: entry.bounds.y * 96, width: entry.bounds.w * 96, height: entry.bounds.h * 96} : null);
925
927
 
926
928
  const notes = readSlideNotes(entries, relationships);
@@ -1047,6 +1049,8 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
1047
1049
  const report = diagnostic => { if (!(treatment && diagnostic.code === 'unsupported-image-crop')) options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`}); };
1048
1050
  const item = importPicture(entries, picture, slidePath, relationships, report);
1049
1051
  if (item?.payload && treatment) item.payload = {...item.payload, ...structuredClone(treatment)};
1052
+ else if (item?.nativeStretch) { item.payload.fit='stretch'; item.inferredStretch=true; }
1053
+ else if (item?.nativeFitUnsupported) report({code:'unsupported-image-fit',message:'Native picture tiling or an offset stretch fill is not represented by the imported image block; review the reflowed image.'});
1050
1054
  if (item) items.push({...item, sources: [`pic:${index}`]});
1051
1055
  }
1052
1056
 
@@ -1202,6 +1206,8 @@ function importPicture(entries, picture, slidePath, relationships, report) {
1202
1206
 
1203
1207
  return {
1204
1208
  kind: "image",
1209
+ nativeStretch: nativePictureStretch(picture, bytes, bounds, Boolean(svg)),
1210
+ nativeFitUnsupported: picture['p:blipFill']?.['a:tile']!==undefined || ['l','r','t','b'].some(key=>Number(picture['p:blipFill']?.['a:stretch']?.['a:fillRect']?.[key]??0)!==0),
1205
1211
  bounds,
1206
1212
  visualBounds: visualBounds(picture["p:spPr"]?.["a:xfrm"]),
1207
1213
  name,
@@ -1216,6 +1222,46 @@ function importPicture(entries, picture, slidePath, relationships, report) {
1216
1222
  };
1217
1223
  }
1218
1224
 
1225
+ // A full-source native stretch is distinguishable from cover only when the
1226
+ // intrinsic aspect differs from its frame. No crop, tile, offset fill or turn is
1227
+ // inferred; those require their existing diagnostics/separate fidelity review.
1228
+ function nativePictureStretch(picture,bytes,bounds,svg) {
1229
+ const fill=picture['p:blipFill'],crop=fill?.['a:srcRect'],stretch=fill?.['a:stretch'];
1230
+ const rect=stretch?.['a:fillRect'],transform=picture['p:spPr']?.['a:xfrm'];
1231
+ const metadata=svg?null:rasterMetadata(bytes);
1232
+ if(!bounds||!(bounds.w>0&&bounds.h>0)||!metadata||!(metadata.width>0&&metadata.height>0)||metadata.orientation>1) return false;
1233
+ if(stretch===undefined||fill?.['a:tile']!==undefined||Number(transform?.rot??0)!==0) return false;
1234
+ if([crop,rect].some(value=>value&&['l','r','t','b'].some(key=>Number(value[key]??0)!==0)))return false;
1235
+ // Compare dimensions in native EMUs, without a new geometry tolerance.
1236
+ const ext=transform?.['a:ext'];
1237
+ const cx=Number(ext?.cx),cy=Number(ext?.cy);
1238
+ if(!Number.isFinite(cx)||!Number.isFinite(cy))return false;
1239
+ // Equal-aspect dimensions can round by one EMU when serialized. A dimension
1240
+ // reproduced by rounding the intrinsic ratio is still the same aspect.
1241
+ return Math.round(cx*metadata.height/metadata.width)!==cy&&Math.round(cy*metadata.width/metadata.height)!==cx;
1242
+ }
1243
+
1244
+ // Provenance may rebuild flat blocks into groups or scalar fields. Remove only
1245
+ // our inferred fit when restored design already supplies stretch, preserving an
1246
+ // unchanged authored default rather than adding an unsolicited block override.
1247
+ function applyNativeImageFits(imported,contexts,groups,options) {
1248
+ for(const [slideIndex,context] of contexts.entries()) {
1249
+ if(!context.inferredImageFits?.length)continue;
1250
+ const slide=imported.slides[slideIndex];
1251
+ const resolved=resolveCoreSlideContext(imported,slideIndex,options.catalogs!==undefined?{catalogs:options.catalogs}:{}).resolved;
1252
+ const fit=slide.design?.imageFit??imported.design?.imageFit??resolved.theme?.design?.imageFit??resolved.layout?.design?.imageFit??'cover';
1253
+ const restored=groups.find(group=>group.applied&&group.contentPaths?.slide===slideIndex)?.contentPaths?.paths;
1254
+ for(const index of context.inferredImageFits) {
1255
+ const path=restored?.get(index)??`blocks.${index}`;
1256
+ const value=path.split('.').reduce((item,key)=>item?.[key],slide);
1257
+ if(value&&typeof value==='object'&&value.image!==undefined) {
1258
+ if(fit==='stretch')delete value.fit;
1259
+ else value.fit='stretch';
1260
+ }
1261
+ }
1262
+ }
1263
+ }
1264
+
1219
1265
  function readParagraphs(txBody) {
1220
1266
  return withDisplayedNumbers(asArray(txBody?.["a:p"])
1221
1267
  .map((paragraph) => {
@@ -1332,7 +1378,7 @@ function joinRich(a, b) {
1332
1378
  // `runs` (opf-pptx#212) holds the look export wrote on every run of a list ({text, description?}): a run value still equal to
1333
1379
  // it is inherited formatting, not authored run style, and is removed (as for a tagged heading), so an untouched round trip
1334
1380
  // adds no explicit sizes, families or colours; a value edited in PowerPoint differs and is kept. Without it runs are as read.
1335
- function mergeOpfListShapes(items, breaks = null, report = () => {}, runs = null) {
1381
+ function mergeOpfListShapes(items, breaks = null, report = () => {}, runs = null, roles = null) {
1336
1382
  const lists = new Map();
1337
1383
  for (const item of items) {
1338
1384
  const match = /^OPF list (.+) line (\d+)$/.exec(item.name ?? '');
@@ -1345,6 +1391,10 @@ function mergeOpfListShapes(items, breaks = null, report = () => {}, runs = null
1345
1391
  for (const [path, entries] of lists) {
1346
1392
  entries.sort((a, b) => a.line - b.line);
1347
1393
  if (!entries[0].item.paragraphs[0].bullet) continue;
1394
+ const expectedRoles = roles?.get(path);
1395
+ const validRoles = expectedRoles && expectedRoles.length === entries.length && entries.every(({item, line}, index) => line === index && Boolean(item.paragraphs[0].bullet) === expectedRoles[index][2]);
1396
+ const lineRoles = validRoles ? expectedRoles : null;
1397
+ if (expectedRoles && !validRoles) report({code: 'list-line-role-changed', message: `The line topology of ${path} changed; list roles were not restored.`});
1348
1398
  const look = runs?.get(path), textLook = cleanBase(look?.text), descriptionLook = cleanBase(look?.description);
1349
1399
  // The line's runs without the inherited look, or the paragraph's own richText when there is no record for this kind of line.
1350
1400
  // Each run is cleaned on its own and none merge, so the runs stay the slots the run colour record counts (src/run-colors.js);
@@ -1374,7 +1424,7 @@ function mergeOpfListShapes(items, breaks = null, report = () => {}, runs = null
1374
1424
  const join = (a, b) => gap === undefined ? joinRich(a, b) : joinWrappedText([a, b], [gap]);
1375
1425
  if (paragraph.bullet) { paragraphs.push({...paragraph, richText: own(paragraph, textLook)}); size = paragraph.maxFontSize; }
1376
1426
  else if (paragraph.text === '') { /* a blank line of the entry: no text, no description */ }
1377
- else if (paragraph.maxFontSize < size - 0.01) {
1427
+ else if (lineRoles ? lineRoles[line][1] === 'description' : paragraph.maxFontSize < size - 0.01) {
1378
1428
  const text = own(paragraph, descriptionLook) ?? paragraph.text;
1379
1429
  if (current.description === undefined) current.description = text;
1380
1430
  else { current.description = join(current.description, text); applied.add(line); }
@@ -1885,7 +1935,7 @@ function resolvePresentationContext(presentation, options, core = coreContext({.
1885
1935
  // The primary color, which the exporter draws accent fills (table headers, tag, media badge) in. The `accent` ColorRef role is roles.accent.
1886
1936
  accent: normalizeHex(roles.primary),
1887
1937
  surface: normalizeHex(roles.surface),
1888
- border: normalizeHex(colorScheme.accent5 ?? "#CBD5E1")
1938
+ border: normalizeHex(roles.border)
1889
1939
  },
1890
1940
  variables
1891
1941
  };
@@ -2138,7 +2188,7 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
2138
2188
  const surface=itemContext.colorScheme.surface??itemContext.colorScheme[itemContext.roles.dark?'dark2':'light2']??`#${itemContext.colors.surface}`;
2139
2189
  slide.addShape('roundRect',{x:frame.x/96,y:frame.y/96,w:frame.width/96,h:frame.height/96,
2140
2190
  rectRadius:8*Math.min(widthInches,heightInches)/720,
2141
- fill:paint(surface),line:{...paint(itemContext.colorScheme.accent5??`#${itemContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
2191
+ fill:paint(surface),line:{...paint(`#${itemContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
2142
2192
  }
2143
2193
  if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
2144
2194
  addMeasuredPayloadText(slide,item.value,item.box,itemContext,options,{path:item.path,fit:item.text,textStyle:item.textStyle,sourceText:item.field==='text'&&!!item.text.sourceLines,align:item.alignment,diagnosticsHandled:true,heading:['title','subtitle','tag'].includes(item.field)?item.field:undefined,color:item.field==='tag'?tagColor(itemContext):itemContext.textColor});
@@ -2178,7 +2228,7 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
2178
2228
  } else {
2179
2229
  // RR-34: a captioned item draws its media in item.box; the caption band follows as tagged text boxes linked to the media shape by name.
2180
2230
  const mediaNames = item.caption ? new Set([...context.images.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()]) : undefined;
2181
- await addPayload(slide, presentation, item.payload, region, item.path, { ...itemContext, composition: item.composition, contentAlignment: item.alignment ?? "left", direction: geometry.direction }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
2231
+ await addPayload(slide, presentation, item.payload, region, item.path, { ...itemContext, composition: item.composition, contentAlignment: item.alignment ?? "left", tableAlignment: geometry.design.contentAlignment, direction: geometry.direction }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
2182
2232
  if (item.caption) {
2183
2233
  const mediaName = item.field === 'video' ? `OPF media ${item.path} frame` : [...context.images.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()].find(name => !mediaNames.has(name));
2184
2234
  addCaption(slide, item, mediaName, itemContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: item.caption.path}));
@@ -2675,7 +2725,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2675
2725
  labels: !chartOptions?.dataLabels?.content?.includes('percent'), scatter: chartData.type === 'scatter'};
2676
2726
  context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading,labelColor,spec:chartData.spec,textSize,
2677
2727
  options:chartOptions,kind:chartTargetFor(chart.type)?.kind,pointCount:chartData.pointCount,numberFormats});
2678
- recordPayloadData(context, objectName, 'chart', presentation, path);
2728
+ // Synthetic row-number categories are a native adaptation, not an authored
2729
+ // data column. Full provenance restores the source form only on unchanged caches.
2730
+ recordPayloadData(context, objectName, 'chart', presentation, path, undefined, {rowNumbers: chart.data?.columns?.length === 1 && chartData.adaptations?.some(entry => entry.adaptation === 'row-numbers') === true});
2679
2731
  context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
2680
2732
  // FA-09: the chart's text alternative (the frame's descr; the empty string is PowerPoint's decorative marker), applied before the chartex frame copies the head.
2681
2733
  if (typeof chart.alt === 'string') context.frameAlts.set(objectName, chart.alt);
@@ -2728,6 +2780,11 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2728
2780
  dataLabelFontSize: textSize / 100,
2729
2781
  showValue: false,
2730
2782
  valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
2783
+ // Magnitude bars/columns/areas must not let Office auto-truncate a near-equal range.
2784
+ ...(chartData.spec.family === "category" && ["bar", "area"].includes(chartData.type) && chartData.spec.grouping !== "percentStacked" ? {
2785
+ ...(chartData.series.every(entry => entry.values.every(value => value == null || value >= 0)) ? {valAxisMinVal: 0} : {}),
2786
+ ...(chartData.series.some(entry => entry.values.some(value => value < 0)) && chartData.series.every(entry => entry.values.every(value => value == null || value <= 0)) ? {valAxisMaxVal: 0} : {})
2787
+ } : {}),
2731
2788
  barDir: chartData.barDir,
2732
2789
  barGrouping: chartData.barGrouping,
2733
2790
  // Right to left (RR-05): column, line and area charts reverse their categories (c:catAx orientation maxMin), which also moves the
@@ -2774,11 +2831,11 @@ function comboChart(chartData, options, {palette, chartOptions, labelColor, font
2774
2831
  // RR-54: a chart or table that uses a dataset, DataColumn objects, a mapping, a data source or number formats records its
2775
2832
  // authored data form on its native frame (src/data-provenance.js), in `full` provenance mode only. `path` is the composed
2776
2833
  // item's path ("slides.0.blocks.1.chart"), which names the authored value (composition hands over the inline copy).
2777
- function recordPayloadData(context, objectName, kind, presentation, path, layout) {
2834
+ function recordPayloadData(context, objectName, kind, presentation, path, layout, chartRecordOptions) {
2778
2835
  if (context.provenanceMode !== 'full' || !context.dataRecords || !presentation) return;
2779
2836
  let authored = presentation;
2780
2837
  for (const key of String(path).split('.')) authored = authored !== null && typeof authored === 'object' ? authored[Array.isArray(authored) ? Number(key) : key] : undefined;
2781
- const record = kind === 'chart' ? chartDataRecord(authored, presentation.datasets) : tableDataRecord(authored, layout);
2838
+ const record = kind === 'chart' ? chartDataRecord(authored, presentation.datasets, chartRecordOptions) : tableDataRecord(authored, layout);
2782
2839
  if (record) {
2783
2840
  context.dataRecords.set(objectName, record);
2784
2841
  context.dataRecordPaths.set(objectName, String(path));
@@ -2800,7 +2857,7 @@ function addTablePayload(slide, authoredTable, region, context, options, path, p
2800
2857
  }
2801
2858
 
2802
2859
  const layout = layoutTable(table, {x:region.x*96,y:region.y*96,width:region.w*96,height:region.h*96}, {
2803
- scale, minFontSize:context.composition?.minFontSize, fontFamily:context.fonts.body, textMeasurement:options.textMeasurement, path,
2860
+ scale, minFontSize:context.composition?.minFontSize, fontFamily:context.fonts.body, textMeasurement:options.textMeasurement, path, alignment:context.tableAlignment,
2804
2861
  ...(context.direction === 'rtl' ? {direction: 'rtl'} : {}), ...(presentation ? {presentation} : {})
2805
2862
  });
2806
2863
  const columnCount = layout.columnCount;
@@ -2850,7 +2907,7 @@ function addTablePayload(slide, authoredTable, region, context, options, path, p
2850
2907
  ...(rich ? {bold:false,italic:false} : {}),
2851
2908
  lineSpacing: fit.lineHeight * 0.75,
2852
2909
  paraSpaceAfter: 0,
2853
- align: physicalAlignment(cellStyle.align ?? context.contentAlignment, cell.direction),
2910
+ align: physicalAlignment(cell.alignment, cell.direction),
2854
2911
  valign: cellStyle.verticalAlign ?? 'top',
2855
2912
  ...(cell.colSpan > 1 ? {colspan:cell.colSpan} : {}),
2856
2913
  ...(cell.rowSpan > 1 ? {rowspan:cell.rowSpan} : {}),
@@ -2867,7 +2924,7 @@ function addTablePayload(slide, authoredTable, region, context, options, path, p
2867
2924
  recordPayloadData(context, objectName, 'table', presentation, path, layout);
2868
2925
  // FA-27: the table's text alternative (the frame's descr; the empty string is PowerPoint's decorative marker).
2869
2926
  if (typeof authoredTable?.alt === 'string') context.frameAlts.set(objectName, authoredTable.alt);
2870
- if (layout.rows.some(row => row.cells.some(cell => Object.keys(cell.style ?? {}).length))) context.tableCells.set(objectName, {layout, scale, context, defaultBorder:{color:"accent5",width:1/scale}});
2927
+ if (layout.rows.some(row => row.cells.some(cell => Object.keys(cell.style ?? {}).length))) context.tableCells.set(objectName, {layout, scale, context, defaultBorder:{color:`#${context.colors.border}`,width:1/scale}});
2871
2928
  slide.addTable(rows, {
2872
2929
  objectName,
2873
2930
  x: region.x,
@@ -2883,7 +2940,7 @@ function addTablePayload(slide, authoredTable, region, context, options, path, p
2883
2940
  fontFace: context.fonts.body,
2884
2941
  fontSize: 15 * scale * 0.75,
2885
2942
  color: context.colors.text,
2886
- border: { type: "solid", color: nativeColor("accent5", context.colors.border, context), pt: 0.75 },
2943
+ border: { type: "solid", color: context.colors.border, pt: 0.75 },
2887
2944
  margin: [6 * scale, 7.5 * scale, 3 * scale, 7.5 * scale],
2888
2945
  valign: "top"
2889
2946
  });
@@ -4273,7 +4330,11 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
4273
4330
  `<Override PartName="/${part}" ContentType="${metadata.mediaType}"/>`).join('');
4274
4331
  xml = xml.replace('</Types>', `${overrides}</Types>`);
4275
4332
  }
4276
- if (/^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(path)) xml = themeMasterBulletFonts(xml);
4333
+ if (/^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(path)) {
4334
+ // Name the generated layout group independently of a:theme's authored identity. Script masters copy this label.
4335
+ xml = themeMasterBulletFonts(xml).replace(/<p:cSld\b([^>]*)>/, (_, attributes) =>
4336
+ `<p:cSld${attributes.replace(/\s+name="[^"]*"/, '')} name="pptx.gallery">`);
4337
+ }
4277
4338
  if (context.masterBackground && path === 'ppt/slideMasters/slideMaster1.xml') xml = writeMasterBackground(xml, context.masterBackground);
4278
4339
  if (context.masterBackground && /^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(path)) xml = inheritLayoutBackground(xml);
4279
4340
  if (path === 'ppt/presentation.xml') xml = presentationInSchemaOrder(xml);
@@ -0,0 +1,71 @@
1
+ // Full provenance stores style spans, never source words. Native edits win.
2
+ const keys = ['fontSize', 'fontFamily', 'color'];
3
+ const literalColor = value => typeof value === 'string' && /^#(?:[a-f0-9]{3}|[a-f0-9]{6}|[a-f0-9]{8})$/i.test(value);
4
+ export function sourceRunStyles(value) {
5
+ if (!Array.isArray(value) || !value.length) return undefined;
6
+ const spans = value.map(run => typeof run === 'string' ? {length: run.length, string: true} : {
7
+ length: run.text.length, ...(run.color !== undefined && !literalColor(run.color) ? {keepColor: true} : {}), ...Object.fromEntries(keys.filter(key => run[key] !== undefined && (key !== 'color' || literalColor(run.color))).map(key => [key, run[key]]))
8
+ });
9
+ if (spans.some(span => !span.length)) return undefined;
10
+ try { validateSourceRunStyles(spans); } catch { return undefined; }
11
+ return spans;
12
+ }
13
+ export function validateSourceRunStyles(spans) {
14
+ if (!Array.isArray(spans) || !spans.length || spans.length > 10000) throw Error('Invalid source style spans.');
15
+ let length = 0;
16
+ for (const span of spans) {
17
+ if (!span || typeof span !== 'object' || Array.isArray(span) || Object.keys(span).some(key => !['length', 'string', 'keepColor', ...keys].includes(key))
18
+ || !Number.isSafeInteger(span.length) || span.length < 1 || (length += span.length) > 1000000
19
+ || (span.keepColor !== undefined && (span.keepColor !== true || span.color !== undefined))
20
+ || (span.string !== undefined && span.string !== true)
21
+ || (span.fontSize !== undefined && (!Number.isFinite(span.fontSize) || span.fontSize <= 0))
22
+ || (span.fontFamily !== undefined && (typeof span.fontFamily !== 'string' || !span.fontFamily || span.fontFamily.length > 256))
23
+ || (span.color !== undefined && !literalColor(span.color))) throw Error('Invalid source style span.');
24
+ }
25
+ }
26
+ export function restoreSourceRunStyles(value, spans, intact) {
27
+ if (!spans || !Array.isArray(value) || !keys.some(key => intact[key])) return value;
28
+ const runs = value.map(run => typeof run === 'string' ? {text: run} : run);
29
+ if (runs.some(run => typeof run.text !== 'string') || runs.reduce((n, run) => n + run.text.length, 0) !== spans.reduce((n, span) => n + span.length, 0)) return value;
30
+ const result = []; let runIndex = 0, offset = 0;
31
+ for (const span of spans) {
32
+ let left = span.length; const pieces = [];
33
+ while (left) {
34
+ const run = runs[runIndex], take = Math.min(left, run.text.length - offset);
35
+ if (!take) { runIndex++; offset = 0; continue; }
36
+ const piece = {...run, text: run.text.slice(offset, offset + take)};
37
+ for (const key of keys) if (intact[key] && !(key === 'color' && span.keepColor)) { if (span[key] === undefined) delete piece[key]; else piece[key] = span[key]; }
38
+ pieces.push(piece); left -= take; offset += take;
39
+ }
40
+ for (const piece of pieces) {
41
+ const previous = result.at(-1);
42
+ if (pieces.indexOf(piece) > 0 && previous && typeof previous === 'object' && JSON.stringify({...previous, text: ''}) === JSON.stringify({...piece, text: ''})) previous.text += piece.text;
43
+ else result.push(piece);
44
+ }
45
+ if (span.string && pieces.length && Object.keys(result.at(-1)).length === 1 && keys.every(key => intact[key])) result[result.length - 1] = result.at(-1).text;
46
+ }
47
+ return result;
48
+ }
49
+ // Property-specific evidence includes every native run/default property in order.
50
+ // A change anywhere conservatively disables that property's source restoration.
51
+ export function nativeRunStyleEvidence(tree, hash) {
52
+ const values = {fontSize: [], fontFamily: [], color: []};
53
+ const visit = (node, path = []) => {
54
+ if (Array.isArray(node)) { node.forEach((item, index) => visit(item, [...path, index])); return; }
55
+ if (!node || typeof node !== 'object') return;
56
+ for (const [key, value] of Object.entries(node)) {
57
+ if (key === 'sz') values.fontSize.push([[...path, key], value]);
58
+ if (/^a:(latin|ea|cs|sym)$/.test(key)) values.fontFamily.push([[...path, key], value]);
59
+ if (/^a:(srgbClr|schemeClr|sysClr|prstClr)$/.test(key)) values.color.push([[...path, key], value]);
60
+ visit(value, [...path, key]);
61
+ }
62
+ };
63
+ visit(tree);
64
+ return Object.fromEntries(keys.map(key => [key, hash(JSON.stringify(values[key]))]));
65
+ }
66
+
67
+ export function hasSourceRunStyles(topology) {
68
+ if (!topology || typeof topology !== 'object') return false;
69
+ if (Array.isArray(topology)) return topology.some(hasSourceRunStyles);
70
+ return topology.sourceStyles !== undefined || Object.values(topology).some(hasSourceRunStyles);
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-pptx",
3
- "version": "0.19.0",
3
+ "version": "0.20.1",
4
4
  "description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -64,9 +64,9 @@
64
64
  "test:determinism": "node test/determinism.mjs"
65
65
  },
66
66
  "dependencies": {
67
- "@openpresentation/gallery": "^2.0.0",
67
+ "@openpresentation/gallery": "^3.0.0",
68
68
  "@node-projects/jszip": "4.3.0",
69
- "@openpresentation/opf": "^0.19.0",
69
+ "@openpresentation/opf": "^0.20.1",
70
70
  "@rgrove/parse-xml": "4.2.3",
71
71
  "fast-xml-parser": "^5.8.0",
72
72
  "fflate": "^0.8.3",
@@ -74,7 +74,7 @@
74
74
  "sharp": "0.35.5"
75
75
  },
76
76
  "peerDependencies": {
77
- "@openpresentation/opf-render": "^0.19.1"
77
+ "@openpresentation/opf-render": "^0.20.0"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "@openpresentation/opf-render": {
@@ -98,7 +98,7 @@
98
98
  "@expo-google-fonts/roboto": "0.4.3",
99
99
  "@expo-google-fonts/roboto-mono": "0.4.2",
100
100
  "@expo-google-fonts/tinos": "0.4.2",
101
- "@openpresentation/opf-render": "0.19.1",
101
+ "@openpresentation/opf-render": "0.20.0",
102
102
  "@resvg/resvg-js": "2.6.2",
103
103
  "esbuild": "0.28.2",
104
104
  "playwright": "1.63.0"