@openpresentation/opf-pptx 0.16.1 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -11
- package/dist/code-provenance.js +1 -1
- package/dist/document-provenance.js +37 -40
- package/dist/furniture-fields.js +41 -9
- package/dist/furniture-provenance.js +242 -135
- package/dist/image-fallback-node.js +2 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +127 -44
- package/dist/logo-provenance.js +8 -8
- package/dist/master-furniture.js +326 -0
- package/dist/media-dedupe.js +2 -0
- package/dist/native-furniture.js +46 -25
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -69,11 +69,11 @@ Embedded picture bytes are a PNG, JPEG, GIF, WebP or SVG. Bytes that are no read
|
|
|
69
69
|
|
|
70
70
|
SVG pictures are native SVG pictures, as PowerPoint 2016 and Microsoft 365 store them: a `p:pic` whose `a:blip` embeds a PNG fallback raster and carries the `asvg:svgBlip` extension that points at the SVG media part (`image/svg+xml`, content type registered). PowerPoint 2016+ draws the SVG (crisp at any zoom; Graphics Format, Convert to Shape); older viewers draw the PNG. This covers image blocks (placed or not), the alt-text picture of an image background, the watermark, header and footer images and logos, and cover and section logos. The picture takes the same frame and fit (`cover`, `contain`, `stretch`) as a raster of the same proportions; the SVG's size is its `width` and `height` (any unit), else its `viewBox`. Exceptions, each a raster only: a picture bullet (`a:buBlip` can reference a raster only, so it is the PNG), an image background without alt text (a background picture fill carries no SVG), a tiled background picture, and an image block with a duotone recolor or a non-rectangular shape (the effect is written on the PNG blip, so it applies as in the preview, and `svg-image-rasterized` is reported). Opacity (a translucent watermark, slide image opacity), grayscale and the slide image border stay native SVG pictures: PowerPoint applies them to an SVG picture.
|
|
71
71
|
|
|
72
|
-
The PNG fallback is drawn at 192 dpi of the displayed size (a multiple of 128 px on its long side, 128 to 2304 px, transparent background) by `options.svgRasterizer(svg, {width, height, scale, text})`. In Node the default is opf-render's `
|
|
72
|
+
The PNG fallback is drawn at 192 dpi of the displayed size (a multiple of 128 px on its long side, 128 to 2304 px, transparent background) by `options.svgRasterizer(svg, {width, height, scale, text})`. In Node the default is opf-render's `toPng` (resvg with the bundled fonts and no system fonts, so the same SVG gives the same bytes on every machine), loaded only when an SVG is exported: it is the optional peer `@openpresentation/opf-render`. Without opf-render and without `svgRasterizer` (a browser build has no default; pass one that draws the SVG deterministically), the SVG picture is the placeholder with `reason: "svg-rasterizer-unavailable"`. From opf-render 0.16 the PNG converter is itself an optional peer of opf-render: install `@resvg/resvg-js` next to it (`npm install @resvg/resvg-js@^2.6.2`; with text, also the font packages of the `base` pack, `@expo-google-fonts/roboto` and `roboto-mono`). When opf-render rejects with `converter-missing` the exporter reports the same `svg-rasterizer-unavailable` and the message carries opf-render's install command; with `strictAssets` the export throws that code. An `imageResolver` that returns a raster for an SVG source still gives a plain raster picture, and one that returns SVG bytes (`mediaType: "image/svg+xml"`) gives a native SVG picture. A local `.svg` path is read in Node.
|
|
73
73
|
|
|
74
74
|
An SVG is never run or fetched. It is validated as XML and sanitized before it is embedded (and on import): `<script>`, `<foreignObject>`, event-handler attributes, link animations, every `href`/`url()` that is not a `#fragment` or an inline PNG, JPEG, GIF or WebP, `@import` rules, `xml-stylesheet` instructions and DOCTYPE declarations are removed (a DOCTYPE entity that is plain text is expanded; an external or nested entity is refused as `svg-unsafe`), and `svg-sanitized` reports what was removed. An SVG that needs none of that is embedded byte for byte, so import returns the authored source.
|
|
75
75
|
|
|
76
|
-
`fromPptx` reads a picture that carries `asvg:svgBlip` back as the SVG (a `data:image/svg+xml;base64,` source), not its PNG fallback, from this exporter's files and from PowerPoint's (a damaged or hostile SVG part imports its PNG fallback and reports `invalid-svg-image`, or imports sanitized with `svg-sanitized`). Design fields and blocks with SVG sources (`
|
|
76
|
+
`fromPptx` reads a picture that carries `asvg:svgBlip` back as the SVG (a `data:image/svg+xml;base64,` source), not its PNG fallback, from this exporter's files and from PowerPoint's (a damaged or hostile SVG part imports its PNG fallback and reports `invalid-svg-image`, or imports sanitized with `svg-sanitized`). Design fields, organization logos and blocks with SVG sources (`organization.logo`, `design.watermark`, image blocks with their treatments) round-trip as SVG.
|
|
77
77
|
|
|
78
78
|
`fromPptx` parses an existing `.pptx` buffer locally and returns an OPF document that validates with `@openpresentation/opf`:
|
|
79
79
|
|
|
@@ -154,8 +154,8 @@ The first importer is mechanical and schema-compatible:
|
|
|
154
154
|
- Conditional table styles, merged-cell geometry, cell fills/borders/alignment, unsupported text fills/colors, and internal hyperlink actions are not fully reconstructed. `onDiagnostic` reports unsupported table style references, merges, fonts/colors/fills and links with native frame/cell paths. Table paths use the native graphic-frame and row indexes, including a header row. The shared core 0.6.0 layout sizes rows from their content and reports `text-overflow` when text cannot fit at the minimum size. These checks establish native XML conversion, not visual parity with PowerPoint.
|
|
155
155
|
- Unknown non-text shapes and unsupported graphic frames become editable text fallback blocks instead of failing the import.
|
|
156
156
|
- Catalog references (`design.theme`, `colorScheme`, `fontScheme`, `dimensions`, `background`, slide `layout`), slide ids and authoring metadata (`narrative`, `tone`, `audience`, `purpose`, `language`, `organization`, `speaker`, ...) are stored at export in `OPF_DOCUMENT_V1` / `OPF_SLIDE_V1` customer-data tags. Import restores a reference while the theme colors, theme fonts, slide size, background or slide arrangement it produced are unchanged. After an edit, the observed native values stay and `design-reference-changed` / `layout-reference-changed` name the reference. `toPptx` option `provenance: 'references-only' | false` limits or disables these invisible tags. In `full` mode the document tag also lists the document-level defaults the document left to the engine (`$schema`, `name`, `language`, the theme, colour scheme, font scheme and slide size), and import leaves them absent while the package still shows the default, so an untouched round trip is deep-equal to the source (RR-59); a value edited in PowerPoint imports as observed. Slide layout intent (layout reference, type, composition, composition hints and the embedded layout record with its catalogs group and source) lives in each `OPF_SLIDE_V1` record, so a slide keeps its layout even without the document tag, for example when it is pasted into another deck (FF-29). A layout that resolves only in a registered catalog stores no record and restores where `fromPptx` is given that catalog. See [document round trips](docs/document-roundtrip.md) for exactly what is embedded.
|
|
157
|
-
- The slide's content structure is part of `OPF_SLIDE_V1` too (`full` mode): nested `group` blocks, promoted regions (`left`, `top:left`, ...), a root payload (`text`, `items`, `chart`, ...) versus `blocks`, block `id`s and `extensions`, group `composition`, with the reference-pixel box of every leaf from the same composition the export drew. Import matches the native shapes to those boxes and rebuilds the authored form while the slide's arrangement is unchanged, so `slide.type` validates again; a slide whose blocks no longer fit the stored boxes keeps its flat blocks and reports `content-structure-changed` at `slides.N`; a duplicated slide reports `duplicate-block-id` and drops the repeated id. The document tag also stores `filename`, root `extensions`, slide `section` and `extensions`, `design.logo` (deck and slide) and the whole `assets` registry; a `data:` source that is not an exported picture is stored inline up to 256 KiB, so an organization logo or speaker photo given as a data URI round-trips.
|
|
158
|
-
- Slide `section` labels are written as PowerPoint's native section list (`p14:sectionLst` in `ppt/presentation.xml`, whatever the `provenance` option): one section per run of consecutive slides with the same label, runs without a label as `Default Section`, with deterministic ids. Import reads the list back (`Default Section` means no section); it wins over the stored value
|
|
157
|
+
- The slide's content structure is part of `OPF_SLIDE_V1` too (`full` mode): nested `group` blocks, promoted regions (`left`, `top:left`, ...), a root payload (`text`, `items`, `chart`, ...) versus `blocks`, block `id`s and `extensions`, group `composition`, with the reference-pixel box of every leaf from the same composition the export drew. Import matches the native shapes to those boxes and rebuilds the authored form while the slide's arrangement is unchanged, so `slide.type` validates again; a slide whose blocks no longer fit the stored boxes keeps its flat blocks and reports `content-structure-changed` at `slides.N`; a duplicated slide reports `duplicate-block-id` and drops the repeated id. The document tag also stores `filename`, root `extensions`, slide `section` and `extensions`, `design.logo` (deck and slide: a logo reference or `false`), the organizations with their logos and the whole `assets` registry; a `data:` source that is not an exported picture is stored inline up to 256 KiB, so an organization logo or speaker photo given as a data URI round-trips.
|
|
158
|
+
- Slide `section` labels are written as PowerPoint's native section list (`p14:sectionLst` in `ppt/presentation.xml`, whatever the `provenance` option): one section per run of consecutive slides with the same label, runs without a label as `Default Section`, with deterministic ids. Import reads the list back (`Default Section` means no section); it wins over the stored value (a footer's `{{slide.section}}` is only the token, so no footer text names the section), except that the stored label stands while the list still names it. The native PowerPoint check of the sections pane and save/reopen is a separate gate (`scratchpad/spec-gaps-native/`).
|
|
159
159
|
|
|
160
160
|
There is no AI classification pass in the OSS runtime. Hosts can run optional cleanup or semantic remapping after `fromPptx` returns. A host that wants to rebuild structure (code, metrics, quotes, timelines, regions) in a deck that did not come from OPF can ask for the raw shape facts such a step needs; see [Import signals](#import-signals).
|
|
161
161
|
|
|
@@ -335,10 +335,10 @@ A deck or slide watermark (`design.watermark`) exports as one native picture nam
|
|
|
335
335
|
|
|
336
336
|
### Brand assets, picture bullets and the accent font (spec-gap closure A)
|
|
337
337
|
|
|
338
|
-
These design fields need the core that composes them (`resolveLogo`, `geometry.logo`, `item.bulletImage`, `fontScheme.accent
|
|
338
|
+
These design fields need the core that composes them (`resolveLogo`, `geometry.logo`, `item.bulletImage`, `fontScheme.accent`, the organization logo shapes; core 0.18 or the coordinated source). On core 0.11.3 the export carries none of them and is byte-identical to the previous output. Each one is drawn at the box core composes, which the preview draws too (see core `docs/design-resolution.md`, "Brand assets and layout hints").
|
|
339
339
|
|
|
340
|
-
- **Cover and section logo** (`
|
|
341
|
-
- **Header and footer `
|
|
340
|
+
- **Cover and section logo** (the primary organization's `logo`, in its `full` shape; `design.logo` on the deck or a slide names another organization or shape, `var:organization.beta.logo`, `var:organization.logo.wordmark`, or is `false` for none). One native picture named `OPF logo`, after the watermark and before the content, fitted without cropping into core's `geometry.logo` box (56 px high at 720 px, up to four times as wide), anchored at the box's left edge and vertically centered, exactly the preview's `xMinYMid meet`. Its alt text is the asset's `alt`, otherwise `Logo`. A shape given as `{onLight, onDark}` picks its tone from the slide background (a dark background takes `onDark`); the exporter's dark test is the one that already chooses the text colour. An unresolved or unreadable logo draws the preview's "Image unavailable" panel in the box and reports `unresolved-asset` at the organization's logo path (`organization.logo.full.onDark`; `strictAssets` throws). Content slides never get one. The picture carries an `OPF_LOGO_V1` tag (slide, source path, tone, the exact picture properties): an unchanged export consumes the picture on import (it is not content; the organization, with every shape and tone, and `design.logo` return from the document and slide records), and an edited, ambiguous or damaged one stays an ordinary picture and reports `invalid-logo-provenance`. When nothing else restored the organization's logo (an export with `provenance: false`, or with `references-only`, which stores no sources), the consumed picture's own image becomes the logo of the primary organization (a new organization named after the deck when there is none), so the logo is never lost; it is the one drawn image for every shape. A PPTX with no OPF tags imports the picture as an ordinary image block and invents no organization.
|
|
341
|
+
- **Header and footer logos** (`image: "var:organization.logo.icon"`, or `var:organization.logo`, `.stacked`, `.wordmark`, or a partner's `var:organization.beta.logo`). Core lays the parts of a zone out in one row (image, text, socials, date, each at its natural width, vertically centered, `FURNITURE_GAP` apart) and resolves the reference for each slide's background, so an `{onLight, onDark}` shape draws a different asset on a dark slide. The exporter draws every part at `part.box`; a logo is an ordinary furniture image part (the `OPF_FURNITURE_V1` picture tag) whose slide manifest also records the authored reference and the content key of the bytes it embedded (`images`: `{reference, media}` per `kind.zone`, beside `parts` and `definitions`, which every released importer validates strictly). It re-imports as the reference while the picture still embeds those bytes and the stored organization still has a logo for it; a replaced picture (Change Picture), a foreign picture, `provenance: false` and `references-only` (no stored organization) import it as an ordinary `image`. A reference whose organization has no logo reports `unresolved-content` at `<zone>.image`, draws nothing and still returns as the reference (`drawn: false`). An asset that cannot be drawn reports `unresolved-asset` at the organization's logo path.
|
|
342
342
|
- **Picture bullets** (`design.listBullet: image`, resolved to the icon logo by core as `item.bulletImage`). Every entry's first line carries `<a:buSzPct val="100000"/><a:buBlip><a:blip r:embed=…/></a:buBlip>` in place of the character bullet, so the marker is the text size, like the preview's square marker. PptxGenJS embeds the icon once per slide (a picture named `OPF bullet image`); packaging removes that picture and keeps the media part and its relationship for the `a:buBlip` elements. Marker indent and text positions are those of the character bullets. An icon that cannot be embedded keeps the character bullets and reports `unresolved-asset` once per slide at the logo's path. Import recognizes `a:buBlip` paragraphs as list paragraphs; `design.listBullet` returns from the stored design hint (a PPTX without tags has no hint). PowerPoint shows a picture bullet at the picture's own aspect ratio; use a square icon for the preview's square marker. The released 0.11.6 importer does not know `a:buBlip` and imports such a list as plain text lines.
|
|
343
343
|
- **`fontScheme.accent`.** Core resolves the accent role for the slide tag (eyebrow) and the quote body, so their runs carry the accent typeface in `a:latin`, `a:ea` and `a:cs` with the catalog's pitch family when the family is known; theme major/minor fonts, every other run and the theme are unchanged. The accent family appears in `docProps/app.xml` "Fonts Used" like any other used font; `checkTypefaces` callers list it in `options.fonts`. `design.fontScheme` (with `accent`) returns from the document tag.
|
|
344
344
|
|
|
@@ -453,12 +453,14 @@ Version 0.5.0 requires core 0.7.0 and renderer 0.5.0 for coordinated previews. `
|
|
|
453
453
|
|
|
454
454
|
Version 0.8.0 exports editable native lines with explicit four-space tab stops. Boundary tags contain no original source words, so native edits and deletions remain authoritative. XML-unrepresentable scalar controls reject with an actionable path rather than silently losing characters. Twenty wide/portrait measured/estimated export cases, current-text mutation controls and damaged-tag fallbacks pass; native Office acceptance remains separate. The coordinated core browser workflow also verifies editing, undo and exact reimport.
|
|
455
455
|
|
|
456
|
-
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
|
|
456
|
+
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.
|
|
457
457
|
|
|
458
|
-
|
|
458
|
+
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.
|
|
459
459
|
|
|
460
460
|
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.
|
|
461
461
|
|
|
462
|
-
Missing, duplicated or inconsistent furniture tags fall back to current native content with an `invalid-furniture-provenance` diagnostic.
|
|
462
|
+
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.
|
|
463
463
|
|
|
464
|
-
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. Header parts,
|
|
464
|
+
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` with a `{{slide.number}}` is the `sldNum` placeholder (its other words are 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).
|
|
465
|
+
|
|
466
|
+
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).
|
package/dist/code-provenance.js
CHANGED
|
@@ -91,7 +91,7 @@ export function nativeShapeParagraphs(xml, rootElement = 'p:sld') {
|
|
|
91
91
|
};
|
|
92
92
|
for (const child of paragraph) {
|
|
93
93
|
if (child['a:br'] !== undefined) text += '\n';
|
|
94
|
-
if (child['a:fld'] !== undefined) fields.push({type: String(child[':@']?.type ?? ''), text: children(child['a:fld'],'a:t').map(plainText).join('')});
|
|
94
|
+
if (child['a:fld'] !== undefined) fields.push({type: String(child[':@']?.type ?? ''), text: children(child['a:fld'],'a:t').map(plainText).join(''), start: text.length});
|
|
95
95
|
text += readRuns(child);
|
|
96
96
|
if (child['mc:AlternateContent'] !== undefined) {
|
|
97
97
|
// FF-45: an a14:m math zone (an OMML equation, m:oMathPara) has no OPF model. Its mc:Fallback runs (the text PowerPoint
|
|
@@ -108,10 +108,12 @@ export function splitAuthors(creator) {
|
|
|
108
108
|
export const DEFAULT_AUTHOR = 'OpenPresentation';
|
|
109
109
|
// Stored only when the native creator cannot carry the authored form by itself (or would read as the default).
|
|
110
110
|
const authorNeedsRecord = author => Array.isArray(author) || (typeof author === 'string' && (author === DEFAULT_AUTHOR || Array.isArray(splitAuthors(author))));
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// consumes the drawn logo
|
|
111
|
+
// `design.logo` (RR-71: a logo reference such as `var:organization.beta.logo`, or `false`) has no native gate: it returns
|
|
112
|
+
// from the stored value. The logo image itself lives on the organization (metadata `organization`); the cover picture
|
|
113
|
+
// (`OPF_LOGO_V1`) only consumes the drawn logo.
|
|
114
114
|
export const BRAND_ASSETS = Object.freeze(['logo']);
|
|
115
|
+
// A logo reference names an organization's logo, which 'references-only' mode does not store: the reference stays out with it.
|
|
116
|
+
const storesOnlyWithOrganization = (key, value) => key === 'logo' && typeof value === 'string';
|
|
115
117
|
const STYLE_REFERENCES = ['theme', 'colorScheme', 'fontScheme'];
|
|
116
118
|
const SLIDE_STRUCTURE = ['layout', 'type', 'composition'];
|
|
117
119
|
// Slide fields with no native counterpart, restored from the stored value.
|
|
@@ -471,7 +473,7 @@ export function documentProvenance(presentation, {mode = 'full', layoutOf = () =
|
|
|
471
473
|
const design = {}, metadata = {};
|
|
472
474
|
for (const key of DESIGN_FIELDS) {
|
|
473
475
|
if (!own(presentation.design, key)) continue;
|
|
474
|
-
if (referencesOnly && carriesSource(presentation.design[key])) continue;
|
|
476
|
+
if (referencesOnly && (carriesSource(presentation.design[key]) || storesOnlyWithOrganization(key, presentation.design[key]))) continue;
|
|
475
477
|
design[key] = clone(presentation.design[key]);
|
|
476
478
|
}
|
|
477
479
|
for (const key of METADATA) {
|
|
@@ -499,7 +501,7 @@ export function documentProvenance(presentation, {mode = 'full', layoutOf = () =
|
|
|
499
501
|
}
|
|
500
502
|
const slideDesign = {};
|
|
501
503
|
for (const key of SLIDE_DESIGN_FIELDS) {
|
|
502
|
-
if (!own(slide.design, key) || (referencesOnly && carriesSource(slide.design[key]))) continue;
|
|
504
|
+
if (!own(slide.design, key) || (referencesOnly && (carriesSource(slide.design[key]) || storesOnlyWithOrganization(key, slide.design[key])))) continue;
|
|
503
505
|
slideDesign[key] = clone(slide.design[key]);
|
|
504
506
|
}
|
|
505
507
|
if (Object.keys(slideDesign).length) record.design = slideDesign;
|
|
@@ -928,6 +930,20 @@ export function slideListBreaks(entries, path, root, rels) {
|
|
|
928
930
|
}
|
|
929
931
|
}
|
|
930
932
|
|
|
933
|
+
/**
|
|
934
|
+
* The authoring metadata the stored document record (OPF_DOCUMENT_V1) holds (organization, speaker, ...), or {} when it is missing
|
|
935
|
+
* or unreadable. Furniture import reads it before the slides: a socials part belongs to an organization by id, and a stored
|
|
936
|
+
* header or footer text with built-in variables ({{organization.name}}) is drawn from it. Read only; nothing is restored here.
|
|
937
|
+
*/
|
|
938
|
+
export function storedMetadata(entries, presentationRoot, presentationRels) {
|
|
939
|
+
try {
|
|
940
|
+
const found = readTag(entries, presentationRoot?.['p:custDataLst'], presentationRels, DOCUMENT_TAG);
|
|
941
|
+
if (found.missing) return {};
|
|
942
|
+
const metadata = validateDocument(found.value).metadata;
|
|
943
|
+
return object(metadata) ? metadata : {};
|
|
944
|
+
} catch { return {}; }
|
|
945
|
+
}
|
|
946
|
+
|
|
931
947
|
/**
|
|
932
948
|
* Read OPF_DOCUMENT_V1 / OPF_SLIDE_V1 and decide what still matches the
|
|
933
949
|
* package. Nothing is modified here: the result lists restore groups (one per
|
|
@@ -949,7 +965,7 @@ export function slideListBreaks(entries, path, root, rels) {
|
|
|
949
965
|
* `nativeSections` is the package's native section list as one name (or
|
|
950
966
|
* undefined) per slide, or null when the package has no list.
|
|
951
967
|
*/
|
|
952
|
-
export function restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, slides,
|
|
968
|
+
export function restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, slides, catalogs: hostCatalogs, nativeSections = null, schemaOption = false}, report) {
|
|
953
969
|
const invalid = message => report({code: 'invalid-document-provenance', path: '', message: `${message} Ordinary import keeps the values observed in the PPTX.`});
|
|
954
970
|
let document;
|
|
955
971
|
try {
|
|
@@ -1018,27 +1034,24 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
|
|
|
1018
1034
|
return true;
|
|
1019
1035
|
};
|
|
1020
1036
|
|
|
1021
|
-
// Sections:
|
|
1022
|
-
// list
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
// the
|
|
1026
|
-
|
|
1027
|
-
// Labels compare the way the native list stores them: tab, LF and CR are
|
|
1028
|
-
// spaces in an XML attribute, and a blank label is no section.
|
|
1029
|
-
const blank = value => typeof value !== 'string' || value.trim() === '';
|
|
1030
|
-
const normalized = value => blank(value) ? '' : value.replace(/[\t\n\r]/g, ' ');
|
|
1031
|
-
const sameSection = (a, b) => normalized(a) === normalized(b);
|
|
1037
|
+
// Sections: two sources name a slide's section, the native section list (PowerPoint's sections pane) and the stored
|
|
1038
|
+
// OPF_SLIDE_V1 value. The list is native data and wins, except that the stored label stands while the list still names it:
|
|
1039
|
+
// a label is an XML attribute, so tab, LF and CR come back from the list as spaces (src/sections.js) and compare as such.
|
|
1040
|
+
// Without a list the stored value is the fallback. (A footer's `{{slide.section}}` is only the token, FA-31: no footer
|
|
1041
|
+
// shows the section text any more.)
|
|
1042
|
+
const spaced = value => (typeof value !== 'string' || value.trim() === '') ? '' : value.replace(/[\t\n\r]/g, ' ');
|
|
1032
1043
|
const reconcileSection = (index, stored) => {
|
|
1033
|
-
const shown = imported.slides[index]?.section;
|
|
1034
1044
|
if (!nativeSections) {
|
|
1035
|
-
if (stored !== undefined
|
|
1045
|
+
if (stored !== undefined) group(`slides.${index}.section`, [set(['slides', index, 'section'], clone(stored))]);
|
|
1036
1046
|
return;
|
|
1037
1047
|
}
|
|
1038
1048
|
const listed = nativeSections[index];
|
|
1039
|
-
|
|
1040
|
-
if (
|
|
1041
|
-
|
|
1049
|
+
// A slide in no named section: the list cannot say a blank label, so the stored blank one (if authored) returns.
|
|
1050
|
+
if (listed === undefined) {
|
|
1051
|
+
if (stored !== undefined && spaced(stored) === '') group(`slides.${index}.section`, [set(['slides', index, 'section'], clone(stored))]);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
group(`slides.${index}.section`, [set(['slides', index, 'section'], stored !== undefined && spaced(stored) === spaced(listed) ? clone(stored) : listed)]);
|
|
1042
1055
|
};
|
|
1043
1056
|
|
|
1044
1057
|
// Without document provenance (a slide pasted into another deck, or a
|
|
@@ -1189,21 +1202,13 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
|
|
|
1189
1202
|
}
|
|
1190
1203
|
|
|
1191
1204
|
// Authoring metadata has no native counterpart. Fields read from native
|
|
1192
|
-
// content (
|
|
1193
|
-
//
|
|
1205
|
+
// content (linked socials) win over their stored values; stored-only fields
|
|
1206
|
+
// (name, logo, role ...) return.
|
|
1194
1207
|
const metadata = document.metadata ?? {};
|
|
1195
1208
|
// The exporter writes the default creator when no author was authored: with the document record present, that default is not the author.
|
|
1196
1209
|
if (metadata.author === undefined && imported.author === DEFAULT_AUTHOR) group('author', [remove(['author'])]);
|
|
1197
1210
|
for (const key of METADATA) {
|
|
1198
1211
|
if (metadata[key] === undefined) continue;
|
|
1199
|
-
if (key === 'organization' && organizationConflict) {
|
|
1200
|
-
report({code: 'metadata-reference-changed', path: 'organization', message: 'Slides now show different organization names in their furniture, so the stored organization was not restored; each slide keeps its visible text.'});
|
|
1201
|
-
continue;
|
|
1202
|
-
}
|
|
1203
|
-
if (key === 'speaker' && speakerConflict) {
|
|
1204
|
-
report({code: 'metadata-reference-changed', path: 'speaker', message: 'Slides now show different speaker names in their furniture, so the stored speaker was not restored; each slide keeps its visible text.'});
|
|
1205
|
-
continue;
|
|
1206
|
-
}
|
|
1207
1212
|
if (key === 'author') {
|
|
1208
1213
|
// Native evidence: the stored authored form only stands for the creator it was joined into.
|
|
1209
1214
|
if (joinAuthors(imported.author) === joinAuthors(metadata.author)) group('author', [set(['author'], clone(metadata.author))]);
|
|
@@ -1222,14 +1227,6 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
|
|
|
1222
1227
|
else if (Array.isArray(value)) value[index] = {...list[index], ...merge(list[index], clone(observed))};
|
|
1223
1228
|
else value = {...value, ...merge(value, clone(observed))};
|
|
1224
1229
|
}
|
|
1225
|
-
if (key === 'speaker' && object(imported.speaker)) {
|
|
1226
|
-
// The furniture speaker line (name and title) is native content and wins over the stored speaker with the same id.
|
|
1227
|
-
const observed = imported.speaker, list = array(value), index = list.findIndex(item => object(item) && item.id === observed.id);
|
|
1228
|
-
const merged = stored => { const {title: _title, ...rest} = stored; return {...rest, ...clone(observed)}; };
|
|
1229
|
-
if (index < 0) value = clone(observed);
|
|
1230
|
-
else if (Array.isArray(value)) value[index] = merged(list[index]);
|
|
1231
|
-
else value = merged(value);
|
|
1232
|
-
}
|
|
1233
1230
|
group(key, [set([key], value)]);
|
|
1234
1231
|
}
|
|
1235
1232
|
|
package/dist/furniture-fields.js
CHANGED
|
@@ -107,19 +107,51 @@ export function parseDate(text, pattern) {
|
|
|
107
107
|
return formatDate(iso, pattern) === text ? iso : null;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Live field ranges for one accepted furniture text part: core's `part.fields` (one `slideNumber` per substituted
|
|
112
|
+
* `{{slide.number}}`, one `date` for a current date), as half-open UTF-16 offsets into `part.text`.
|
|
113
|
+
*/
|
|
114
|
+
export function furniturePartFields(part) {
|
|
115
|
+
return Array.isArray(part.fields) ? part.fields : [];
|
|
113
116
|
}
|
|
114
117
|
|
|
118
|
+
/** True when a core text part carries at least one slide-number field. */
|
|
119
|
+
export const hasSlideNumberField = part => furniturePartFields(part).some(field => field.type === 'slideNumber');
|
|
120
|
+
|
|
121
|
+
export const SLIDE_NUMBER_TOKEN = '{{slide.number}}';
|
|
122
|
+
// The slide-scoped tokens core substitutes per slide (core SLIDE_TOKEN_BODY): typed text that spells one must be
|
|
123
|
+
// escaped, or the next export would turn it into a live value.
|
|
124
|
+
const literalSlideToken = /\{\{(?=\s*(?:slide(?:\.[A-Za-z0-9_-]+){1,2}|deck\.slideCount)\s*(?:\|[^{}]*)?\}\})/g;
|
|
125
|
+
const slideTokenUsed = /\{\{\s*(?:slide\.|deck\.slideCount)/;
|
|
126
|
+
/** True when a string mentions a slide-scoped token (escaped or not). */
|
|
127
|
+
export const mentionsSlideToken = text => typeof text === 'string' && slideTokenUsed.test(text);
|
|
128
|
+
const anyToken = /(\\)?\{\{\s*([^{}|]*?)\s*(?:\|[^{}]*)?\}\}/g;
|
|
129
|
+
const builtinName = /^(?:speakers|(?:deck|speaker|organization|slide)(?:\.[A-Za-z0-9_-]+){1,2})$/;
|
|
115
130
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
131
|
+
* True when a zone text uses {{ }} and every unescaped token in it is a built-in variable (core's pattern: deck.*, speaker.*,
|
|
132
|
+
* speakers, organization.*, slide.*, deck.slideCount). Such an authored text is stored in the furniture manifest because the deck-wide
|
|
133
|
+
* pass resolves its built-ins before export; a text with a user variable ({{customer}}) is not: the filled deck is what round-trips.
|
|
119
134
|
*/
|
|
120
|
-
export function
|
|
121
|
-
if (
|
|
122
|
-
|
|
135
|
+
export function isBuiltinTemplate(text) {
|
|
136
|
+
if (typeof text !== 'string' || !text.includes('{{')) return false;
|
|
137
|
+
for (const match of text.matchAll(anyToken)) if (!match[1] && !builtinName.test(match[2])) return false;
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
export const escapeSlideTokens = text => text.replace(literalSlideToken, '\\{{');
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The zone `text` a native footer or header line stands for: every native slide-number field (`{type, text, start}`,
|
|
144
|
+
* `start` an offset into `text`) becomes `{{slide.number}}`, and typed words that spell a slide-scoped token stay
|
|
145
|
+
* literal. A field whose words are not at its recorded offset is left as the words it shows.
|
|
146
|
+
*/
|
|
147
|
+
export function slideNumberTemplate(text, nativeFields) {
|
|
148
|
+
let cursor = 0, out = '';
|
|
149
|
+
for (const field of [...nativeFields].filter(field => field.type === 'slidenum').sort((a, b) => a.start - b.start)) {
|
|
150
|
+
if (!(field.start >= cursor) || text.slice(field.start, field.start + field.text.length) !== field.text) continue;
|
|
151
|
+
out += escapeSlideTokens(text.slice(cursor, field.start)) + SLIDE_NUMBER_TOKEN;
|
|
152
|
+
cursor = field.start + field.text.length;
|
|
153
|
+
}
|
|
154
|
+
return out + escapeSlideTokens(text.slice(cursor));
|
|
123
155
|
}
|
|
124
156
|
|
|
125
157
|
/** Native <a:fld> type for a core field, or null when the value must stay fixed text. */
|