@openpresentation/opf-pptx 0.20.4 → 0.22.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 +65 -3
- package/dist/body-text-import.js +64 -7
- package/dist/chart-scheme-colors.js +47 -0
- package/dist/code-provenance.js +34 -4
- package/dist/furniture-fields.js +14 -1
- package/dist/furniture-provenance.js +13 -5
- package/dist/heading-provenance.js +3 -3
- package/dist/image-provenance.js +65 -7
- package/dist/index.d.ts +37 -1
- package/dist/index.js +131 -56
- package/dist/metric-provenance.js +4 -4
- package/dist/native-body.js +147 -0
- package/dist/native-footer-classification.js +104 -0
- package/dist/native-furniture.js +52 -17
- package/dist/native-text-style.js +7 -2
- package/dist/quote-provenance.js +3 -3
- package/dist/template-layouts.js +30 -14
- package/dist/text-provenance.js +3 -3
- package/dist/timeline-provenance.js +3 -3
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# OPF PPTX
|
|
2
2
|
|
|
3
|
+
Source prototype: `toPptx(deck, {bodyText: "native"})` is an opt-in mode and stays a prototype. It writes one editable paragraph, flat-bullet or numbered-list body per text region (the first is the Body placeholder) and a wrapped plain title as one Title placeholder, with an explicit `native-body-fallback` diagnostic for anything it cannot represent; the default measured output is unchanged. Keynote 15.1.1 was checked for Title and Body controls and reflow after an edit ([evidence](docs/evidence/rr-88-keynote/README.md)); PowerPoint has not been checked, and nothing here claims native raster equivalence. See [Choosing body text mode](#choosing-body-text-mode-since-0220) and the [scope and qualification boundaries](docs/native-body-prototype.md).
|
|
4
|
+
|
|
5
|
+
New since 0.22.0 (each section below says what changed and what is unchanged by default):
|
|
6
|
+
|
|
7
|
+
- [Choosing body text mode](#choosing-body-text-mode-since-0220): measured (default) or `bodyText: "native"`, with the fallbacks and what Keynote and PowerPoint do.
|
|
8
|
+
- [Slide numbers with words around them](#slide-numbers-with-words-around-them-since-0220): fixed text by default so Keynote shows `2 / 6`; `slideNumberFields: "live"` for PowerPoint-only targets.
|
|
9
|
+
- [Chart colors and value axis](#chart-colors-and-value-axis-since-0220): series from the deck color scheme and `chart.colors`, written as theme colors, and the value axis the preview draws.
|
|
10
|
+
- [Quiet re-import of an unedited export](#quiet-re-import-of-an-unedited-export-since-0220): no per-group diagnostics for a pristine export, and authored image sources come back.
|
|
11
|
+
|
|
3
12
|
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
13
|
|
|
5
14
|
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.
|
|
@@ -148,6 +157,7 @@ The first importer is mechanical and schema-compatible:
|
|
|
148
157
|
- Remaining text boxes map to `blocks[]` as text or list payloads, sorted by OOXML position. A measured list writes its size, family, weight and colour on every native run; in `full` provenance the slide record (`OPF_SLIDE_V1.listRuns`, opf-pptx#212) keeps that look per list (entry and description lines, never text), and import removes a run value still equal to it, so an untouched list round trip returns its items as authored instead of baking those values into `TextRun`s. A value edited in PowerPoint differs and comes back as observed; a run colour reference that resolves to the list colour (the `text` role) still returns through the run colour record, but an authored literal colour, size or family equal to the list's own cannot be told from it and returns absent. Without the record (`references-only`, `provenance: false`, a plain PPTX) runs are read as observed.
|
|
149
158
|
- PowerPoint tables map to OPF table blocks, embedded images map to data URI image blocks, and cached chart series map to OPF chart blocks whose `type` is the core chart type id for the native construct (for example a `percentStacked` column chart imports as `100pct-stacked-column`, a filled radar as `filled-radar`; a scatter chart imports as `[Point, X, ...series]`).
|
|
150
159
|
- Chart cache points are placed by their native `c:pt@idx`, with `c:ptCount` retaining trailing gaps. Missing labels and values become `null`; explicitly empty labels and series names stay empty strings, and an actual numeric zero stays zero. Empty numeric cache values remain missing. Malformed or duplicate indices, contradictory counts, competing caches and allocation limits reject with `invalid-chart-cache` and the chart-part/cache path; hierarchical category caches reject with `unsupported-chart-cache` instead of flattening labels. Each cache is limited to 100,000 positions, and combined caches and the emitted table each to 1,000,000 cells. Complete decimal scientific notation in numeric caches is read as a number; exponent overflow or nonzero underflow to zero rejects with `unsupported-chart-cache` and the chart part, series, cache and logical point path. Zero coefficients remain zero. This finite-number boundary does not classify rejected text as invalid OOXML. Malformed exponent strings and other nonblank numeric text retain the existing legacy conversion policy. This cache-only import does not repair embedded worksheets, restore unsupported scatter X/point labels, change exporter/workbook parsing or export/re-export missing-data handling, or establish native chart fidelity.
|
|
160
|
+
- Explicit native solid line-series paint imports with a `chart-option-adapted` diagnostic at the native slide/chart/series locator (`series.N.line.color`). OPF has no per-series color field, so it does not retain that paint override or infer semantic highlight from it. Data, design and restored highlight remain unchanged. This reports unsupported formatting, including an explicit default, rather than establishing a visual mismatch; other series/point/marker paint remains outside this bounded diagnostic. The preservation gap is tracked in [opf-pptx#272](https://github.com/OpenPresentation/opf-pptx/issues/272).
|
|
151
161
|
- Table imports retain empty rows. A native `firstRow` flag of `1` or `true` maps the first row to column labels; absent/false flags retain every row as data. New exports set this flag from OPF columns. Older exports without the flag retain their labels as the first data row rather than inferring headers.
|
|
152
162
|
- Native table text preserves run/field/break order, significant whitespace, and blank paragraphs. Cells return canonical `{value, style}` objects; `value` retains scalar text or supported rich runs. Covered merge positions are `null`. Explicit normal headers override OPF’s bold header default. Numeric/boolean/null source types cannot be reconstructed from native display text.
|
|
153
163
|
- Run `lang` maps back to the presentation `language` as a BCP-47 tag (the run's own tag, for example `ja-JP`); see [Languages, right-to-left text and script fonts](#languages-right-to-left-text-and-script-fonts).
|
|
@@ -354,7 +364,7 @@ Image block treatments export from core's normalized geometry as native DrawingM
|
|
|
354
364
|
- `recolor` becomes `a:grayscl` or `a:duotone`, followed by `a:alphaModFix` for `opacity`, on the blip only.
|
|
355
365
|
- `overlay` becomes one tagged `OPF image N overlay` shape directly above the picture, in the frame's shape or as an edge band.
|
|
356
366
|
|
|
357
|
-
|
|
367
|
+
Every image block carries an `OPF_IMAGE_V1` shape tag with its own fields (`fit`, `focus`, a treatment, `placement`; none for a plain block) and the exact native picture geometry; its overlay carries `OPF_IMAGE_OVERLAY_V1`. On import an unchanged tagged picture becomes the same image block again, with the embedded bytes as its data URI source (RR-94: or, when the block was authored from a relative path or an http(s) URL, that authored `src`, which the tag records beside the media key of the picture's bytes; it is restored only while the bytes still hash the same, and a data URI, an `asset:` reference, an absolute or parent-relative path or a `file:` URL is never recorded). A plain block whose picture was replaced or moved reads as an ordinary picture without a diagnostic. An edited, duplicated or ambiguous tagged picture is imported as an ordinary image block and reports `invalid-image-provenance`; an edited overlay drops only the overlay. Native PowerPoint raster parity for negative `a:srcRect` insets has not been checked with Office yet.
|
|
358
368
|
|
|
359
369
|
A deck or slide watermark (`design.watermark`) exports as one native picture named `OPF watermark` per slide, after the image background and its overlay, if any, and before all content (placed image blocks included) (the preview's paint order, so a background picture lies beneath the watermark), at the preview's frame: the centered 40% by 40% box at 30%/30% of the slide, fitted without cropping. Its opacity is `a:alphaModFix` (the object form's `opacity`, otherwise the preview default 0.08), and its alt text is the asset's `alt`, otherwise `Watermark`. `design.watermark = false` on a slide exports none. An unchanged export imports back as `design.watermark` (at deck level when every slide carries the same one); an edited picture stays ordinary content and reports `invalid-watermark-provenance`. An unresolved image reports `unresolved-asset`; `src` is required by the schema, so a watermark with no image source is a validation error and not an export diagnostic.
|
|
360
370
|
|
|
@@ -482,14 +492,66 @@ Version 0.9.1 exports core's accepted header/footer text and picture geometry as
|
|
|
482
492
|
|
|
483
493
|
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
494
|
|
|
485
|
-
A header or footer zone's `text` is the author's own string (FA-31): generated values are `{{ }}` variables in it.
|
|
495
|
+
A header or footer zone's `text` is the author's own string (FA-31): generated values are `{{ }}` variables in it. A zone `text` that is only `{{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. A number with words around it (`"A-{{slide.number}}"`, `"Page {{slide.number}} of {{deck.slideCount}}"`) is fixed text by default since 0.22.0 ([why](#slide-numbers-with-words-around-them-since-0220)); `slideNumberFields: "live"` keeps the field with the words as 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.
|
|
486
496
|
|
|
487
497
|
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.
|
|
488
498
|
|
|
489
499
|
Missing, duplicated or inconsistent furniture tags fall back to current native content with an `invalid-furniture-provenance` diagnostic. Social profiles (`socials: true`) belong to the organization the manifest names by id, which only the stored document record can supply, so a file without it (`provenance: false`, another tool) imports the profile lines as ordinary text with an `invalid-furniture-provenance` diagnostic. Inherited definitions become global only when every slide has a valid definition/override and all inherited values agree; otherwise recovered definitions stay local. A moved slide keeps its live slide-number field (it imports as `{{slide.number}}` and renumbers in PowerPoint). Native typography, positioning, crop and unrelated metadata are not reconstructed; `furniture-import-reflow` requests review. `npm run test:furniture` exercises XML conversion and mutation controls. Native Office, visual corpus and clean installed-package acceptance remain separate gates; published tagged-shape furniture (0.9.1 to 0.11.7) does not establish native Header/Footer support.
|
|
490
500
|
|
|
491
|
-
|
|
501
|
+
`toPptx(deck, {footerText: 'shape'})` omits only the first eligible literal footer's slide-local `ftr` binding, keeping its
|
|
502
|
+
measured editable shape, locks, formatting, geometry, paint order and provenance. The default is `footerText: 'native'`.
|
|
503
|
+
Date and slide-number fields/placeholders and master/layout footer controls remain unchanged. Shape mode gives up native
|
|
504
|
+
Footer-dialog ownership of that literal shape; it does not recover tags stripped by another application or establish
|
|
505
|
+
general native layout parity. See [literal footer text as a shape](docs/native-header-footer.md#literal-footer-text-as-a-shape).
|
|
506
|
+
|
|
507
|
+
Native header and footer placeholders (RR-11): the first footer text becomes a `ftr` placeholder, the first footer date a `dt` placeholder (a live `datetime1`-`datetime7` field for `date: true`) and the first footer slide number an `sldNum` placeholder, each at core's exact box with its furniture tag; the slide master and layout carry the three placeholders and `p:hf` flags, and the notes master records that notes pages carry a page number only. A zone `text` that is only `{{slide.number}}` is the `sldNum` placeholder; a number with words around it is an ordinary tagged shape by default since 0.22.0 (`slideNumberFields: "live"` keeps the `sldNum` placeholder with the words as fixed runs); any other zone `text` is the `ftr` placeholder. Header parts, socials, images and logos, second text/date/number parts and multi-line text stay tagged shapes (PowerPoint has no object for them). A footer text that shares its zone with a logo is only as wide as its words (core's row layout), so its slide placeholder has that box while the master and layout placeholder spans the zone. `fromPptx` reads the placeholders back with or without provenance, and a deck changed through PowerPoint's Insert > Header & Footer dialog imports the change. Every deck, with or without a footer, carries the three master and layout placeholders (flags off when unused), so Insert > Header & Footer works on a deck with no footer. The mapping table, the dialog-edit table and the vetoable decisions are in [docs/native-header-footer.md](docs/native-header-footer.md).
|
|
492
508
|
|
|
493
509
|
Master furniture (RR-72): a part with no placeholder (a logo or other zone image, socials, header text, a second footer text) that is drawn the same on two or more slides is written once. It goes on the slide master when every slide that shows such furniture draws it, else on a layout shared by the slides that do (one layout per distinct set, so a logo's `onLight` and `onDark` assets get a layout per background tone); a slide that hides it (`design.footer: false`) uses a layout with `showMasterSp="0"`. A part on one slide only, a part with a live field and a part that slide content covers stay on the slide (the last with `furniture-on-slide`, because PowerPoint draws master shapes beneath slide content). The lifted shapes keep their furniture tags (with the slot in place of the slide part), and each slide manifest lists them under `shared`, so `fromPptx` reads them from the layout or master: a master logo returns as its `var:organization.logo.*` reference on every slide. Importers before 0.18 ignore `shared`; shapes on a master or layout without OPF tags (a third-party template) are not imported. See [Master furniture](docs/native-header-footer.md#master-furniture-rr-72).
|
|
494
510
|
|
|
495
511
|
Multi-series Pareto exports in `chartex: "auto"` as an editable clustered column chart containing every authored series, with a `chart-data-adapted` / `chartex-fallback` diagnostic. This preserves data rather than claiming native multi-series Pareto support. Explicit `chartex: "native"` rejects it with `unsupported-chart-data`; single-series native Pareto is unchanged. `chartex: "fallback"` also retains all series. Native acceptance of any new behavior remains separate.
|
|
512
|
+
|
|
513
|
+
A narrowly bounded [current-native literal footer fallback](docs/native-footer-classification.md) recognizes a unique generated-name literal only with its accepted same-group live slide-number anchor and exact own bottom-band frame. Current words, literal brace tokens and deletions remain authoritative; unsupported or ambiguous objects stay content with a diagnostic. This does not restore native geometry, paint order or notes fields.
|
|
514
|
+
|
|
515
|
+
## Choosing body text mode (since 0.22.0)
|
|
516
|
+
|
|
517
|
+
`toPptx(deck, {bodyText})` chooses how the text of a slide is written. Both modes read back with `fromPptx`. The default is `"measured"`, and it is byte-identical to omitting the option.
|
|
518
|
+
|
|
519
|
+
| | `"measured"` (default) | `"native"` (opt-in prototype) |
|
|
520
|
+
| --- | --- | --- |
|
|
521
|
+
| Body text, lists, bullets | One native text box per wrapped visual line, at the positions core measured | One native body per eligible text, list or bullets region, with native wrapping and no autofit. The first body of a slide is the Body placeholder; each further region is an ordinary native text box at its region |
|
|
522
|
+
| Numbered lists | Each marker line is its own box with the number core counted | One sequence (same style and suffix, counting up by one) is native auto-numbering on every paragraph; a restarted entry falls back |
|
|
523
|
+
| Wrapped title | One box per line; only a one-line title binds the Title placeholder | A plain-string title that wraps is one Title-placeholder box with native wrap, no autofit and the measured line pitch; a title with a hard break, overflow, rich runs or right-to-left text keeps the measured lines (no diagnostic) |
|
|
524
|
+
| Editing in an application | Editing a line does not reflow, so a longer line overlaps the next | Text reflows inside its frame; the frame is fixed (no autofit), so very long text can overflow it |
|
|
525
|
+
| Wraps and baselines | Exactly the preview's | The application's own, so they can differ from the preview |
|
|
526
|
+
| Falls back to measured shapes | Never | Per region, with a `native-body-fallback` diagnostic and a reason: `complex-composition` (groups, cards, a shared region), `multi-column`, `overflow`, `unsupported-list` (nested entries, descriptions, picture bullets, restarted numbering, a hard break inside an entry), `unsupported-text` (right-to-left text, tabs, unsupported run effects) or `annotated-text` (citations, footnotes) |
|
|
527
|
+
| Keynote 15.1.1 | The title shows only for a one-line title (`title showing=false` on a wrapped one) | `title showing` is true on every slide of the repro deck, Body is a real body, and a lengthened bullet reflows (a copy in memory, nothing saved); [evidence](docs/evidence/rr-88-keynote/README.md) |
|
|
528
|
+
| PowerPoint | Written as before | Not checked here (not available); the XML is standard `p:sp` placeholders and text |
|
|
529
|
+
|
|
530
|
+
Native bodies keep no per-line recovery tags and no cached source words: `fromPptx` reads the current native paragraphs and runs, and native paragraph separators normalize to LF. The scope, the layout variants and the qualification boundaries are in [docs/native-body-prototype.md](docs/native-body-prototype.md).
|
|
531
|
+
|
|
532
|
+
## Slide numbers with words around them (since 0.22.0)
|
|
533
|
+
|
|
534
|
+
A footer or header text such as `{{slide.number}} / {{deck.slideCount}}` or `Page {{slide.number}}` exports its number as **fixed text** in an ordinary tagged shape by default (`slideNumberFields: "static"`): one run, no field, no Slide Number placeholder, so PowerPoint and Keynote both show `2 / 6`.
|
|
535
|
+
|
|
536
|
+
Why: Keynote has no inline slide-number field. It replaces the whole paragraph that holds a `slidenum` field with the number, in the Slide Number placeholder and in a plain text box alike, so the live `{{slide.number}} / 6` showed only `2` (Keynote Creator Studio 15.1.1, 2026-10-17; text in a separate paragraph or shape is kept). The total is fixed in every mode, because PowerPoint has no slide-count field.
|
|
537
|
+
|
|
538
|
+
- A text that is only `{{slide.number}}` keeps its live `slidenum` field in the `sldNum` placeholder in both modes; it is the way to get a live number in both apps.
|
|
539
|
+
- `toPptx(deck, {slideNumberFields: "live"})` restores the 0.20 output (a live field with the words as fixed runs, in the Slide Number placeholder for the first footer one) for PowerPoint-only targets. Keynote then shows only the digit.
|
|
540
|
+
- A fixed number does not follow slides reordered, added or deleted in PowerPoint; re-export from OPF to refresh it.
|
|
541
|
+
- Import returns the authored `{{slide.number}} / {{deck.slideCount}}` either way (the furniture manifest stores the template). Details and the placeholder mapping are in [docs/native-header-footer.md](docs/native-header-footer.md#slide-number-with-words-around-it-rr-91).
|
|
542
|
+
|
|
543
|
+
## Chart colors and value axis (since 0.22.0)
|
|
544
|
+
|
|
545
|
+
Requires `@openpresentation/opf` ^0.21.0. Classic native charts (column, bar, line, area, pie and the other non-chartex types) now match what core composes:
|
|
546
|
+
|
|
547
|
+
- **Series colors come from the deck.** Series 1 to 6 are `accent1` to `accent6` of the deck color scheme, then their tints, replacing the fixed palette of earlier versions; the chart's own `chart.colors` entries (a scheme slot, a role or a hex color) replace the entry for their position. The colors are still adjusted for contrast against the chart panel, as in the preview.
|
|
548
|
+
- **Written as theme colors where possible.** A series that keeps an unadjusted theme accent, or a `chart.colors` scheme slot or role, is `<a:schemeClr val="accentN"/>`, so re-theming the deck in PowerPoint recolors it. Hex colors, tints and colors the panel adjustment moved stay `a:srgbClr`.
|
|
549
|
+
- **The value axis matches the preview.** Column, bar, line and area charts write the axis core composed as `c:min`, `c:max` and `c:majorUnit` (a fraction for 100% stacked), so PowerPoint and Keynote draw the preview's gridlines instead of choosing their own. Combo, scatter, radar, pie and chartex charts write no fixed axis.
|
|
550
|
+
- Chart parts of exported decks changed, so archives of chart decks are byte-different from 0.20 (the reviewed fixtures were re-pinned). `chart.highlight` is unchanged (see Chart emphasis above).
|
|
551
|
+
|
|
552
|
+
## Quiet re-import of an unedited export (since 0.22.0)
|
|
553
|
+
|
|
554
|
+
`fromPptx` of an unedited OPF export reports nothing. Before 0.22.0 it printed one `*-import-reflow` diagnostic per tagged group (about 14 on a six-slide deck), plus `unsupported-image-crop` for each picture.
|
|
555
|
+
|
|
556
|
+
- **Text.** Each tagged text shape (heading, plain text, metric, quote, timeline, code and furniture lines) records a fingerprint of its geometry and native paragraphs as exported (`sig` in its tag). The `heading-`, `text-`, `metric-`, `quote-`, `timeline-`, `code-` and `furniture-import-reflow` diagnostics are reported only for a group whose shapes no longer match their fingerprint (a text edit, a moved or resized shape). A tag from an earlier version has no fingerprint and keeps reporting.
|
|
557
|
+
- **Images.** Every image block is tagged (an empty treatment when it has none), so an unchanged picture imports without `unsupported-image-crop`. A block authored from a relative path or an http(s) URL also records that `src` and the picture's media key; import gives the authored `src` back while the picture bytes still hash the same. A data URI, an `asset:` reference, an absolute or parent-relative path and a `file:` URL are never recorded (a crafted PPTX could otherwise point a later export at another file), and a replaced picture keeps its data URI. A plain block whose picture was replaced or moved reads as an ordinary picture without a diagnostic.
|
package/dist/body-text-import.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// recovers authoring boundaries across separate native text shapes.
|
|
3
3
|
import {XMLParser} from 'fast-xml-parser';
|
|
4
4
|
import {drawingObject, readSlideTheme} from './background-import.js';
|
|
5
|
-
import {
|
|
5
|
+
import {readBackgroundColor} from './background.js';
|
|
6
|
+
import {nativeRunStyle, mergeNativeRunProperties, nativeTypeface} from './native-text-style.js';
|
|
6
7
|
|
|
7
8
|
const parser = new XMLParser({ignoreAttributes:false, attributeNamePrefix:'', preserveOrder:true, parseAttributeValue:false, parseTagValue:false, trimValues:false, htmlEntities:true});
|
|
8
9
|
const nodes = (tree, tag) => (tree ?? []).filter(node => Object.hasOwn(node, tag));
|
|
@@ -13,6 +14,42 @@ const text = tree => (tree ?? []).map(node => node['#text'] ?? '').join('');
|
|
|
13
14
|
const shapes = tree => [...nodes(tree, 'p:sp').map(node => node['p:sp']), ...nodes(tree, 'p:grpSp').flatMap(node => shapes(node['p:grpSp']))];
|
|
14
15
|
const LATIN_ONLY = /^[\p{Script=Latin}\p{N}\p{P}\p{S}\p{Z}]*$/u;
|
|
15
16
|
const value = runs => runs.some(run => typeof run !== 'string') ? runs : runs.join('');
|
|
17
|
+
const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
18
|
+
const bulletKeys = ['a:buNone', 'a:buChar', 'a:buBlip', 'a:buAutoNum'];
|
|
19
|
+
|
|
20
|
+
// Only current, unambiguous body-placeholder bindings can inherit styles.
|
|
21
|
+
// Undefined is an ordinary text box; null is an unresolved Body binding.
|
|
22
|
+
function bodyInheritedStyles(shape, context, archive, slidePath) {
|
|
23
|
+
const ph = drawingObject(child(shape, 'p:nvSpPr'))['p:nvPr']?.['p:ph'];
|
|
24
|
+
if (!ph || Array.isArray(ph) || ph.type !== 'body') return undefined;
|
|
25
|
+
const placeholder = (root, reference) => {
|
|
26
|
+
const candidates = array(root?.['p:cSld']?.['p:spTree']?.['p:sp']).filter(shape => {
|
|
27
|
+
const current = shape['p:nvSpPr']?.['p:nvPr']?.['p:ph'];
|
|
28
|
+
return current && !Array.isArray(current) && current.type === 'body' && String(current.idx ?? '0') === String(reference.idx ?? '0');
|
|
29
|
+
});
|
|
30
|
+
return candidates.length === 1 ? candidates[0] : undefined;
|
|
31
|
+
};
|
|
32
|
+
const related = (part, type) => [...archive.relationships(part).values()].filter(rel => rel.type.endsWith('/' + type));
|
|
33
|
+
const layouts = related(slidePath, 'slideLayout');
|
|
34
|
+
if (layouts.length !== 1 || layouts[0].targetMode === 'External') return null;
|
|
35
|
+
const masters = related(layouts[0].path, 'slideMaster');
|
|
36
|
+
if (masters.length !== 1 || masters[0].targetMode === 'External') return null;
|
|
37
|
+
const layout = context.chain.find(part => part.path === layouts[0].path)?.root;
|
|
38
|
+
const master = context.chain.find(part => part.path === masters[0].path)?.root;
|
|
39
|
+
if (!layout || !master) return null;
|
|
40
|
+
const layoutShape = placeholder(layout, ph);
|
|
41
|
+
if (!layoutShape) return null;
|
|
42
|
+
const masterShape = placeholder(master, layoutShape['p:nvSpPr']['p:nvPr']['p:ph']);
|
|
43
|
+
if (!masterShape) return null;
|
|
44
|
+
return [layoutShape['p:txBody']?.['a:lstStyle'], masterShape?.['p:txBody']?.['a:lstStyle'], master['p:txStyles']?.['p:bodyStyle']];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function inheritedRunDefaults(styles, level) {
|
|
48
|
+
if (!styles || !Number.isInteger(level) || level < 1 || level > 9) return undefined;
|
|
49
|
+
const levels = [...styles].reverse().flatMap(style => [style?.['a:defPPr'], style?.[`a:lvl${level}pPr`]]);
|
|
50
|
+
if (styles.some(Array.isArray) || levels.some(value => Array.isArray(value) || Array.isArray(value?.['a:defRPr']))) return undefined;
|
|
51
|
+
return mergeNativeRunProperties(...levels.map(value => value?.['a:defRPr']));
|
|
52
|
+
}
|
|
16
53
|
export const joinNativeParagraphs = paragraphs => value(paragraphs.flatMap((paragraph, index) => [
|
|
17
54
|
...(index ? ['\n'] : []), ...(Array.isArray(paragraph.richText) ? paragraph.richText : [paragraph.richText ?? paragraph.text])
|
|
18
55
|
]));
|
|
@@ -47,7 +84,10 @@ function bodyRunStyle(properties, context, relationships, report, extras = {}) {
|
|
|
47
84
|
report('unsupported-body-text-style', `Repeated native ${key} properties are ambiguous; current text was retained.`);
|
|
48
85
|
delete supported[key];
|
|
49
86
|
}
|
|
50
|
-
|
|
87
|
+
const latin = nativeTypeface(properties['a:latin']?.typeface, context)?.trim().toLowerCase();
|
|
88
|
+
for (const key of ['a:ea','a:cs','a:sym']) if (properties[key]?.typeface && (extras.inheritedBody
|
|
89
|
+
? !latin || nativeTypeface(properties[key].typeface, context)?.trim().toLowerCase() !== latin
|
|
90
|
+
: properties[key].typeface !== properties['a:latin']?.typeface)) {
|
|
51
91
|
report('unsupported-body-font', 'Script-specific native faces cannot be represented by one OPF run font family; the Latin face and current text were retained.');
|
|
52
92
|
}
|
|
53
93
|
if (properties['a:hlinkMouseOver']) report('unsupported-body-link', 'A native mouse-over action cannot be represented by an OPF run URL.');
|
|
@@ -68,12 +108,14 @@ function bodyRunStyle(properties, context, relationships, report, extras = {}) {
|
|
|
68
108
|
}
|
|
69
109
|
|
|
70
110
|
export function nativeBodyReader(slidePath, archive, relationships, report, extras = {}) {
|
|
71
|
-
let bodies, context;
|
|
111
|
+
let bodies, context, inheritedStyles;
|
|
72
112
|
return index => {
|
|
73
113
|
if (!bodies) {
|
|
74
114
|
const tree = archive.part(slidePath, parser);
|
|
75
|
-
|
|
115
|
+
const currentShapes = shapes(child(child(child(tree, 'p:sld'), 'p:cSld'), 'p:spTree'));
|
|
116
|
+
bodies = currentShapes.map(shape => child(shape, 'p:txBody'));
|
|
76
117
|
context = readSlideTheme(slidePath, archive);
|
|
118
|
+
inheritedStyles = currentShapes.map(shape => bodyInheritedStyles(shape, context, archive, slidePath));
|
|
77
119
|
}
|
|
78
120
|
const body = bodies[index];
|
|
79
121
|
if (!body) return undefined;
|
|
@@ -81,7 +123,17 @@ export function nativeBodyReader(slidePath, archive, relationships, report, extr
|
|
|
81
123
|
return nodes(body, 'a:p').map((paragraph, paragraphIndex) => {
|
|
82
124
|
const content = paragraph['a:p'], pPr = drawingObject(child(content, 'a:pPr'));
|
|
83
125
|
const level = Number(nodes(content, 'a:pPr')[0]?.[':@']?.lvl ?? 0) + 1;
|
|
84
|
-
const
|
|
126
|
+
const inheritedBody = inheritedStyles[index] !== undefined;
|
|
127
|
+
const defaults = mergeNativeRunProperties(inheritedRunDefaults(inheritedStyles[index], level), listStyle['a:defPPr']?.['a:defRPr'], listStyle[`a:lvl${level}pPr`]?.['a:defRPr'], pPr['a:defRPr']);
|
|
128
|
+
// Membership alone follows native paragraph/shape/layout/master precedence.
|
|
129
|
+
// A local buNone cancels all
|
|
130
|
+
// inherited bullets; missing bindings never recover stored source words.
|
|
131
|
+
const inherited = Number.isInteger(level) && level >= 1 && level <= 9 ? inheritedStyles[index] ?? [] : [];
|
|
132
|
+
const bulletProperties = [pPr, listStyle[`a:lvl${level}pPr`], listStyle['a:defPPr'],
|
|
133
|
+
...inherited.flatMap(style => [style?.[`a:lvl${level}pPr`], style?.['a:defPPr']])]
|
|
134
|
+
.find(properties => properties && !Array.isArray(properties) && bulletKeys.some(key => Object.hasOwn(properties, key)));
|
|
135
|
+
const membership = bulletProperties ? {bullet: !Object.hasOwn(bulletProperties, 'a:buNone'), level: level - 1,
|
|
136
|
+
...(bulletProperties['a:buAutoNum'] ? {autoNum: {type: bulletProperties['a:buAutoNum'].type, startAt: bulletProperties['a:buAutoNum'].startAt}} : {})} : {};
|
|
85
137
|
const runs = [];
|
|
86
138
|
let runIndex = 0;
|
|
87
139
|
for (const node of content) {
|
|
@@ -92,10 +144,15 @@ export function nativeBodyReader(slidePath, archive, relationships, report, extr
|
|
|
92
144
|
const properties = mergeNativeRunProperties(defaults, drawingObject(node[tag])['a:rPr']);
|
|
93
145
|
// RR-05: a Latin phrase of a right-to-left paragraph is its own en-US run; its East Asian/complex-script slots can render nothing in it, so the face loss is not reported.
|
|
94
146
|
const phrase = nodes(content, 'a:pPr')[0]?.[':@']?.rtl === '1' && properties.lang === 'en-US' && LATIN_ONLY.test(current);
|
|
95
|
-
const style = bodyRunStyle(properties, context, relationships, (code, message) => { if (!(phrase && code === 'unsupported-body-font')) report({code,message,path}); }, phrase ? {
|
|
147
|
+
const style = bodyRunStyle(properties, context, relationships, (code, message) => { if (!(phrase && code === 'unsupported-body-font')) report({code,message,path}); }, {...extras, inheritedBody, ...(phrase ? {deckLang: undefined} : {})});
|
|
148
|
+
// Supported canonical forms may intentionally omit a resolved family
|
|
149
|
+
// (code:true) or the resolved theme hyperlink color (link without color).
|
|
150
|
+
const resolvedFill = style.color !== undefined || style.link && properties['a:solidFill'] && readBackgroundColor(properties['a:solidFill'], context);
|
|
151
|
+
if (current && inheritedBody && (!(style.fontFamily || style.code) || style.fontSize === undefined || !resolvedFill)) report({code: 'unsupported-body-inherited-style', path,
|
|
152
|
+
message: 'The current Body font family, point size or fill could not be fully resolved from its explicit styles and unambiguous layout/master cascade; current text was retained.'});
|
|
96
153
|
if (current) runs.push(Object.keys(style).length ? {text:current,...style} : current);
|
|
97
154
|
}
|
|
98
|
-
return {text:runs.map(run => typeof run === 'string' ? run : run.text).join(''), richText:value(runs)};
|
|
155
|
+
return {text:runs.map(run => typeof run === 'string' ? run : run.text).join(''), richText:value(runs), ...membership};
|
|
99
156
|
});
|
|
100
157
|
};
|
|
101
158
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// RR-89: chart series colours as theme colours.
|
|
2
|
+
//
|
|
3
|
+
// Core's chartSeriesPalette gives series i the deck scheme's accent (i + 1) for the first six series, or the chart's own
|
|
4
|
+
// `chart.colors` entry. When the chart panel keeps that colour exactly (chartPaletteForFill only adjusts a colour that falls short of 3:1
|
|
5
|
+
// contrast), the series is written as `<a:schemeClr val="accentN"/>` so re-theming the deck in PowerPoint recolours the chart, as
|
|
6
|
+
// chart-highlight.js does for the highlight accent. A hex entry, a tint (series 7 to 12) and an adjusted colour stay `srgbClr`.
|
|
7
|
+
const hex6 = value => String(value).replace(/^#/, '').toUpperCase();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The scheme value (`accent1`, `tx2`, ...) of each palette colour that may be written as a theme colour, or undefined.
|
|
11
|
+
* `colors` is the chart's authored `colors`, `preferred` and `adjusted` the palette before and after the panel adjustment, `schemeFor`
|
|
12
|
+
* maps a colour reference and its resolved hex to a scheme value when the deck theme holds that colour (theme-colors.js). A colour the
|
|
13
|
+
* panel adjusted, a hex or variable entry and a tint (series 7 to 12) have none.
|
|
14
|
+
*/
|
|
15
|
+
export function chartPaletteSchemes(colors, preferred, adjusted, schemeFor) {
|
|
16
|
+
const authored = Array.isArray(colors) ? colors : [];
|
|
17
|
+
return preferred.map((color, index) => {
|
|
18
|
+
if (hex6(color) !== hex6(adjusted[index])) return undefined;
|
|
19
|
+
const entry = typeof authored[index] === 'string' ? authored[index].trim() : undefined;
|
|
20
|
+
if (entry !== undefined) return /^[A-Za-z]/.test(entry) ? schemeFor(entry, hex6(color)) : undefined;
|
|
21
|
+
return index < 6 ? schemeFor(`accent${index + 1}`, hex6(color)) : undefined;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const srgbIn = (block, hex, scheme) => block.replace(new RegExp(`<a:srgbClr val="${hex}"\\s*/>`, 'gi'), `<a:schemeClr val="${scheme}"/>`);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Rewrite the series fills of a generated classic chart part to scheme colours. Series are matched to the palette by position (the
|
|
29
|
+
* generated part has one c:ser per plotted series in palette order; pie, doughnut and similar charts colour category i, one c:dPt each).
|
|
30
|
+
* Only series and point shape properties (c:spPr, and the marker's) are touched, never text properties.
|
|
31
|
+
*/
|
|
32
|
+
export function applyChartSchemeColors(xml, palette, schemes, {circular = false} = {}) {
|
|
33
|
+
if (!schemes?.some(Boolean)) return xml;
|
|
34
|
+
let seriesIndex = 0;
|
|
35
|
+
return xml.replace(/<c:ser>[\s\S]*?<\/c:ser>/g, ser => {
|
|
36
|
+
const index = seriesIndex++;
|
|
37
|
+
const rewrite = (block, colorIndex) => {
|
|
38
|
+
const scheme = schemes[colorIndex], hex = palette[colorIndex];
|
|
39
|
+
return scheme && hex ? block.replace(/<c:spPr>[\s\S]*?<\/c:spPr>/g, properties => srgbIn(properties, hex6(hex), scheme)) : block;
|
|
40
|
+
};
|
|
41
|
+
if (circular) {
|
|
42
|
+
return ser.replace(/<c:dPt>[\s\S]*?<\/c:dPt>/g, point => rewrite(point, Number(/<c:idx val="(\d+)"\/>/.exec(point)?.[1])));
|
|
43
|
+
}
|
|
44
|
+
// The series' own shape properties and markers, then its points (a bar series with several colours carries c:dPt).
|
|
45
|
+
return rewrite(ser, index);
|
|
46
|
+
});
|
|
47
|
+
}
|
package/dist/code-provenance.js
CHANGED
|
@@ -25,12 +25,42 @@ export function codeManifest(value, layout, group, bands) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export function attachCodeTags(entries, records) {
|
|
28
|
-
return attachTextTags(entries,records,TAG,'opfCode','code');
|
|
28
|
+
return attachTextTags(entries,records,TAG,'opfCode','code',{signed:true});
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export {unhex as decodeTextTag, hex as encodeTextTag};
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// RR-94: a signed tag records a fingerprint of its shape as exported (the geometry and the native paragraphs). On import a group whose
|
|
34
|
+
// shapes all still match their fingerprint is a pristine export: nothing was edited, so there is nothing to tell the user about.
|
|
35
|
+
const cyrb53 = text => {
|
|
36
|
+
let h1 = 0xdeadbeef, h2 = 0x41c6ce57;
|
|
37
|
+
for (let i = 0; i < text.length; i++) {
|
|
38
|
+
const code = text.charCodeAt(i);
|
|
39
|
+
h1 = Math.imul(h1 ^ code, 2654435761);
|
|
40
|
+
h2 = Math.imul(h2 ^ code, 1597334677);
|
|
41
|
+
}
|
|
42
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
43
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
44
|
+
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(14, '0');
|
|
45
|
+
};
|
|
46
|
+
const fingerprint = (geometry, paragraphs) => cyrb53(JSON.stringify([geometry, paragraphs]));
|
|
47
|
+
const wrapShape = shape => `<p:sld><p:cSld><p:spTree>${shape}</p:spTree></p:cSld></p:sld>`;
|
|
48
|
+
function exportedSignature(shape) {
|
|
49
|
+
const frame = /<a:xfrm\b[^>]*>\s*<a:off x="(-?\d+)" y="(-?\d+)"\s*\/>\s*<a:ext cx="(\d+)" cy="(\d+)"\s*\/>/.exec(shape);
|
|
50
|
+
return fingerprint(frame ? frame.slice(1).map(Number) : [], nativeShapeParagraphs(wrapShape(shape))[0] ?? []);
|
|
51
|
+
}
|
|
52
|
+
/** True when every tagged text shape of an imported group still has the geometry and native text it was exported with. */
|
|
53
|
+
export function groupUnchanged(items, paragraphs) {
|
|
54
|
+
return items.every(item => {
|
|
55
|
+
const shape = item.shape;
|
|
56
|
+
if (!shape || shape['p:nvPicPr']) return true;
|
|
57
|
+
const frame = shape['p:spPr']?.['a:xfrm'], off = frame?.['a:off'], ext = frame?.['a:ext'];
|
|
58
|
+
const geometry = off && ext ? [off.x, off.y, ext.cx, ext.cy].map(Number) : [];
|
|
59
|
+
return typeof item.data?.sig === 'string' && item.data.sig === fingerprint(geometry, paragraphs[item.index] ?? []);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function attachTextTags(entries, records, tagName, prefix, kind, {pictures = false, descriptions, signed = false} = {}) {
|
|
34
64
|
if (!records.size) return;
|
|
35
65
|
let count = 0;
|
|
36
66
|
const seen = new Set(), types = [];
|
|
@@ -48,7 +78,7 @@ export function attachTextTags(entries, records, tagName, prefix, kind, {picture
|
|
|
48
78
|
let id = `rId${prefix[0].toUpperCase()+prefix.slice(1)}${count}`;
|
|
49
79
|
while (ids.has(id)) id += '_';
|
|
50
80
|
ids.add(id);
|
|
51
|
-
entries[part] = enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${tagName}" val="${hex(records.get(name))}"/></p:tagLst>`);
|
|
81
|
+
entries[part] = enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${tagName}" val="${hex(signed && shape.startsWith('<p:sp>') ? {...records.get(name), sig: exportedSignature(shape)} : records.get(name))}"/></p:tagLst>`);
|
|
52
82
|
types.push(`<Override PartName="/${part}" ContentType="application/vnd.openxmlformats-officedocument.presentationml.tags+xml"/>`);
|
|
53
83
|
rels = rels.replace('</Relationships>',`<Relationship Id="${id}" Type="${REL}" Target="../tags/${prefix}${count}.xml"/></Relationships>`);
|
|
54
84
|
// Optional alternative text (a:cNvPr descr) for generated decoration such as the metric trend arrow.
|
|
@@ -218,7 +248,7 @@ export function importCodeGroups(shapes, paragraphs, relationships, entries, rep
|
|
|
218
248
|
}
|
|
219
249
|
for (const item of group) consumed.add(item.shape);
|
|
220
250
|
items.push({shape:panel.shape,payload:{type:'code',code:typeof manifest.value === 'string' ? value.source : value}});
|
|
221
|
-
report({code:'code-import-reflow',message:'Code source boundaries and native text were recovered. Native positioning, formatting and font theme are not reconstructed; review the reflowed OPF.'});
|
|
251
|
+
if(!groupUnchanged(group,paragraphs))report({code:'code-import-reflow',message:'Code source boundaries and native text were recovered. Native positioning, formatting and font theme are not reconstructed; review the reflowed OPF.'});
|
|
222
252
|
} catch (error) {
|
|
223
253
|
report({code:'invalid-code-provenance',message:`${error.message} Visible native shapes were retained without restoring old source text.`});
|
|
224
254
|
}
|
package/dist/furniture-fields.js
CHANGED
|
@@ -118,7 +118,17 @@ export function furniturePartFields(part) {
|
|
|
118
118
|
/** True when a core text part carries at least one slide-number field. */
|
|
119
119
|
export const hasSlideNumberField = part => furniturePartFields(part).some(field => field.type === 'slideNumber');
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
/**
|
|
122
|
+
* True when the whole text of a core text part is one slide-number field (`{{slide.number}}` and nothing else): the only shape
|
|
123
|
+
* a native `sldNum` placeholder can carry. Words around the number (`Page 2 of 6`, `2 / 6`) make it an ordinary text box with the
|
|
124
|
+
* field, because Keynote (and Google Slides) show only the number of a slide-number placeholder and drop the literal runs.
|
|
125
|
+
*/
|
|
126
|
+
export function isBareSlideNumber(part) {
|
|
127
|
+
const fields = furniturePartFields(part);
|
|
128
|
+
return fields.length === 1 && fields[0].type === 'slideNumber' && fields[0].start === 0 && fields[0].end === part.text?.length;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const SLIDE_NUMBER_TOKEN ='{{slide.number}}';
|
|
122
132
|
// The slide-scoped tokens core substitutes per slide (core SLIDE_TOKEN_BODY): typed text that spells one must be
|
|
123
133
|
// escaped, or the next export would turn it into a live value.
|
|
124
134
|
const literalSlideToken = /\{\{(?=\s*(?:slide(?:\.[A-Za-z0-9_-]+){1,2}|deck\.slideCount)\s*(?:\|[^{}]*)?\}\})/g;
|
|
@@ -138,6 +148,9 @@ export function isBuiltinTemplate(text) {
|
|
|
138
148
|
return true;
|
|
139
149
|
}
|
|
140
150
|
export const escapeSlideTokens = text => text.replace(literalSlideToken, '\\{{');
|
|
151
|
+
// A current native literal is never a template, including already displayed
|
|
152
|
+
// backslashes. Add one escape for each complete token using core's convention.
|
|
153
|
+
export const escapeLiteralTokens = text => text.replace(anyToken, token => token.replace('{{', '\\{{'));
|
|
141
154
|
|
|
142
155
|
/**
|
|
143
156
|
* The zone `text` a native footer or header line stands for: every native slide-number field (`{type, text, start}`,
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import {XMLParser} from 'fast-xml-parser';
|
|
2
|
-
import {attachTextTags, decodeTextTag, encodeTextTag} from './code-provenance.js';
|
|
2
|
+
import {attachTextTags, decodeTextTag, encodeTextTag, groupUnchanged} from './code-provenance.js';
|
|
3
3
|
import {sourceLineParagraphs} from './text-provenance.js';
|
|
4
4
|
import {DEFAULT_DATE_FORMAT, NATIVE_DATE_FIELDS, isBuiltinTemplate, mentionsSlideToken, parseDate, slideNumberTemplate} from './furniture-fields.js';
|
|
5
5
|
import {schemas} from '@openpresentation/opf';
|
|
6
6
|
import {resolveLogo} from '@openpresentation/opf/composition';
|
|
7
7
|
import {mediaKey} from './media-dedupe.js';
|
|
8
8
|
import {NATIVE_PLACEHOLDERS, footerValue, isNativePlaceholderType, readNativePlaceholders} from './native-furniture.js';
|
|
9
|
+
import {classifyNativeFooters} from './native-footer-classification.js';
|
|
9
10
|
|
|
10
11
|
const TAG = 'OPF_FURNITURE_V1';
|
|
11
12
|
// A slide has one tag list; it also carries the document's OPF_SLIDE_V1 record.
|
|
@@ -126,7 +127,7 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
|
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
export function attachFurnitureTags(entries, records, manifests) {
|
|
129
|
-
attachTextTags(entries, records, TAG, 'opfFurniture', 'furniture', {pictures: true});
|
|
130
|
+
attachTextTags(entries, records, TAG, 'opfFurniture', 'furniture', {pictures: true, signed: true});
|
|
130
131
|
const types = [];
|
|
131
132
|
for (const [path, manifest] of manifests) {
|
|
132
133
|
const part = `ppt/tags/opfFurnitureSlide${manifest.group}.xml`;
|
|
@@ -390,7 +391,8 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText,
|
|
|
390
391
|
if (value !== false) for (const zone of zones) if (definition.value[zone] !== undefined) {
|
|
391
392
|
value[zone] = Object.fromEntries(Object.entries(definition.value[zone]).filter(([, flag]) => flag === false));
|
|
392
393
|
}
|
|
393
|
-
|
|
394
|
+
// RR-94: pristine while every tagged text shape still has the geometry and text it was exported with (a picture is not compared).
|
|
395
|
+
const candidate = {scope: definition.scope, value, text: [], pictures: [], socials: [], pristine: true};
|
|
394
396
|
// A logo reference with no part (the organization had no logo to draw) returns as the reference.
|
|
395
397
|
for (const [slot, entry] of Object.entries(manifest.images ?? {})) {
|
|
396
398
|
const [slotKind, slotZone] = slot.split('.');
|
|
@@ -433,6 +435,7 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText,
|
|
|
433
435
|
check(count === part.count && Number.isSafeInteger(line) && line >= 0 && line < part.count && !ordered[line], 'Ambiguous furniture line.');
|
|
434
436
|
ordered[line] = record;
|
|
435
437
|
}
|
|
438
|
+
if (!groupUnchanged(ordered.map(record => ({shape: source.shapes?.[record.index] ?? {}, index: record.index, data: record.data})), paragraphs)) candidate.pristine = false;
|
|
436
439
|
const text = sourceLineParagraphs(ordered, paragraphs)[0].text;
|
|
437
440
|
const format = manifest.formats?.[`${kind}.${part.zone}`] ?? {};
|
|
438
441
|
if (part.field === 'socials') {
|
|
@@ -446,6 +449,7 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText,
|
|
|
446
449
|
} catch (error) {
|
|
447
450
|
// A part on the slide master or a layout is never slide content: when its record no longer reads, its current words (or
|
|
448
451
|
// picture) stay the part's value, as literal text, and the damage is reported.
|
|
452
|
+
candidate.pristine = false;
|
|
449
453
|
if (!part.on) throw error;
|
|
450
454
|
report(slideIndex, `${kind}.${part.zone}.${part.field}: ${error.message} The current ${part.type === 'image' ? 'picture' : 'words'} on the slide master or layout are kept.`);
|
|
451
455
|
if (part.type === 'image') {
|
|
@@ -462,6 +466,7 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText,
|
|
|
462
466
|
// The slide no longer follows the deck's definition: it is its own override, and the other slides can still agree.
|
|
463
467
|
candidate.scope = 'local';
|
|
464
468
|
candidate.derived = true;
|
|
469
|
+
candidate.pristine = false;
|
|
465
470
|
if (value !== false) {
|
|
466
471
|
for (const zone of Object.keys(value)) if (!Object.keys(value[zone]).length) delete value[zone];
|
|
467
472
|
if (!Object.keys(value).length) candidate.value = false;
|
|
@@ -527,13 +532,16 @@ export function importFurniture(contexts, entries, onDiagnostic, {sections = [],
|
|
|
527
532
|
const slide = candidates[index];
|
|
528
533
|
const claimed = new Set([...taggedText[index], ...Object.values(slide).flatMap(candidate => candidate.text)]);
|
|
529
534
|
const fields = readNativePlaceholders(context, claimed, context.relationshipsOf, context.readPart);
|
|
530
|
-
if (!fields.length) return;
|
|
531
535
|
const existing = slide.footer, accepted = [];
|
|
532
536
|
for (const item of fields) {
|
|
533
537
|
const current = existing && existing.value !== false ? existing.value[item.zone]?.[item.field] : undefined;
|
|
538
|
+
if (existing) existing.pristine = false;
|
|
534
539
|
if (current !== undefined && current !== false) report(index, `footer.${item.zone}.${item.field}: A native PowerPoint placeholder duplicates a furniture part already read; it stays ordinary current text.`);
|
|
535
540
|
else accepted.push(item);
|
|
536
541
|
}
|
|
542
|
+
const occupied = new Set(accepted.filter(item => item.field === 'text').map(item => item.zone));
|
|
543
|
+
for (const zone of zones) if (existing?.value && existing.value[zone]?.text !== undefined && existing.value[zone].text !== false) occupied.add(zone);
|
|
544
|
+
accepted.push(...classifyNativeFooters(context, claimed, accepted, occupied, diagnostic => onDiagnostic?.({...diagnostic, path: `slides.${index}.${diagnostic.path}`})));
|
|
537
545
|
if (!accepted.length) return;
|
|
538
546
|
const merged = footerValue(accepted), indexes = accepted.map(item => item.index);
|
|
539
547
|
if (!existing) slide.footer = {scope: 'native', value: merged, text: indexes, pictures: [], socials: []};
|
|
@@ -580,6 +588,6 @@ export function importFurniture(contexts, entries, onDiagnostic, {sections = [],
|
|
|
580
588
|
});
|
|
581
589
|
}
|
|
582
590
|
}
|
|
583
|
-
candidates.forEach((slide, index) => { if (Object.
|
|
591
|
+
candidates.forEach((slide, index) => { if (Object.values(slide).some(candidate => candidate.pristine !== true)) onDiagnostic?.({code: 'furniture-import-reflow', path: `slides.${index}.design`, message: 'Complete furniture roles retain current text, source line boundaries, image content and unambiguous metadata. Native formatting, positioning, crop and other presentation metadata are not reconstructed; review the reflowed furniture.'}); });
|
|
584
592
|
return result;
|
|
585
593
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {XMLParser} from 'fast-xml-parser';
|
|
2
|
-
import {attachTextTags,decodeTextTag} from './code-provenance.js';
|
|
2
|
+
import {attachTextTags,decodeTextTag,groupUnchanged} from './code-provenance.js';
|
|
3
3
|
import {sourceLineParagraphs} from './text-provenance.js';
|
|
4
4
|
import {cleanBase,headingValue,joinRichLines} from './rich-heading.js';
|
|
5
5
|
const TAG='OPF_HEADING_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
6
6
|
const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
|
|
7
7
|
const decoder=new TextDecoder('utf-8',{fatal:true}),array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
|
|
8
|
-
export const attachHeadingTags=(entries,records)=>attachTextTags(entries,records,TAG,'opfHeading','heading');
|
|
8
|
+
export const attachHeadingTags=(entries,records)=>attachTextTags(entries,records,TAG,'opfHeading','heading',{signed:true});
|
|
9
9
|
|
|
10
10
|
// Tags carry only roles and line ordering. Current native text always wins;
|
|
11
11
|
// neither original source text nor old geometry can be restored from these tags.
|
|
@@ -45,7 +45,7 @@ export function importHeadingGroups(shapes,paragraphs,relationships,entries,repo
|
|
|
45
45
|
const base=cleanBase(ordered[0].data.base);
|
|
46
46
|
const parts=base?joinRichLines(ordered,readBody,(item,last)=>source?item.data.separator:last?'':'\n'):undefined;
|
|
47
47
|
items.push({field:first.field,shapes:ordered.map(item=>item.shape),paragraphs:restored,...(parts?{richText:headingValue(parts,base)}:{})});
|
|
48
|
-
report({code:'heading-import-reflow',message:'Complete tagged heading lines retain their roles, order and current native text. New boundary tags also retain authored whitespace and line endings; legacy tags retain native line breaks. Run formatting that differs between words is read back as TextRun[]; geometry is not reconstructed.'});
|
|
48
|
+
if(!groupUnchanged(ordered,paragraphs))report({code:'heading-import-reflow',message:'Complete tagged heading lines retain their roles, order and current native text. New boundary tags also retain authored whitespace and line endings; legacy tags retain native line breaks. Run formatting that differs between words is read back as TextRun[]; geometry is not reconstructed.'});
|
|
49
49
|
}catch(error){report({code:'invalid-heading-provenance',message:`${error.message} Ordinary import retains visible native text.`});}
|
|
50
50
|
return {consumed,items};
|
|
51
51
|
}
|