@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 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 `svgToPng` (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.
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 (`design.logo`, `design.watermark`, image blocks with their treatments) round-trip as SVG.
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`; core 0.11.4 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").
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** (`design.logo`, a slide's own `design.logo`, else the primary organization's `logo`). 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 LogoSet picks its variant from the slide background (a dark background takes the light variants); 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 logo's path (`strictAssets` throws). Content slides never get one. The picture carries an `OPF_LOGO_V1` tag (slide, source path, variant, the exact picture properties): an unchanged export consumes the picture on import (it is not content; `design.logo` itself returns from the document tag), and an edited, ambiguous or damaged one stays an ordinary picture and reports `invalid-logo-provenance`. When nothing else restored a logo (an export with `provenance: false`, or with `references-only`, which stores no sources), the consumed picture's own image becomes `design.logo` (the slide's own for a slide-level logo), so the logo is never lost; it is a single image, not the LogoSet. A PPTX with no OPF tags imports the picture as an ordinary image block and invents no `design.logo`.
341
- - **Header and footer `logo: true`.** A generated image part in the part's box (the icon variant), exported like an `image` part with the furniture picture tag replaced by an `OPF_LOGO_V1` tag with role `furniture`. The slide's furniture manifest lists it under a separate `logos` key (`{kind, zone, drawn}`), never in `parts` or `definitions`, because every released importer validates those two strictly. It re-imports as `logo: true`, never as an `image` data URI; without a resolvable logo core reports `unresolved-content` at `<zone>.logo`, nothing is drawn and `logo: true` still returns (`drawn: false`).
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
- // Brand images have no native gate: they return from the stored value once
112
- // their media and asset references resolve. The P2 logo picture (`OPF_LOGO_V1`)
113
- // consumes the drawn logo; `design.logo` itself always returns from here.
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 {LOGO_TAG} from './logo-provenance.js';
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 and the authored text of a zone whose
76
- // `text` uses built-in variables (`templates`, FA-31; `authored` is the document as given to toPptx, before the deck-wide pass). Current native shapes supply all words, image bytes and
77
- // alt text, even after edits; a recorded format or template is kept only while the current text still matches it exactly.
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.flatMap((part, index) => part.field === 'logo' ? [] : [{kind: part.kind, zone: part.zone, field: part.field,
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
- // The manifest index of a layout part: logo parts are not in the manifest, so later parts shift down.
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.logos !== undefined) {
191
- check(Array.isArray(manifest.logos) && manifest.logos.length <= 6, 'Invalid furniture logos.');
192
- const seen = new Set();
193
- for (const logo of manifest.logos) {
194
- const slot = `${logo?.kind}.${logo?.zone}`;
195
- check(object(logo) && kinds.includes(logo.kind) && zones.includes(logo.zone) && typeof logo.drawn === 'boolean' && Object.keys(logo).length === 3 &&
196
- object(manifest.definitions[logo.kind]?.value?.[logo.zone]) && !seen.has(slot), 'Invalid furniture logo.');
197
- seen.add(slot);
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
- function readSlide(context, entries, slideIndex, slideCount, report, taggedText, section, draw) {
274
- const {root, shapes, paragraphs, pictures, relationships, readPicture} = context;
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 && !logoPictures.length) return candidates;
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: true returns as its flag; the generated picture is consumed, never content or an image field.
319
- for (const logo of (manifest.logos ?? []).filter(item => item.kind === kind)) {
320
- const found = logoPictures.filter(item => item.data.v === 1 && item.data.group === manifest.group && item.data.furniture === kind && item.data.zone === logo.zone);
321
- check(found.length <= 1, 'Duplicated furniture logo.');
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
- const group = records.filter(record => record.data.part === partIndex);
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
- const current = readPicture(pictures[group[0].index]);
333
- check(current?.kind === 'image', 'Furniture image is no longer recoverable.');
334
- value[part.zone].image = current.payload.image;
335
- candidate.pictures.push(group[0].index);
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 svgToPng(svg, {fonts, scale, background}) takes the
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
- ({svgToPng: render} = await import('@openpresentation/opf-render'));
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`, the asset registry, slide `section`/`extensions` and
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 `svgToPng` (resvg with the bundled fonts), loaded only when an
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
  */