@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 +7 -3
- package/dist/content-topology.js +46 -4
- package/dist/data-provenance.js +7 -4
- package/dist/document-provenance.js +21 -7
- package/dist/index.js +76 -15
- package/dist/source-run-styles.js +71 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# OPF PPTX
|
|
2
2
|
|
|
3
|
-
Catalogs (OPF 0.
|
|
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
|
-
|
|
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.
|
package/dist/content-topology.js
CHANGED
|
@@ -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
|
-
|
|
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).
|
package/dist/data-provenance.js
CHANGED
|
@@ -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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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:
|
|
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))
|
|
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.
|
|
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": "^
|
|
67
|
+
"@openpresentation/gallery": "^3.0.0",
|
|
68
68
|
"@node-projects/jszip": "4.3.0",
|
|
69
|
-
"@openpresentation/opf": "^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.
|
|
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.
|
|
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"
|