@openpresentation/opf-pptx 0.17.0 → 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 +9 -7
- package/dist/document-provenance.js +7 -5
- package/dist/furniture-provenance.js +179 -69
- package/dist/image-fallback-node.js +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +78 -27
- 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 +6 -2
- package/package.json +4 -4
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,7 +154,7 @@ 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.
|
|
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
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).
|
|
@@ -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
|
|
|
@@ -461,4 +461,6 @@ When a supported current-date field wraps across lines, full provenance records
|
|
|
461
461
|
|
|
462
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. 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, second text/date/number parts and multi-line text stay tagged shapes (PowerPoint has no object for them). `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).
|
|
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).
|
|
@@ -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;
|
|
@@ -3,7 +3,8 @@ import {attachTextTags, decodeTextTag, encodeTextTag} 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
|
-
import {
|
|
6
|
+
import {resolveLogo} from '@openpresentation/opf/composition';
|
|
7
|
+
import {mediaKey} from './media-dedupe.js';
|
|
7
8
|
import {NATIVE_PLACEHOLDERS, footerValue, isNativePlaceholderType, readNativePlaceholders} from './native-furniture.js';
|
|
8
9
|
|
|
9
10
|
const TAG = 'OPF_FURNITURE_V1';
|
|
@@ -26,6 +27,8 @@ const platformId = new RegExp(schemas.presentation.$defs.Socials.propertyNames.p
|
|
|
26
27
|
const socialLines = part => part.links.map(link => ({platform: link.platform,
|
|
27
28
|
scheme: link.href && !/^[a-z][a-z0-9+.-]*:/i.test(link.text) ? 'https://' : ''}));
|
|
28
29
|
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
30
|
+
const LOGO_REFERENCE = /^var:organization(?:\.[A-Za-z0-9_-]{1,64}){1,3}$/;
|
|
31
|
+
const isLogoReference = value => typeof value === 'string' && value.length <= 200 && LOGO_REFERENCE.test(value);
|
|
29
32
|
const key = part => `${part.kind}.${part.zone}.${part.field}`;
|
|
30
33
|
// Key order is not meaning: a value merged from native placeholders equals the same value read from a manifest.
|
|
31
34
|
const canonical = value => JSON.stringify(value, (key, item) => object(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : item);
|
|
@@ -72,11 +75,13 @@ function unchangedStaticDate(marker, format, ordered, paragraphs) {
|
|
|
72
75
|
}), 'Current static date text or boundaries differ from the exported lines.');
|
|
73
76
|
}
|
|
74
77
|
|
|
75
|
-
// This manifest contains topology, identities, inactive flags, format settings
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
+
// This manifest contains topology, identities, inactive flags, format settings, the authored text of a zone whose `text` uses
|
|
79
|
+
// built-in variables (`templates`, FA-31; `authored` is the document as given to toPptx, before the deck-wide pass) and the
|
|
80
|
+
// organization logo reference of a zone `image` (`images`, RR-71: `{reference, media?, drawn?}` per `kind.zone`). Current native
|
|
81
|
+
// shapes supply all words, image bytes and alt text, even after edits; a recorded format, template or logo reference is kept
|
|
82
|
+
// only while the current text or picture still matches it exactly.
|
|
78
83
|
export function furnitureManifest(presentation, slide, layout, slideIndex, staticDates = new Map(), natives = new Map(), authored) {
|
|
79
|
-
const definitions = {}, formats = {}, templates = {};
|
|
84
|
+
const definitions = {}, formats = {}, templates = {}, images = {};
|
|
80
85
|
for (const kind of kinds) {
|
|
81
86
|
const local = slide.design?.[kind] !== undefined;
|
|
82
87
|
const source = local ? slide.design[kind] : presentation.design?.[kind];
|
|
@@ -84,8 +89,14 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
|
|
|
84
89
|
const value = source === false ? false : {};
|
|
85
90
|
if (value !== false) for (const zone of zones) if (source[zone] !== undefined) {
|
|
86
91
|
value[zone] = {};
|
|
92
|
+
// An organization logo reference (`var:organization.logo.icon`): core lays out an image part with `reference`; the manifest keeps
|
|
93
|
+
// the authored string. With no part (the organization has no logo) the reference is all there is, and import restores it.
|
|
94
|
+
const reference = isLogoReference(source[zone].image) ? source[zone].image : undefined;
|
|
95
|
+
const imagePart = layout.parts.find(part => part.kind === kind && part.zone === zone && part.type === 'image');
|
|
96
|
+
if (reference !== undefined) images[`${kind}.${zone}`] = imagePart ? {reference} : {reference, drawn: false};
|
|
87
97
|
for (const field of fields) if (source[zone][field] !== undefined) {
|
|
88
98
|
const current = source[zone][field];
|
|
99
|
+
if (field === 'image' && reference !== undefined && !imagePart) continue;
|
|
89
100
|
value[zone][field] = field === 'image' ? 'native' : typeof current === 'string' ? 'literal' : current;
|
|
90
101
|
}
|
|
91
102
|
const format = Object.fromEntries(settings.filter(setting => typeof source[zone][setting] === 'string').map(setting => [setting, source[zone][setting]]));
|
|
@@ -103,35 +114,19 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
|
|
|
103
114
|
if (!Object.keys(definitions).length) return null;
|
|
104
115
|
const organizations = array(presentation.organization);
|
|
105
116
|
const organization = organizations.find(item => item.role === 'primary') ?? organizations[0];
|
|
106
|
-
// Generated deck logos (logo: true) are listed beside the topology, never inside it: the part list and the field
|
|
107
|
-
// definitions are validated strictly by every released importer (0.11.6 and earlier reject an unknown field there),
|
|
108
|
-
// so they keep describing only what those importers know. `drawn` is false when no logo resolved at export.
|
|
109
|
-
const logos = [];
|
|
110
|
-
for (const kind of kinds) {
|
|
111
|
-
const source = (slide.design?.[kind] !== undefined ? slide.design : presentation.design)?.[kind];
|
|
112
|
-
for (const zone of zones) if (definitions[kind]?.value?.[zone] && source?.[zone]?.logo === true) {
|
|
113
|
-
logos.push({kind, zone, drawn: layout.parts.some(part => part.field === 'logo' && part.kind === kind && part.zone === zone)});
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
117
|
return {v: 1, role: 'slide', group: String(slideIndex), definitions, ...(Object.keys(formats).length ? {formats} : {}),
|
|
117
|
-
...(Object.keys(templates).length ? {templates} : {}),
|
|
118
|
+
...(Object.keys(templates).length ? {templates} : {}), ...(Object.keys(images).length ? {images} : {}),
|
|
118
119
|
...(layout.parts.some(part => part.field === 'socials') ? {organizationId: organization?.id} : {}),
|
|
119
|
-
parts: layout.parts.
|
|
120
|
+
parts: layout.parts.map((part, index) => ({kind: part.kind, zone: part.zone, field: part.field,
|
|
120
121
|
type: part.type, count: part.type === 'image' ? 1 : part.fit.lines.length,
|
|
121
122
|
...(part.field === 'socials' ? {socials: socialLines(part)} : {}),
|
|
122
123
|
...(staticDates.has(index) ? {staticDate: staticDates.get(index)} : {}),
|
|
123
124
|
// RR-11: this part is a native PowerPoint placeholder (dt, ftr or sldNum); deleting it in PowerPoint's dialog is then intent.
|
|
124
|
-
...(natives.has(index) ? {ph: natives.get(index)} : {})}
|
|
125
|
-
...(logos.length ? {logos} : {})};
|
|
125
|
+
...(natives.has(index) ? {ph: natives.get(index)} : {})}))};
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
|
|
129
|
-
export const manifestPartIndex = (parts, index) => parts.slice(0, index).filter(part => part.field !== 'logo').length;
|
|
130
|
-
|
|
131
|
-
export function attachFurnitureTags(entries, records, manifests, logoRecords = new Map()) {
|
|
128
|
+
export function attachFurnitureTags(entries, records, manifests) {
|
|
132
129
|
attachTextTags(entries, records, TAG, 'opfFurniture', 'furniture', {pictures: true});
|
|
133
|
-
// A generated logo picture carries the logo tag (never the furniture tag): see logo-provenance.js.
|
|
134
|
-
attachTextTags(entries, logoRecords, LOGO_TAG, 'opfFurnitureLogo', 'furniture logo', {pictures: true});
|
|
135
130
|
const types = [];
|
|
136
131
|
for (const [path, manifest] of manifests) {
|
|
137
132
|
const part = `ppt/tags/opfFurnitureSlide${manifest.group}.xml`;
|
|
@@ -153,6 +148,49 @@ export function attachFurnitureTags(entries, records, manifests, logoRecords = n
|
|
|
153
148
|
if (types.length) entries['[Content_Types].xml'] = enc.encode(dec.decode(entries['[Content_Types].xml']).replace('</Types>', types.join('') + '</Types>'));
|
|
154
149
|
}
|
|
155
150
|
|
|
151
|
+
/**
|
|
152
|
+
* An organization `logo` value reduced to what logo resolution needs (RR-71): the shapes and tones that exist, each as a
|
|
153
|
+
* placeholder string (a stored picture, an asset reference and a path all count), or undefined when it has none.
|
|
154
|
+
*/
|
|
155
|
+
export function logoShape(value) {
|
|
156
|
+
if (typeof value === 'string') return value.trim() ? 'x' : undefined;
|
|
157
|
+
if (!object(value)) return undefined;
|
|
158
|
+
if (typeof value.src === 'string') return value.src.trim() ? 'x' : undefined;
|
|
159
|
+
if (typeof value.$opfMedia === 'string') return 'x';
|
|
160
|
+
const kept = Object.fromEntries(Object.entries(value).map(([name, item]) => [name, logoShape(item)]).filter(([, item]) => item !== undefined));
|
|
161
|
+
return Object.keys(kept).length ? kept : undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The content key of the embedded bytes of a picture: its main blip's media part.
|
|
165
|
+
function pictureKey(picture, relationships, entries) {
|
|
166
|
+
const target = relationships.get(picture?.['p:blipFill']?.['a:blip']?.['r:embed'])?.path;
|
|
167
|
+
return target && entries[target] ? mediaKey(entries[target]) : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Finish the `images` of each slide manifest once the package is final (SVG parts, format conversion and media de-duplication
|
|
172
|
+
* change what a picture embeds): the media key of every logo-reference picture, or `drawn: false` when the picture could not be
|
|
173
|
+
* embedded. Rewrites the slide's furniture tag part. `names` maps `<slide part>|<kind>.<zone>` to the picture's object name.
|
|
174
|
+
*/
|
|
175
|
+
export function stampFurnitureImageKeys(entries, manifests, names, relationshipsOf) {
|
|
176
|
+
for (const [path, manifest] of manifests) {
|
|
177
|
+
if (!manifest.images) continue;
|
|
178
|
+
const xml = dec.decode(entries[path]), relationships = relationshipsOf(entries, path);
|
|
179
|
+
const pictures = [...xml.matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)].map(match => match[0]);
|
|
180
|
+
for (const [slot, entry] of Object.entries(manifest.images)) {
|
|
181
|
+
if (entry.drawn === false) continue;
|
|
182
|
+
const name = names.get(`${path}|${slot}`);
|
|
183
|
+
const embed = name === undefined ? undefined : pictures.find(picture => picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1] === name)?.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
184
|
+
const target = embed === undefined ? undefined : relationships.get(embed)?.path;
|
|
185
|
+
if (target && entries[target]) entry.media = mediaKey(entries[target]);
|
|
186
|
+
else entry.drawn = false;
|
|
187
|
+
}
|
|
188
|
+
const part = `ppt/tags/opfFurnitureSlide${manifest.group}.xml`;
|
|
189
|
+
if (!entries[part]) throw Error('Missing furniture tag part.');
|
|
190
|
+
entries[part] = enc.encode(dec.decode(entries[part]).replace(/\bval="[^"]*"/, () => `val="${encodeTextTag(manifest)}"`));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
156
194
|
function readTags(container, relationships, entries) {
|
|
157
195
|
const tags = [];
|
|
158
196
|
let unreadable = false;
|
|
@@ -167,6 +205,29 @@ function readTags(container, relationships, entries) {
|
|
|
167
205
|
ambiguous: unreadable || tags.filter(tag => /^OPF_/i.test(tag.name) && tag.name.toUpperCase() !== SLIDE_TAG).length !== 1};
|
|
168
206
|
}
|
|
169
207
|
|
|
208
|
+
// RR-72: a part written once on the slide master or a layout is stored under `shared` (keyed by slot, with `on`, the definition's
|
|
209
|
+
// flag and its text template or logo reference), so importers that read only slide shapes ignore it. Put it back in place, as a
|
|
210
|
+
// part marked `on`, before the manifest is validated as one record.
|
|
211
|
+
function withSharedParts(manifest) {
|
|
212
|
+
if (manifest?.shared === undefined) return manifest;
|
|
213
|
+
check(object(manifest.shared) && object(manifest.definitions) && Array.isArray(manifest.parts), 'Invalid shared furniture.');
|
|
214
|
+
const result = {...manifest, definitions: structuredClone(manifest.definitions), parts: [...manifest.parts],
|
|
215
|
+
...(manifest.templates ? {templates: {...manifest.templates}} : {}), ...(manifest.images ? {images: {...manifest.images}} : {})};
|
|
216
|
+
delete result.shared;
|
|
217
|
+
for (const [slot, entry] of Object.entries(manifest.shared)) {
|
|
218
|
+
check(object(entry) && ['master', 'layout'].includes(entry.on) && slot === key(entry) && entry.ph === undefined, 'Invalid shared furniture part.');
|
|
219
|
+
const {on, flag, template, image, ...part} = entry;
|
|
220
|
+
const zone = result.definitions[entry.kind]?.value?.[entry.zone];
|
|
221
|
+
check(object(zone) && zone[entry.field] === undefined && flag !== undefined, 'Invalid shared furniture part.');
|
|
222
|
+
zone[entry.field] = flag;
|
|
223
|
+
const place = `${entry.kind}.${entry.zone}`;
|
|
224
|
+
if (template !== undefined) { check(entry.field === 'text' && result.templates?.[place] === undefined, 'Invalid shared furniture text.'); (result.templates ??= {})[place] = template; }
|
|
225
|
+
if (image !== undefined) { check(entry.field === 'image' && result.images?.[place] === undefined, 'Invalid shared furniture image.'); (result.images ??= {})[place] = image; }
|
|
226
|
+
result.parts.push({...part, on});
|
|
227
|
+
}
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
|
|
170
231
|
function validateManifest(manifest) {
|
|
171
232
|
check(manifest?.v === 1 && manifest.role === 'slide' && /^\d{1,10}$/.test(manifest.group), 'Invalid slide identity.');
|
|
172
233
|
check(object(manifest.definitions) && Object.keys(manifest.definitions).length > 0 && Object.keys(manifest.definitions).every(kind => kinds.includes(kind)), 'Invalid furniture definitions.');
|
|
@@ -187,14 +248,16 @@ function validateManifest(manifest) {
|
|
|
187
248
|
}
|
|
188
249
|
}
|
|
189
250
|
}
|
|
190
|
-
if (manifest.
|
|
191
|
-
check(
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
check(
|
|
196
|
-
|
|
197
|
-
|
|
251
|
+
if (manifest.images !== undefined) {
|
|
252
|
+
check(object(manifest.images), 'Invalid furniture images.');
|
|
253
|
+
for (const [slot, entry] of Object.entries(manifest.images)) {
|
|
254
|
+
const [kind, zone, extra] = slot.split('.');
|
|
255
|
+
const flag = manifest.definitions[kind]?.value?.[zone]?.image;
|
|
256
|
+
check(extra === undefined && object(manifest.definitions[kind]?.value?.[zone]) && object(entry) && Object.keys(entry).every(name => ['reference', 'media', 'drawn'].includes(name)) &&
|
|
257
|
+
isLogoReference(entry.reference) && (entry.media === undefined || (typeof entry.media === 'string' && /^\d{1,12}:[0-9a-f]{1,32}$/.test(entry.media))) &&
|
|
258
|
+
(entry.drawn === undefined || (entry.drawn === false && entry.media === undefined)) &&
|
|
259
|
+
// A reference with no part at all has no image flag; one with a part has the part's flag.
|
|
260
|
+
(flag === 'native' || (flag === undefined && entry.drawn === false)), 'Invalid furniture image.');
|
|
198
261
|
}
|
|
199
262
|
}
|
|
200
263
|
if (manifest.templates !== undefined) {
|
|
@@ -218,6 +281,8 @@ function validateManifest(manifest) {
|
|
|
218
281
|
check(object(part) && expected.get(key(part)) === part.type, 'Ambiguous furniture part.');
|
|
219
282
|
check(Number.isSafeInteger(part.count) && part.count >= 1 && part.count <= 10000 && (part.type !== 'image' || part.count === 1), 'Invalid furniture line count.');
|
|
220
283
|
// RR-11: a footer part that is a native PowerPoint placeholder (dt, ftr, sldNum), unique per slide.
|
|
284
|
+
// RR-72: a part drawn once on the slide master or a layout.
|
|
285
|
+
check(part.on === undefined || (['master', 'layout'].includes(part.on) && part.ph === undefined), 'Invalid shared furniture part.');
|
|
221
286
|
if (part.ph !== undefined) check(isNativePlaceholderType(part.ph) && part.kind === 'footer' && part.type === 'text' && part.count === 1 &&
|
|
222
287
|
NATIVE_PLACEHOLDERS[part.ph].field === part.field && manifest.parts.filter(other => other.ph === part.ph).length === 1, 'Invalid native placeholder part.');
|
|
223
288
|
if (part.field === 'socials') check(Array.isArray(part.socials) && part.socials.length >= 1 && part.socials.length <= 100
|
|
@@ -229,17 +294,6 @@ function validateManifest(manifest) {
|
|
|
229
294
|
if (manifest.parts.some(part => part.field === 'socials')) check(typeof manifest.organizationId === 'string' && /^[a-zA-Z0-9_-]+$/.test(manifest.organizationId), 'Invalid organization identity.');
|
|
230
295
|
}
|
|
231
296
|
|
|
232
|
-
// The logo tags of a picture: any other OPF tag next to one is not an identity conflict for the furniture reader.
|
|
233
|
-
function readLogoTags(container, relationships, entries) {
|
|
234
|
-
const found = [];
|
|
235
|
-
for (const link of array(container?.['p:tags'])) {
|
|
236
|
-
const rel = relationships.get(link['r:id']);
|
|
237
|
-
if (rel?.type !== REL || rel.targetMode === 'External' || !entries[rel.path]) continue;
|
|
238
|
-
found.push(...array(parser.parse(dec.decode(entries[rel.path]))['p:tagLst']?.['p:tag']).filter(tag => tag.name?.toUpperCase() === LOGO_TAG));
|
|
239
|
-
}
|
|
240
|
-
return found;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
297
|
// The native fields of the lines of one tagged text part, with offsets into the text sourceLineParagraphs joins from them.
|
|
244
298
|
function orderedFields(ordered, paragraphs) {
|
|
245
299
|
const fields = [];
|
|
@@ -270,8 +324,37 @@ function importedZoneText(text, nativeFields, template, position) {
|
|
|
270
324
|
return slideNumberTemplate(text, nativeFields);
|
|
271
325
|
}
|
|
272
326
|
|
|
273
|
-
|
|
274
|
-
|
|
327
|
+
// RR-72: the tagged furniture shapes of the slide's layout and master, by `on` and slot, and whether the slide shows them: a slide
|
|
328
|
+
// whose "Hide background graphics" is on (showMasterSp="0") shows neither; a layout with it hides the master's.
|
|
329
|
+
function inheritedRecords(context, entries) {
|
|
330
|
+
const inherited = context.inherited?.() ?? {};
|
|
331
|
+
const records = new Map(), damaged = {};
|
|
332
|
+
for (const on of ['layout', 'master']) {
|
|
333
|
+
const source = inherited[on];
|
|
334
|
+
if (!source) continue;
|
|
335
|
+
for (const [type, nodes, properties] of [['text', source.shapes, 'p:nvSpPr'], ['image', source.pictures, 'p:nvPicPr']]) {
|
|
336
|
+
for (const [index, node] of nodes.entries()) {
|
|
337
|
+
const container = node[properties]?.['p:nvPr']?.['p:custDataLst'];
|
|
338
|
+
const result = readTags(container, source.relationships, entries);
|
|
339
|
+
// A tag that cannot be read (a missing part, two identities) leaves the part's shape unknown: that is damage, not a removal.
|
|
340
|
+
if (array(container?.['p:tags']).length && result.tags.length !== 1 && result.ambiguous) damaged[on] = true;
|
|
341
|
+
for (const tag of result.tags) {
|
|
342
|
+
let data;
|
|
343
|
+
try { data = decodeTextTag(tag.val); } catch { damaged[on] = true; continue; }
|
|
344
|
+
if (result.ambiguous || data?.v !== 1 || typeof data.slot !== 'string' || data.role !== type) { damaged[on] = true; continue; }
|
|
345
|
+
const slot = `${on}|${data.slot}`;
|
|
346
|
+
records.set(slot, [...(records.get(slot) ?? []), {type, index, data}]);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const hiddenSlide = context.root?.showMasterSp === '0' || context.root?.showMasterSp === 'false';
|
|
352
|
+
const hiddenLayout = inherited.layout?.root?.showMasterSp === '0' || inherited.layout?.root?.showMasterSp === 'false';
|
|
353
|
+
return {sources: inherited, records, damaged, hidden: {layout: hiddenSlide, master: hiddenSlide || hiddenLayout}};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function readSlide(context, entries, slideIndex, slideCount, report, taggedText, section, draw, logoOwner) {
|
|
357
|
+
const {root, shapes, pictures, relationships} = context;
|
|
275
358
|
const candidates = {};
|
|
276
359
|
const records = [];
|
|
277
360
|
let invalidRecord = false;
|
|
@@ -285,26 +368,19 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText,
|
|
|
285
368
|
} catch { invalidRecord = true; }
|
|
286
369
|
}
|
|
287
370
|
}
|
|
288
|
-
// Generated logo pictures: identity only, the bytes come from design.logo.
|
|
289
|
-
const logoPictures = [];
|
|
290
|
-
for (const [index, node] of pictures.entries()) try {
|
|
291
|
-
for (const tag of readLogoTags(node['p:nvPicPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries)) {
|
|
292
|
-
const data = decodeTextTag(tag.val);
|
|
293
|
-
if (data?.role === 'furniture') logoPictures.push({index, data});
|
|
294
|
-
}
|
|
295
|
-
} catch { invalidRecord = true; }
|
|
296
371
|
const tags = readTags(root['p:cSld']?.['p:custDataLst'], relationships, entries);
|
|
297
|
-
if (!tags.tags.length && !records.length && !invalidRecord
|
|
372
|
+
if (!tags.tags.length && !records.length && !invalidRecord) return candidates;
|
|
298
373
|
let manifest;
|
|
299
374
|
try {
|
|
300
375
|
check(!tags.ambiguous && tags.tags.length === 1 && !invalidRecord, 'Missing or ambiguous furniture manifest/shape tags.');
|
|
301
|
-
manifest = decodeTextTag(tags.tags[0].val);
|
|
376
|
+
manifest = withSharedParts(decodeTextTag(tags.tags[0].val));
|
|
302
377
|
validateManifest(manifest);
|
|
303
378
|
for (const record of records) {
|
|
304
379
|
const data = record.data, part = manifest.parts[data?.part];
|
|
305
380
|
check(data?.v === 1 && data.group === manifest.group && Number.isSafeInteger(data.part) && part && data.role === record.type && part.type === record.type, 'Unmatched furniture identity.');
|
|
306
381
|
}
|
|
307
382
|
} catch (error) { report(slideIndex, error.message); return candidates; }
|
|
383
|
+
const inherited = manifest.parts.some(part => part.on) ? inheritedRecords(context, entries) : undefined;
|
|
308
384
|
for (const kind of kinds) {
|
|
309
385
|
const definition = manifest.definitions[kind];
|
|
310
386
|
if (!definition) continue;
|
|
@@ -315,24 +391,41 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText,
|
|
|
315
391
|
value[zone] = Object.fromEntries(Object.entries(definition.value[zone]).filter(([, flag]) => flag === false));
|
|
316
392
|
}
|
|
317
393
|
const candidate = {scope: definition.scope, value, text: [], pictures: [], socials: []};
|
|
318
|
-
// logo
|
|
319
|
-
for (const
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
if (found.length) { candidate.pictures.push(found[0].index); value[logo.zone].logo = true; }
|
|
323
|
-
else if (!logo.drawn) value[logo.zone].logo = true;
|
|
394
|
+
// A logo reference with no part (the organization had no logo to draw) returns as the reference.
|
|
395
|
+
for (const [slot, entry] of Object.entries(manifest.images ?? {})) {
|
|
396
|
+
const [slotKind, slotZone] = slot.split('.');
|
|
397
|
+
if (slotKind === kind && entry.drawn === false && !definition.value?.[slotZone]?.image && value !== false) value[slotZone].image = entry.reference;
|
|
324
398
|
}
|
|
325
399
|
for (const [partIndex, part] of manifest.parts.entries()) {
|
|
326
400
|
if (part.kind !== kind) continue;
|
|
327
|
-
|
|
401
|
+
// A part on the master or a layout is read there; the slide's own shapes otherwise.
|
|
402
|
+
const source = part.on ? inherited.sources[part.on] : context;
|
|
403
|
+
const group = part.on ? (inherited.hidden[part.on] || !source ? [] : inherited.records.get(`${part.on}|${key(part)}`) ?? []) : records.filter(record => record.data.part === partIndex);
|
|
404
|
+
const {paragraphs, relationships} = source ?? context;
|
|
405
|
+
const logo = part.type === 'image' ? manifest.images?.[`${kind}.${part.zone}`] : undefined;
|
|
406
|
+
// The picture of a logo reference could not be embedded at export: the reference is all there is.
|
|
407
|
+
if (logo?.drawn === false && group.length === 0) { value[part.zone].image = logo.reference; continue; }
|
|
328
408
|
// A native placeholder PowerPoint's Header & Footer dialog removed is a deliberate removal, not damage.
|
|
329
409
|
if (part.ph !== undefined && group.length === 0) { removedNative = true; continue; }
|
|
410
|
+
// So is a master or layout part the slide no longer shows (deleted there, another layout, or the slide's background graphics hidden).
|
|
411
|
+
if (part.on !== undefined && group.length === 0) {
|
|
412
|
+
check(inherited.hidden[part.on] || !inherited.damaged[part.on], 'Damaged furniture tags on the slide master or layout.');
|
|
413
|
+
removedNative = true;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
330
417
|
check(group.length === part.count, 'Incomplete or duplicated furniture part.');
|
|
331
418
|
if (part.type === 'image') {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
419
|
+
// An unchanged picture of an organization logo returns as its reference (the organization is stored with its logo, and the
|
|
420
|
+
// picture still embeds the bytes export drew); an edited, replaced or foreign one is an ordinary image.
|
|
421
|
+
const picture = source.pictures[group[0].index];
|
|
422
|
+
if (logo && logoOwner(logo.reference) && logo.media !== undefined && logo.media === pictureKey(picture, relationships, entries)) value[part.zone].image = logo.reference;
|
|
423
|
+
else {
|
|
424
|
+
const current = source.readPicture(picture);
|
|
425
|
+
check(current?.kind === 'image', 'Furniture image is no longer recoverable.');
|
|
426
|
+
value[part.zone].image = current.payload.image;
|
|
427
|
+
}
|
|
428
|
+
if (!part.on) candidate.pictures.push(group[0].index);
|
|
336
429
|
} else {
|
|
337
430
|
const ordered = Array(part.count);
|
|
338
431
|
for (const record of group) {
|
|
@@ -348,7 +441,20 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText,
|
|
|
348
441
|
candidate.socials.push({id: manifest.organizationId, socials: Object.fromEntries(part.socials.map((line, index) => [line.platform, line.scheme + lines[index]]))});
|
|
349
442
|
}
|
|
350
443
|
value[part.zone][part.field] = part.field === 'text' ? importedZoneText(text, orderedFields(ordered, paragraphs), manifest.templates?.[`${kind}.${part.zone}`], {index: slideIndex, count: slideCount, section, draw}) : part.field === 'date' ? importedDate(definition.value[part.zone].date, format, text, ordered.flatMap(record => (paragraphs[record.index] ?? []).flatMap(paragraph => paragraph.fields ?? [])), value[part.zone], part.staticDate, ordered, paragraphs, message => report(slideIndex, `${kind}.${part.zone}.date: ${message}`)) : true;
|
|
351
|
-
candidate.text.push(...ordered.map(record => record.index));
|
|
444
|
+
if (!part.on) candidate.text.push(...ordered.map(record => record.index));
|
|
445
|
+
}
|
|
446
|
+
} catch (error) {
|
|
447
|
+
// A part on the slide master or a layout is never slide content: when its record no longer reads, its current words (or
|
|
448
|
+
// picture) stay the part's value, as literal text, and the damage is reported.
|
|
449
|
+
if (!part.on) throw error;
|
|
450
|
+
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
|
+
if (part.type === 'image') {
|
|
452
|
+
const current = source.readPicture(source.pictures[group[0].index]);
|
|
453
|
+
if (current?.kind === 'image') value[part.zone].image = current.payload.image;
|
|
454
|
+
} else if (part.field !== 'socials') {
|
|
455
|
+
const lines = [...group].sort((a, b) => (Number.isSafeInteger(a.data.line) ? a.data.line : 0) - (Number.isSafeInteger(b.data.line) ? b.data.line : 0) || a.index - b.index);
|
|
456
|
+
value[part.zone][part.field] = lines.map(record => (paragraphs[record.index] ?? []).map(paragraph => paragraph.text).join('\n')).join('\n');
|
|
457
|
+
}
|
|
352
458
|
}
|
|
353
459
|
}
|
|
354
460
|
check(new Set(candidate.text).size === candidate.text.length && new Set(candidate.pictures).size === candidate.pictures.length, 'Repeated furniture shape identity.');
|
|
@@ -398,9 +504,13 @@ function importedDate(flag, format, text, nativeFields, zone, marker, ordered, p
|
|
|
398
504
|
// `options.organizations` the stored organizations (OPF_DOCUMENT_V1) a socials part can name by id, and `options.draw(template,
|
|
399
505
|
// {index, count, section})` draws a stored zone text for a slide (its built-in variables resolved from the document's metadata).
|
|
400
506
|
export function importFurniture(contexts, entries, onDiagnostic, {sections = [], organizations = [], draw = () => undefined} = {}) {
|
|
507
|
+
// A logo reference returns only while the stored organizations still give it a logo (RR-71): `organizations` carry `logo`.
|
|
508
|
+
const logoOwner = reference => {
|
|
509
|
+
try { return resolveLogo({organization: organizations.filter(item => item.logo !== undefined)}, {}, {reference}) !== null; } catch { return false; }
|
|
510
|
+
};
|
|
401
511
|
const report = (index, message) => onDiagnostic?.({code: 'invalid-furniture-provenance', path: `slides.${index}.design`, message: `${message} Ordinary import retains current native content; no old source words are restored.`});
|
|
402
512
|
const taggedText = contexts.map(() => new Set());
|
|
403
|
-
const candidates = contexts.map((context, index) => readSlide(context, entries, index, contexts.length, report, taggedText[index], sections[index], draw));
|
|
513
|
+
const candidates = contexts.map((context, index) => readSlide(context, entries, index, contexts.length, report, taggedText[index], sections[index], draw, logoOwner));
|
|
404
514
|
// Social profiles belong to the organization the manifest names by id, which only the stored document metadata can supply
|
|
405
515
|
// (no furniture part shows the organization's name any more); without it (or with disagreeing values) the lines stay
|
|
406
516
|
// ordinary current text.
|
|
@@ -13,12 +13,12 @@ export async function webpToPng(bytes) {
|
|
|
13
13
|
// not installed, and also when it is installed but its PNG converter (`@resvg/resvg-js`, an optional peer of opf-render
|
|
14
14
|
// 0.16) is not: opf-render's `converter-missing` error maps to the same code, with its install command on `install`.
|
|
15
15
|
//
|
|
16
|
-
// This is the one place the exporter calls the renderer. opf-render's
|
|
16
|
+
// This is the one place the exporter calls the renderer. opf-render's toPng(svg, {fonts, scale, background}) takes the
|
|
17
17
|
// fonts handle for the faces it may draw with: here only the bundled ones, never the system's.
|
|
18
18
|
export async function svgToPng(svg, {scale, text}) {
|
|
19
19
|
let render;
|
|
20
20
|
try {
|
|
21
|
-
({
|
|
21
|
+
({toPng: render} = await import('@openpresentation/opf-render'));
|
|
22
22
|
} catch (error) {
|
|
23
23
|
const unavailable = new Error('SVG pictures need the optional peer @openpresentation/opf-render (or an options.svgRasterizer).', {cause: error});
|
|
24
24
|
unavailable.code = 'svg-rasterizer-unavailable';
|
package/dist/index.d.ts
CHANGED
|
@@ -97,7 +97,7 @@ export interface ToPptxOptions {
|
|
|
97
97
|
/**
|
|
98
98
|
* OPF_DOCUMENT_V1 / OPF_SLIDE_V1 customer-data tags that let fromPptx restore
|
|
99
99
|
* catalog references, layout ids, authoring metadata (`filename` and `extensions`
|
|
100
|
-
* included), `design.logo
|
|
100
|
+
* included), `design.logo` (a logo reference or `false`), the organizations with their logos, the asset registry, slide `section`/`extensions` and
|
|
101
101
|
* the slide content structure: nested groups, promoted regions, the root payload
|
|
102
102
|
* form, block ids and extensions, group composition (docs/document-roundtrip.md).
|
|
103
103
|
* Tags are not shown in PowerPoint's UI. Default "full"; "references-only"
|
|
@@ -131,7 +131,7 @@ export interface ToPptxOptions {
|
|
|
131
131
|
/**
|
|
132
132
|
* Draws the PNG fallback of an SVG picture (the raster older viewers show; PowerPoint 2016 and Microsoft 365 draw the SVG itself). It receives the
|
|
133
133
|
* sanitized SVG text and the pixel size to draw (the SVG's own aspect, about 192 dpi of the displayed size) and returns PNG bytes with a transparent
|
|
134
|
-
* background. It must be deterministic for the export to be. Default in Node: opf-render's `
|
|
134
|
+
* background. It must be deterministic for the export to be. Default in Node: opf-render's `toPng` (resvg with the bundled fonts), loaded only when an
|
|
135
135
|
* SVG is exported (optional peer `@openpresentation/opf-render`). Without it, and without opf-render, an SVG picture exports as the "Image unavailable"
|
|
136
136
|
* placeholder with an `unresolved-asset` diagnostic (`reason: "svg-rasterizer-unavailable"`); a browser build has no default.
|
|
137
137
|
*/
|