@openpresentation/opf-pptx 0.14.0 → 0.15.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
@@ -1,8 +1,10 @@
1
1
  # OPF PPTX
2
2
 
3
- API names (RR-55, unreleased; requires core 0.14, and opf-render 0.14 for the optional peer): `toPptx(presentation, { fonts })` takes the fonts handle that opf-render's `loadFonts()` returns (`@openpresentation/opf-render/fonts-node`; core's `Fonts`, an object with at least `textMeasurement`) and no longer reads a top-level `textMeasurement`. `fromPptx(bytes, { signals: true })` resolves to `{ presentation, signals }` (it was `{ document, signals }`). `OPFPptxError` carries `findings` (the `error` findings of core's `format` check, or the variable errors) and `diagnostics` (the layout diagnostics of a `layout-overflow`), not `issues`. `inventoryPptxTypefaces` and `checkPptxTypefaces` are `inventoryTypefaces` and `checkTypefaces`, and `checkTypefaces` takes `{ families }` where it took `{ fonts }`. Each slide is composed with the options of core's `resolveSlideContext`, so the deck's resolved font families (slide design, deck design, theme, then the engine default) are the ones the layout is measured in; a reference that matches no theme, colour scheme or layout is reported as `unresolved-theme`, `unresolved-color-scheme` or `unresolved-layout` (as an unknown font scheme is as `unresolved-font-scheme`) and never refuses the export: the default `minimal` theme and `cool-horizon` colour scheme are used, and a slide with no layout, or an unknown one, is composed with no layout record (automatic composition, as in the preview; it used to compose against the `blank` layout, so a layout-less cover is now centred vertically). The package no longer falls back when core lacks a function: it requires core 0.14.
3
+ Catalogs (FA-23, OPF 0.15, unreleased; requires core 0.15): the package ships and reads no catalog data of its own. `toPptx(presentation, { catalogs })` and `fromPptx(bytes, { catalogs })` take core's registered catalogs (`Catalog[]`: `{ source, layouts?, themes?, colorSchemes?, fontSchemes?, ... }`, records keyed by id, never fetched), the same option opf-render and core's `resolveSlideContext` take. Every reference (slide `layout`, `design.theme`, `colorScheme`, `fontScheme`, a language object's font schemes) resolves through core: the document's embedded `catalogs.<group>` records first, then the registered catalog whose `source` the group names; the first registered catalog is the host default for bare ids, and `"default": false` closes that fallback. Register the gallery snapshot explicitly: `import { defaultCatalog } from "@openpresentation/opf/catalog"; toPptx(deck, { catalogs: [defaultCatalog] })`. A reference that resolves nowhere is reported once per path as core's `unresolved-reference` (kind, reference, path, group, source, fallback); the slide composes automatically and the theme and schemes draw with core's engine defaults (`ENGINE_DEFAULT_THEME`, `ENGINE_DEFAULT_COLOR_SCHEME`, `ENGINE_DEFAULT_FONT_SCHEME`; the theme part's colour scheme is then named `OpenPresentation`). `strictReferences: true` (default: the value of `strictAssets`) makes such a reference fail the export with `OPFPptxError` code `unresolved-reference`, its `path` and core's `diagnostics`. Removed with no alias: the per-source host record option, the `{kind: records[]}` form of `catalogs` (now refused with core's `OPFCatalogsOptionError`, code `invalid-catalogs`), catalog language ids, and catalog social-platform records (core formats socials from its `SOCIAL_PLATFORMS` vocabulary).
4
4
 
5
- Version 0.12.0 requires `@openpresentation/opf` ^0.12.0 (composed font sizes on PowerPoint's 0.01 pt grid, hanging wrap whitespace, promoted regions in reading order, right-to-left decks composed mirrored) and exports what that core composes, plus the export and import changes listed in the changelog; use it with opf-render 0.12.0 (optional peer ^0.12.0) and opf-editor 0.11.0 so preview and export resolve one core. Version 0.11.9 exports an SVG image as a native SVG picture over a PNG fallback (rasterized in Node by the optional `@openpresentation/opf-render` peer or `options.svgRasterizer`; without one it stays the placeholder), writes a deck footer's first text, date and slide number as native PowerPoint Header & Footer placeholders (output change for decks with a footer; every export now carries the footer placeholders on its master, layout and notes master; see [native header and footer](docs/native-header-footer.md)) and adds the opt-in `fromPptx(bytes, {signals: true})`, which also returns the raw per-shape signals of every slide (see Import signals; the option leaves the default import unchanged; core floor `^0.11.4` unchanged, no API removed). Version 0.11.8 re-imports rich text that wraps over several native lines as the one authored payload instead of reporting `content-structure-changed` (the export records the line count; decks exported by earlier versions import as before; core floor `^0.11.4` unchanged, no API change). Version 0.11.7 requires `@openpresentation/opf` ^0.11.4 and exports the design fields natively (cover and section logos, header and footer logos, picture bullets, the accent font), writes every chart's text at the size the preview draws, round-trips the slide content structure, sections, extensions, assets and brand images through PPTX provenance (a fresh export of a plain deck re-imports in its authored form, so read a root payload field before `blocks[0]`), writes slide sections as PowerPoint's section list, and degrades an SVG image to the preview's placeholder instead of aborting; install it with renderer 0.11.9 and editor 0.10.6 so export and preview resolve the same core (no API removed). Version 0.11.6 exports the treemap, histogram, pareto, box-and-whisker, waterfall and funnel charts as native chartex parts by default (`toPptx({chartex: 'auto'})`; `world` stays a clustered column with `chart-data-adapted`; pass `chartex: 'fallback'` for the previous output) and gives chartex text the deck's label colour and font (no API removed). Version 0.11.5 writes the slide tag run as a theme color reference (`a:schemeClr accent1`) where the deck theme holds the primary there (no API change; the color is unchanged). Version 0.11.4 re-imports quote payloads (each native quote line carries an `OPF_QUOTE_V1` tag) and slide-image payloads from an unchanged export, writes the theme's `a:ea` and `a:cs` only for slots a script font is selected for, and requires core `^0.11.3` (the 70 legacy gallery layout ids and their geometry; install it with renderer 0.11.6 and editor 0.10.4 so export and preview resolve the same core; no API removed). Version 0.11.3 adds the opt-in `toPptx({ chartex: 'native' })` export of the seven chartex chart types and always imports chartex charts (no API removed; the default output is unchanged). Version 0.11.2 exports the classic chart types as native chart constructs and writes named and default table colours, text pairs and hyperlink colours as theme references (no API or dependency change; resolved colours are unchanged). Version 0.11.1 exports `design.watermark` as a native picture (it re-imports) and embeds byte-identical media parts once, with no API or dependency change. Version 0.11.0 requires core `^0.11.2` (cover slides are centered between header and footer furniture) and the optional `@openpresentation/opf-render` peer `^0.11.0`; install them together so export and preview resolve the same core. Version 0.10.0 required core `^0.11.1` and the peer `^0.10.0`. It adds native slide-number and date fields, socials, slide-image treatments, theme color schemes, script-slot fonts and RTL, re-import of design and metadata references, native underline and current-body formatting on import, and UTC-canonical `zipDate` (see the changelog for the intentional contract changes).
5
+ API names (RR-55, unreleased; requires core 0.14, and opf-render 0.14 for the optional peer): `toPptx(presentation, { fonts })` takes the fonts handle that opf-render's `loadFonts()` returns (`@openpresentation/opf-render/fonts-node`; core's `Fonts`, an object with at least `textMeasurement`) and no longer reads a top-level `textMeasurement`. `fromPptx(bytes, { signals: true })` resolves to `{ presentation, signals }` (it was `{ document, signals }`). `OPFPptxError` carries `findings` (the `error` findings of core's `format` check, or the variable errors) and `diagnostics` (the layout diagnostics of a `layout-overflow`), not `issues`. `inventoryPptxTypefaces` and `checkPptxTypefaces` are `inventoryTypefaces` and `checkTypefaces`, and `checkTypefaces` takes `{ families }` where it took `{ fonts }`. Each slide is composed with the options of core's `resolveSlideContext`, so the deck's resolved font families (slide design, deck design, theme, then the engine default) are the ones the layout is measured in; a reference that matches no record was reported per kind and never refused the export (0.15 replaces this with one `unresolved-reference` diagnostic and core's engine defaults; see Catalogs above): the default `minimal` theme and `cool-horizon` colour scheme were used, and a slide with no layout, or an unknown one, is composed with no layout record (automatic composition, as in the preview; it used to compose against the `blank` layout, so a layout-less cover is now centred vertically). The package no longer falls back when core lacks a function: it requires core 0.14.
6
+
7
+ Version 0.12.0 requires `@openpresentation/opf` ^0.12.0 (composed font sizes on PowerPoint's 0.01 pt grid, hanging wrap whitespace, promoted regions in reading order, right-to-left decks composed mirrored) and exports what that core composes, plus the export and import changes listed in the changelog; use it with opf-render 0.12.0 (optional peer ^0.12.0) and opf-editor 0.11.0 so preview and export resolve one core. Version 0.11.9 exports an SVG image as a native SVG picture over a PNG fallback (rasterized in Node by the optional `@openpresentation/opf-render` peer or `options.svgRasterizer`; without one it stays the placeholder), writes a deck footer's first text, date and slide number as native PowerPoint Header & Footer placeholders (output change for decks with a footer; every export now carries the footer placeholders on its master, layout and notes master; see [native header and footer](docs/native-header-footer.md)) and adds the opt-in `fromPptx(bytes, {signals: true})`, which also returns the raw per-shape signals of every slide (see Import signals; the option leaves the default import unchanged; core floor `^0.11.4` unchanged, no API removed). Version 0.11.8 re-imports rich text that wraps over several native lines as the one authored payload instead of reporting `content-structure-changed` (the export records the line count; decks exported by earlier versions import as before; core floor `^0.11.4` unchanged, no API change). Version 0.11.7 requires `@openpresentation/opf` ^0.11.4 and exports the design fields natively (cover and section logos, header and footer logos, picture bullets, the accent font), writes every chart's text at the size the preview draws, round-trips the slide content structure, sections, extensions, assets and brand images through PPTX provenance (a fresh export of a plain deck re-imports in its authored form, so read a root payload field before `blocks[0]`), writes slide sections as PowerPoint's section list, and degrades an SVG image to the preview's placeholder instead of aborting; install it with renderer 0.11.9 and editor 0.10.6 so export and preview resolve the same core (no API removed). Version 0.11.6 exports the treemap, histogram, pareto, box-and-whisker, waterfall and funnel charts as native chartex parts by default (`toPptx({chartex: 'auto'})`; `world` stays a clustered column with `chart-data-adapted`; pass `chartex: 'fallback'` for the previous output) and gives chartex text the deck's label colour and font (no API removed). Version 0.11.5 writes the slide tag run as a theme color reference (`a:schemeClr accent1`) where the deck theme holds the primary there (no API change; the color is unchanged). Version 0.11.4 re-imports quote payloads (each native quote line carries an `OPF_QUOTE_V1` tag) and slide-level image payloads from an unchanged export, writes the theme's `a:ea` and `a:cs` only for slots a script font is selected for, and requires core `^0.11.3` (the 70 legacy gallery layout ids and their geometry; install it with renderer 0.11.6 and editor 0.10.4 so export and preview resolve the same core; no API removed). Version 0.11.3 adds the opt-in `toPptx({ chartex: 'native' })` export of the seven chartex chart types and always imports chartex charts (no API removed; the default output is unchanged). Version 0.11.2 exports the classic chart types as native chart constructs and writes named and default table colours, text pairs and hyperlink colours as theme references (no API or dependency change; resolved colours are unchanged). Version 0.11.1 exports `design.watermark` as a native picture (it re-imports) and embeds byte-identical media parts once, with no API or dependency change. Version 0.11.0 requires core `^0.11.2` (cover slides are centered between header and footer furniture) and the optional `@openpresentation/opf-render` peer `^0.11.0`; install them together so export and preview resolve the same core. Version 0.10.0 required core `^0.11.1` and the peer `^0.10.0`. It adds native slide-number and date fields, socials, slide-level image treatments, theme color schemes, script-slot fonts and RTL, re-import of design and metadata references, native underline and current-body formatting on import, and UTC-canonical `zipDate` (see the changelog for the intentional contract changes).
6
8
 
7
9
  Chart options (RR-35, unreleased): `axisTitles`, `legend` and `dataLabels` on a chart export as native `c:title`, `c:legendPos` and `c:dLbls` (and `cx:title`, `cx:legend`, `cx:dataLabels` on the Office 2016 constructs) and import back; the preview draws the same options. See core `docs/chart-options.md` for the per-type support table and the `chart-option-adapted` diagnostic.
8
10
 
@@ -65,13 +67,13 @@ await fs.promises.writeFile("quarterly-review.pptx", bytes);
65
67
 
66
68
  Embedded picture bytes are a PNG, JPEG, GIF, WebP or SVG. Bytes that are no readable image (or an SVG that cannot be exported: malformed XML, no `xmlns`, no width and height and no viewBox, or no rasterizer, see below) export what the preview shows, like an unresolved asset: the "Image unavailable" placeholder (nothing for a watermark, the background colour for a background image) and one `unresolved-asset` diagnostic with a `reason` (`unsupported-format`, `svg-malformed`, `svg-no-size`, `svg-too-large`, `svg-unsafe`, `svg-unreadable`, `svg-rasterizer-unavailable`, `svg-render-failed`); `strictAssets` throws `unsupported-image-dimensions` for an unreadable raster, `invalid-svg-image` for an unusable SVG, `svg-rasterizer-unavailable` or `svg-render-failed`.
67
69
 
68
- 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 content images, the slide image (`design.slideImage`), the watermark, header and footer images and logos, and cover and section logos. The picture takes the same frame and fit (contain, `design.imageFill: "crop"`) 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 (a background picture fill carries no SVG), and a slide image 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.
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.
69
71
 
70
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"`. 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.
71
73
 
72
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.
73
75
 
74
- `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 with SVG sources (`design.logo`, `design.watermark`, `design.slideImage`) 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 and blocks with SVG sources (`design.logo`, `design.watermark`, image blocks with their treatments) round-trip as SVG.
75
77
 
76
78
  `fromPptx` parses an existing `.pptx` buffer locally and returns an OPF document that validates with `@openpresentation/opf`:
77
79
 
@@ -138,7 +140,7 @@ This does not reconstruct original source run identities or boundaries between n
138
140
  The first importer is mechanical and schema-compatible:
139
141
 
140
142
  - Presentation core properties map to OPF `name`, `description`, and `author`.
141
- - The first slide master's theme `clrScheme` maps to `design.colorScheme`: a catalog id on an exact twelve-slot match, otherwise inline slots. A theme named like a catalog theme maps to `design.theme` when its colors or heading/body fonts corroborate it (see [Theme color scheme](#theme-color-scheme)).
143
+ - The first slide master's theme `clrScheme` maps to `design.colorScheme`: the bare id of a colour scheme in the host default catalog (the first of `fromPptx`'s `catalogs`) on an exact twelve-slot match, otherwise inline slots. A theme named like a theme of that catalog maps to `design.theme` when its colors or heading/body fonts corroborate it. With no catalog registered the colour scheme is inline and no theme id is recovered (see [Theme color scheme](#theme-color-scheme)).
142
144
  - Rich headline text (FA-10): a `TextRun[]` `title`, `subtitle` or `tag` exports as one tagged native text box per laid-out line with its runs (color, weight, italic, links, super/subscript, citation markers), and a `TextRun[]` `quote.text` the same way under the quote tags. Import returns `TextRun[]` where a heading's native runs are formatted differently from one another and a plain string where they are uniform: a tagged export records the heading's own weight, color, size and family (`base`), so only authored run formatting comes back, and a native title placeholder is read like body text, dropping what every run shares. A weight that the export writes as bold on every run of a title (and on a quote body) is the default, so only a `bold: false` word in a title is kept; `bold: true` in a quote body is indistinguishable from the body weight and is not restored. Marker runs become `cite`/`footnote` again, and heading and quote runs take part in the run-color reference slots.
143
145
  - Native title/subtitle placeholders retain their roles. On slides without complete OPF heading tags, recognizable text-box positions and sizes provide a fallback. If any complete OPF heading role is recovered, untagged body text stays in `blocks[]` instead of being promoted into an absent heading role. Damaged tags retain visible text through ordinary import and diagnostics.
144
146
  - Remaining text boxes map to `blocks[]` as text or list payloads, sorted by OOXML position.
@@ -146,12 +148,12 @@ The first importer is mechanical and schema-compatible:
146
148
  - Chart cache points are placed by their native `c:pt@idx`, with `c:ptCount` retaining trailing gaps. Missing labels and values become `null`; explicitly empty labels and series names stay empty strings, and an actual numeric zero stays zero. Empty numeric cache values remain missing. Malformed or duplicate indices, contradictory counts, competing caches and allocation limits reject with `invalid-chart-cache` and the chart-part/cache path; hierarchical category caches reject with `unsupported-chart-cache` instead of flattening labels. Each cache is limited to 100,000 positions, and combined caches and the emitted table each to 1,000,000 cells. Complete decimal scientific notation in numeric caches is read as a number; exponent overflow or nonzero underflow to zero rejects with `unsupported-chart-cache` and the chart part, series, cache and logical point path. Zero coefficients remain zero. This finite-number boundary does not classify rejected text as invalid OOXML. Malformed exponent strings and other nonblank numeric text retain the existing legacy conversion policy. This cache-only import does not repair embedded worksheets, restore unsupported scatter X/point labels, change exporter/workbook parsing or export/re-export missing-data handling, or establish native chart fidelity.
147
149
  - Table imports retain empty rows. A native `firstRow` flag of `1` or `true` maps the first row to column labels; absent/false flags retain every row as data. New exports set this flag from OPF columns. Older exports without the flag retain their labels as the first data row rather than inferring headers.
148
150
  - Native table text preserves run/field/break order, significant whitespace, and blank paragraphs. Cells return canonical `{value, style}` objects; `value` retains scalar text or supported rich runs. Covered merge positions are `null`. Explicit normal headers override OPF’s bold header default. Numeric/boolean/null source types cannot be reconstructed from native display text.
149
- - Run `lang` maps back to the presentation `language` (a catalog id when it round-trips, else the tag); see [Languages, right-to-left text and script fonts](#languages-right-to-left-text-and-script-fonts).
151
+ - Run `lang` maps back to the presentation `language` as a BCP-47 tag (the run's own tag, for example `ja-JP`); see [Languages, right-to-left text and script fonts](#languages-right-to-left-text-and-script-fonts).
150
152
  - Imported runs retain bold, italic, underline, strike, point sizes, Latin font families, solid colors/alpha, external hyperlink URLs, and superscript/subscript direction. List-level and paragraph defaults apply before run overrides; supported theme fonts/colors resolve from the archive. Field values become their cached text, and underline/strike variants and baseline magnitudes reduce to OPF booleans.
151
153
  - Citations, footnotes and captions (core RR-34 fields): a marker exports as a native superscript run (`baseline="30000"`) after its run; the slide's footnote area is a line shape plus one tagged text box per listed line (`OPF_FOOTNOTES_V1`), a caption one tagged text box per line (`OPF_CAPTION_V1`) naming its media shape, both at core's geometry; `references` travel in the document record. Import re-attaches tagged captions to their media block, rebuilds `references`, `cite` and `footnote` from the footnote tags and the marker runs (removing the marker runs; an edited note keeps its edited text), and guesses nothing without tags: a superscript number stays a superscript run and a text box stays a text block.
152
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.
153
155
  - Unknown non-text shapes and unsupported graphic frames become editable text fallback blocks instead of failing the import.
154
- - 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. Slide layout intent (layout id, type, composition, composition hints and the inline layout record) 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). See [document round trips](docs/document-roundtrip.md) for exactly what is embedded.
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. 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.
155
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.
156
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 and over the section text a footer shows (`section-reference-changed` at `slides.N.section`), except that an edited footer line keeps its text while the list still equals the stored value. The native PowerPoint check of the sections pane and save/reopen is a separate gate (`scratchpad/spec-gaps-native/`).
157
159
 
@@ -182,7 +184,7 @@ Without `signals` (or with `false`) the return value and the document are byte f
182
184
  - `text`: paragraphs with `level`, `align`, the effective `bullet` (inherited from the list style when the paragraph states none), indents and spacing, and runs with `font`, `size`, `bold`, `italic`, `weight`, `underline`, `strike`, `baseline`, `caps`, `color` (`#RRGGBB`) with its theme `colorRef`, `monospace`, `link`, `field` and `lineBreak`. Values are the effective ones PowerPoint draws: the run, its paragraph, the shape's list style, the layout and master placeholder, the master text styles, the presentation defaults and the theme (font tokens such as `+mj-lt` and scheme colours are resolved). A value no part of that chain states is omitted, and defaults are omitted (`bold`, `italic`, `monospace` absent means false, `weight` absent 400, `level` absent 0, `align` absent left). `monospace` is true for a known fixed-width family (Consolas, Courier New, Menlo, JetBrains Mono, ...); `text.monospaceShare`, `dominantFont`, `dominantSize`, `maxFontSize`, `anchor` and `autofit` summarise the shape.
183
185
  - `fill`, `outline` (kind, colour, width, dash, arrowheads) and `geometry` (`rect`, `roundRect`, `ellipse`, ...). Fills and lines that only name a theme style are reported as `kind: "style"` with the style index and colour.
184
186
  - `picture` (part, media type, bytes, natural size, crop), `table` (rows, columns, widths, header flags, cell text), `chart` (chart types, series and point counts, title).
185
- - `opf`: where the importer put the shape in the document it returned. `{role: "block", path: "slides.2.blocks.1", blockType: "text"}`, `{role: "title", path: "slides.0.title"}`, or `null` when the importer made nothing of it (a connector, a table inside a group). For an OPF export the path follows the rebuilt structure (`slides.0.blocks.0.blocks.1`, `slides.1.left`, `slides.2.text`). A shape the importer consumed without a block of its own has a `role` only (`furniture`, `logo`, `watermark`, `slide-image`, or the member of a tagged group). A group links where all its members do. Several shapes can link to one block (the lines of a list, the parts of a quote).
187
+ - `opf`: where the importer put the shape in the document it returned. `{role: "block", path: "slides.2.blocks.1", blockType: "text"}`, `{role: "title", path: "slides.0.title"}`, or `null` when the importer made nothing of it (a connector, a table inside a group). For an OPF export the path follows the rebuilt structure (`slides.0.blocks.0.blocks.1`, `slides.1.left`, `slides.2.text`). A shape the importer consumed without a block of its own has a `role` only (`furniture`, `logo`, `watermark`, `background`, `image-overlay`, or the member of a tagged group). A group links where all its members do. Several shapes can link to one block (the lines of a list, the parts of a quote).
186
188
 
187
189
  **Bounds.** The output is bounded by `signals` limits, reported in `signals.limits` (defaults, with the largest value each accepts): `maxSlides` 300 (5000), `maxShapesPerSlide` 300 (5000), `maxTotalShapes` 5000 (100000), `maxParagraphsPerShape` 200 (5000), `maxTextCharsPerShape` 8000 (200000), `maxTextCharsTotal` 400000 (5000000), `maxTableCells` 400 (10000), `maxTableCellChars` 200 (5000), `maxGroupDepth` 12 (32). What a bound drops is counted: `signals.truncated` (slides not reported, text budget exhausted), `slide.truncated` (shapes, depth, paragraphs, text, table) and `paragraph.truncated`. `text.chars` and `text.paragraphCount` are the true counts. Images are never embedded, only described, so the size follows the text: about 0.7 KB of JSON per shape.
188
190
 
@@ -214,7 +216,7 @@ The chart area, label colour, label font (`latin`/`ea`/`cs` in the chart's body
214
216
 
215
217
  The exporter reads the presentation `language` through core `resolveScriptFonts()` (FF-07; the model is core `docs/programs/font-fidelity-everywhere/script-font-model.md`):
216
218
 
217
- - Every run, end-of-paragraph and default run property carries `lang` set to the resolved OOXML tag instead of a fixed `en-US`. That tag is the catalog's curated `ooxmlLang` (for example `ja-JP` or `ar-SA`) or an authored region tag such as `en-NZ`. `altLang` is not written: it names the editing-UI language, which OPF does not model.
219
+ - Every run, end-of-paragraph and default run property carries `lang` set to the resolved OOXML tag instead of a fixed `en-US`. That tag is core's curated `ooxmlLang` for the language (for example `ja-JP` for `ja` or `ar-SA` for `ar`) or an authored region tag such as `en-NZ`. `altLang` is not written: it names the editing-UI language, which OPF does not model.
218
220
  - Theme major/minor `a:ea`/`a:cs` are written only for a slot where a script font is actually selected (FF-49): an explicit `eastAsian`/`complexScript` on the design font scheme, the scheme's own script family, or the language's script font (a language that uses the East Asian slot, such as Japanese, writes `ea`; one that uses the complex-script slot, such as Arabic, Hebrew, Indic or Thai, writes `cs`). Every other slot keeps the vendored empty typeface, exactly as Office's own themes leave it, so PowerPoint picks its per-language default for script text typed later and the package names nothing the author did not select (owner font policy). Latin, Cyrillic, Greek, Armenian, Georgian and Ethiopic decks write neither; the other slot of a CJK or complex-script deck is empty too. The language never changes the theme's latin fonts (Model C): `design.fontScheme` alone sets them. The preview resolves the same slots (opf-render's script profile: the theme's family where it names one, the latin family where it is empty), and `test/theme-script-slots.mjs` checks preview against export for every catalog font scheme.
219
221
  - The theme's per-script entry for the language's own script (for example `Jpan`, `Hang`, `Arab` or `Deva`) names the resolver's supplement. The rest of the vendored Office per-script list is unchanged; that list is FF-08's call.
220
222
  - Slide and notes runs name no `a:ea`/`a:cs` (FF-05: PowerPoint lists an explicit run East Asian or complex-script typeface as an empty-name font in `Presentation.Fonts`); their script text takes the theme slots through `+mn-ea`/`+mn-cs`. The theme `ea` always names a font (the selected script font, an East Asian font for East Asian text in the deck, else the latin family), so no paragraph end mark reads an empty font. Chart text keeps its own latin/ea/cs in the chart's body font.
@@ -224,15 +226,13 @@ The exporter reads the presentation `language` through core `resolveScriptFonts(
224
226
 
225
227
  Charts keep their `c:lang` and left-to-right label paragraphs. The embedded chart workbook is untouched.
226
228
 
227
- When the package carries an FF-32 stored `language` ([document round trip](docs/document-roundtrip.md)), that reference is restored while the runs still carry its OOXML tag (or none); if the runs now use another tag, the observed language below is kept and `metadata-reference-changed` is reported once. Otherwise `fromPptx` sets `language` from the most common run `lang`. It uses a catalog id when that record exports the same tag (`ja-JP` imports as `japanese`, `en-US` as `english-us`). Otherwise it keeps the tag itself (`en-NZ`), which still resolves to its catalog record. `english` (`en`) exports `en-US`, so it imports as `english-us`. Diagnostics:
229
+ When the package carries an FF-32 stored `language` ([document round trip](docs/document-roundtrip.md)), that reference is restored while the runs still carry its OOXML tag (or none); if the runs now use another tag, the observed language below is kept and `metadata-reference-changed` is reported once. Otherwise `fromPptx` sets `language` to the most common run `lang`, as that BCP-47 tag (OPF 0.15: language is an engine vocabulary, not a catalog): a deck authored as `ja` exports `ja-JP` runs and, without provenance, imports as `ja-JP`; `en-NZ` imports as `en-NZ`. Diagnostics:
228
230
 
229
231
  - `mixed-run-languages`: runs use several tags; the most common one is imported.
230
- - `language-ambiguous`: records share one curated tag and none has it as its own tag (`bn-BD` is Bengali and Chittagonian, `fil-PH` Filipino and Tagalog); the record of the same primary language is imported.
231
- - `language-uncatalogued`: no catalog record matches; the tag is imported.
232
232
  - `rtl-language-mismatch`: right-to-left paragraphs under a left-to-right language.
233
233
  - `script-font-not-imported`: theme `ea`/`cs` name a font that neither repeats latin nor matches what the imported document resolves for the first slide (path `design.fontScheme`), or a slide on another slide master has a master theme whose `ea`/`cs` the imported slide does not resolve to (path `slides.N.design.fontScheme`; with FF-32 provenance the slide's font scheme is restored and nothing is reported). Without provenance, imported OPF does not carry the script slots.
234
234
 
235
- Exports report `language-unresolved` when the document's language cannot be resolved locally (a URL, `pkg:` reference or unknown id) and `en-US` is used.
235
+ Exports report `language-unresolved` when core cannot resolve the document's language tag (a tag whose script it does not know) and `en-US` is used.
236
236
 
237
237
  ## Small conveniences (FA-13)
238
238
 
@@ -315,20 +315,22 @@ A local macOS Quick Look check opened both Roboto and system-Arial specimens. Qu
315
315
 
316
316
  ## Image geometry
317
317
 
318
- Native image exports now follow the browser's `design.imageFill`: `fit` (the default) centers an image without changing its aspect ratio, and `crop` fills the allocated box with a centered native crop. Slide settings override presentation settings. Geometry is calculated from the exact bytes embedded after asset resolution, so host resolvers are called once. PNG, JPEG, GIF and WebP dimension headers are supported; unsupported or unreadable dimensions produce a path-specific error rather than a distorted picture. Supply supported raster bytes through `imageResolver` for other formats.
318
+ Every content picture is an image block (OPF 0.15; `Slide.image` is shorthand for one). Core composes it as `item.image` (`@openpresentation/opf/composition`): the picture frame, the fit (the block's `fit`, else the effective `design.imageFit`, else `cover`), the focus point and the treatment. The export crops, pads and focuses the embedded bytes with core's `fitImage`, so the preview and the export show the same part of the picture: `cover` fills the frame with a native crop (`a:srcRect`) that keeps the `focus` point in view, `contain` shows the whole picture centered, and `stretch` fills the frame exactly. A plain contained picture in the content flow is framed by the picture itself; a placed or treated one keeps core's frame and pads with negative `a:srcRect` insets, so its mask, line and overlay apply to that frame. Slide settings override presentation settings. Geometry is calculated from the exact bytes embedded after asset resolution, so host resolvers are called once. PNG, JPEG, GIF and WebP dimension headers are supported; unsupported or unreadable dimensions produce a path-specific error rather than a distorted picture. Supply supported raster bytes through `imageResolver` for other formats.
319
319
 
320
320
  JPEG EXIF orientations 1–8 are represented by native picture rotation and mirroring. The embedded copy's orientation tag is normalized to 1 to avoid viewer-dependent double rotation. Compressed pixels and other metadata remain unchanged; input data is not mutated. EXIF orientation in other containers, animated playback, SVG/vector assets, effects and lossless crop/orientation import are not covered by this change.
321
321
 
322
- A slide-level image (`design.slideImage`, composed by core as `geometry.slideImage`) exports as one native picture named `OPF slide image slides.N`. It sits beneath the slide's other shapes. Its frame is the shared composition frame for both fills: `crop` writes positive `a:srcRect` insets and `fit` writes negative insets that pad the centered image. The frame therefore matches the preview's `<image>` box exactly. An `OPF_SLIDE_IMAGE_V1` shape tag records the placement and the native picture geometry. On import, an unchanged tagged picture becomes the slide's `design.slideImage` again, with the embedded bytes as its data URI source and `imageFill: "fit"` when the frame was fitted. An edited, duplicated or ambiguous tagged picture is imported as an ordinary image block and reports `invalid-slide-image-provenance` at `slides.N.design.slideImage`. Native PowerPoint raster parity for negative `a:srcRect` insets has not been checked with Office yet.
322
+ An image block with `placement` (`{edge, size, inset}`) bleeds to that edge: its picture is drawn at the band core composes, in core's item order (after the headings, before the flowed blocks, above the watermark, as the preview draws it), and the headings and the other blocks compose in the rest of the slide. A layout's image placeholder may carry the placement for the slide's image.
323
323
 
324
- Slide-image treatments export from core's normalized geometry as native DrawingML:
324
+ Image block treatments export from core's normalized geometry as native DrawingML, the mapping 0.14's slide image used:
325
325
 
326
326
  - `shape` becomes the picture's `a:prstGeom` (`rect`, `roundRect`, `ellipse` or `hexagon`) with core's guide values.
327
327
  - `border` becomes a centered solid `a:ln` with a miter join.
328
328
  - `recolor` becomes `a:grayscl` or `a:duotone`, followed by `a:alphaModFix` for `opacity`, on the blip only.
329
- - `overlay` becomes one tagged `OPF slide image overlay slides.N` shape directly above the picture.
329
+ - `overlay` becomes one tagged `OPF image N overlay` shape directly above the picture, in the frame's shape or as an edge band.
330
+
331
+ An image block with any of its own fields (`fit`, `focus`, a treatment, `placement`) carries an `OPF_IMAGE_V1` shape tag with those fields and the exact native picture geometry; its overlay carries `OPF_IMAGE_OVERLAY_V1`. On import an unchanged tagged picture becomes the same image block again, with the embedded bytes as its data URI source. An edited, duplicated or ambiguous tagged picture is imported as an ordinary image block and reports `invalid-image-provenance`; an edited overlay drops only the overlay. Native PowerPoint raster parity for negative `a:srcRect` insets has not been checked with Office yet.
330
332
 
331
- A deck or slide watermark (`design.watermark`) exports as one native picture named `OPF watermark` per slide, after the slide image and its overlay, if any, and before all content (the preview's paint order, so a background slide image lies beneath the watermark), at the preview's frame: the centered 40% by 40% box at 30%/30% of the slide, fitted without cropping. Its opacity is `a:alphaModFix` (the object form's `opacity`, otherwise the preview default 0.08), and its alt text is the asset's `alt`, otherwise `Watermark`. `design.watermark = false` on a slide exports none. An unchanged export imports back as `design.watermark` (at deck level when every slide carries the same one); an edited picture stays ordinary content and reports `invalid-watermark-provenance`. An unresolved image reports `unresolved-asset`; `src` is required by the schema, so a watermark with no image source is a validation error and not an export diagnostic.
333
+ A deck or slide watermark (`design.watermark`) exports as one native picture named `OPF watermark` per slide, after the image background and its overlay, if any, and before all content (placed image blocks included) (the preview's paint order, so a background picture lies beneath the watermark), at the preview's frame: the centered 40% by 40% box at 30%/30% of the slide, fitted without cropping. Its opacity is `a:alphaModFix` (the object form's `opacity`, otherwise the preview default 0.08), and its alt text is the asset's `alt`, otherwise `Watermark`. `design.watermark = false` on a slide exports none. An unchanged export imports back as `design.watermark` (at deck level when every slide carries the same one); an edited picture stays ordinary content and reports `invalid-watermark-provenance`. An unresolved image reports `unresolved-asset`; `src` is required by the schema, so a watermark with no image source is a validation error and not an export diagnostic.
332
334
 
333
335
  ### Brand assets, picture bullets and the accent font (spec-gap closure A)
334
336
 
@@ -339,7 +341,7 @@ These design fields need the core that composes them (`resolveLogo`, `geometry.l
339
341
  - **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.
340
342
  - **`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.
341
343
 
342
- An unchanged export imports every treatment field back. An edited overlay drops only the overlay and reports `invalid-slide-image-provenance`. An edited picture or effect drops the slide image. See core `docs/image-treatments.md` for the vocabulary and the unsupported effects: blur, shadows, soft edges and background removal. PowerPoint's luminance weights for grayscale and duotone are unverified natively.
344
+ An unchanged export imports every treatment field back. An edited overlay drops only the overlay and reports `invalid-image-provenance`. An edited picture or effect drops the treatment: the picture stays an ordinary image block. See core `docs/image-treatments.md` for the vocabulary and the unsupported effects: blur, shadows, soft edges and background removal. PowerPoint's luminance weights for grayscale and duotone are unverified natively.
343
345
 
344
346
  Tests compare SVG/native fit and crop geometry across nine synthetic raster fixtures and cover all eight JPEG orientations. Keynote 14.4 visually preserves proportions for wide/tall fit/crop and displays all eight orientations correctly. This does not establish Microsoft PowerPoint raster parity or WebP support in every Office version.
345
347
 
@@ -376,14 +378,14 @@ Pattern backgrounds export as a native `<a:pattFill>` with explicit foreground a
376
378
 
377
379
  `code.language` and `metric.trend` export natively from the same core tables the preview reads (RR-07). Code keeps one text box per source line; a language adds coloured runs (theme-derived colours kept at 4.5:1 or more on the #111827 panel) whose text concatenates to the exact line, and an unknown language is exported plain. A trend adds one arrow shape (`upArrow`, `downArrow` or `rightArrow`, alt text "Trend: up") beside the trend word and colours the delta and trend text green, red or neutral; the arrow is part of the metric's provenance group, so the metric re-imports as the one authored payload. `scripts/rr07-native-set.mjs <dir>` builds the native-check decks, preview PNGs and a manifest.
378
380
 
379
- Image backgrounds export as a native `<a:blipFill>` for the embedded raster: `data:` sources, declared `asset:` ids and `imageResolver` results. `cover` (the default) crops the centered source to the slide aspect with `a:srcRect`. `contain` letterboxes the source with `a:fillRect` insets. `tile` repeats square cells that are min(width, height)/4 in size, from the top-left, as the preview does. The deck `imageFill` decides whether each cell is covered or contained; contained cells use negative (transparent) source insets. With `dpi="0"`, a native tile is sized from the raster's own resolution: PNG `pHYs` in metres; JPEG JFIF density in inches or centimetres, otherwise EXIF `XResolution`/`YResolution`; 96 dpi when absent. The tile scale compensates on each axis, so a cell matches the preview's CSS pixels. WebP sources become PNG parts as for pictures. Opacity becomes `a:alphaModFix`. An unresolved image keeps the background color and reports `unresolved-asset` (`strictAssets` throws). A JPEG EXIF orientation cannot rotate a background fill, so it reports `unsupported-background-image-orientation`.
381
+ An image background (`{type: "image", src, alt, fit, focus, opacity, recolor, overlay}`, or an image source string, a cover image; core composes it as `backgroundImage` from the slide, deck or theme background) never moves content. Without alt text it exports as the native slide background, an `<a:blipFill>` for the embedded raster (`data:` sources, declared `asset:` ids and `imageResolver` results): `cover` (the default) crops the picture to the slide with `a:srcRect` around the `focus` point (core's `fitImage`), `contain` centers the whole picture with `a:fillRect` insets, `stretch` fills the slide, and `tile` repeats the picture at its intrinsic size (1 picture pixel = 1 reference pixel, 1/96 in) from the top-left corner. With `dpi="0"`, a native tile is sized from the raster's own resolution: PNG `pHYs` in metres; JPEG JFIF density in inches or centimetres, otherwise EXIF `XResolution`/`YResolution`; 96 dpi when absent. The tile scale is that resolution over 96 on each axis. `recolor` becomes `a:grayscl` or `a:duotone` and opacity `a:alphaModFix` on the blip, as for image blocks. With alt text (the background's own only: a referenced asset's alt does not make a background meaningful) the picture is a full-slide `p:pic` at the back of the slide, named `OPF background slides.N`, carrying the text as its `descr` so screen readers read it; the slide background is then the colour scheme's default background colour, which shows around a contained picture and through a translucent one. The overlay is one shape directly above the picture (`OPF background overlay slides.N` for a native fill, `OPF background slides.N overlay` for the picture): the whole slide, or a band along one edge. WebP sources become PNG parts as for pictures. An unresolved image keeps the background color and reports `unresolved-asset` at the background's path (`strictAssets` throws). A JPEG EXIF orientation cannot rotate a background fill, so it reports `unsupported-background-image-orientation`.
380
382
 
381
- Import reads `a:pattFill` with a DrawingML preset and resolvable foreground/background colors, including slide/layout/master inheritance and theme references, as an OPF pattern background. Import does not guess undefined default colors. Import also reads embedded `a:blipFill` pictures as an image background with the exact image bytes as a `data:` URI. Tiles become `tile`. If the tile scale, offset, alignment, flip or source insets differ from the geometry OPF `tile` exports (contained cells at the raster's resolution), the import reports `approximate-background-image`. A centered crop matching the slide aspect becomes `cover`, and centered fill insets matching the image aspect become `contain`. Off-center, distorted or effect-bearing fills become the closest `cover` and report `approximate-background-image`. Linked or unreadable pictures report `unsupported-background-image`. `test/background-fills.mjs` covers export, import, native edits, inheritance and diagnostics. Microsoft PowerPoint rendering of these fills, especially its resolution-based tile scale and negative tile insets, has not been verified.
383
+ Import reads `a:pattFill` with a DrawingML preset and resolvable foreground/background colors, including slide/layout/master inheritance and theme references, as an OPF pattern background. Import does not guess undefined default colors. Import also reads embedded `a:blipFill` pictures as an image background with the exact image bytes as a `data:` URI. Tiles become `tile`. If the tile scale, offset, alignment, flip or source insets differ from the geometry OPF `tile` exports (the picture at its intrinsic size), the import reports `approximate-background-image`. A crop matching the slide aspect becomes `cover`, with the `focus` the crop centers (a crop pinned to an edge reads as the edge-most focus with that crop); centered fill insets matching the image aspect become `contain`; an uncropped fill of another aspect becomes `stretch`. Padded, offset or effect-bearing fills become the closest fit and report `approximate-background-image`. `a:grayscl` and an `a:duotone` of two literal colours become `recolor`. An unchanged tagged background picture (`OPF_IMAGE_V1`) becomes the image background with its alt text, fit, focus, opacity and recolor, and an unchanged tagged overlay (`OPF_IMAGE_OVERLAY_V1`) its overlay. Linked or unreadable pictures report `unsupported-background-image`. `test/background-fills.mjs` and `test/background-images.mjs` cover export, import, native edits, inheritance and diagnostics. Microsoft PowerPoint rendering of these fills has not been verified natively: the resolution-based tile scale, a tiled `p:pic` fill, what PowerPoint shows around a contained or through a translucent native background fill (it has nothing behind the slide background), and negative `a:srcRect` insets.
382
384
 
383
385
 
384
386
  ## Theme color scheme
385
387
 
386
- Export writes the deck's resolved color scheme into `ppt/theme/theme1.xml` `a:clrScheme`, named after the scheme. The OPF slots map one to one: `dark1`/`light1`/`dark2`/`light2` to `dk1`/`lt1`/`dk2`/`lt2`, `accent1`-`accent6` to themselves, and `hyperlink`/`followedHyperlink` to `hlink`/`folHlink`. An abstract role fills a slot only when the scheme leaves that slot unset. Previously every export carried the vendored Office palette (`accent1` `4472C4`). When the deck names a catalog `design.theme`, `a:theme` and its `thm15:themeFamily` take that theme's name. The rewrite happens during package normalization; the vendored PptxGenJS bytes are unchanged.
388
+ Export writes the deck's resolved color scheme into `ppt/theme/theme1.xml` `a:clrScheme`, named after the scheme. The OPF slots map one to one: `dark1`/`light1`/`dark2`/`light2` to `dk1`/`lt1`/`dk2`/`lt2`, `accent1`-`accent6` to themselves, and `hyperlink`/`followedHyperlink` to `hlink`/`folHlink`. An abstract role fills a slot only when the scheme leaves that slot unset. Previously every export carried the vendored Office palette (`accent1` `4472C4`). When the deck's `design.theme` resolves (embedded, or in a registered catalog), `a:theme` and its `thm15:themeFamily` take that theme's name (else its id). A deck with no resolvable colour scheme writes core's engine default slots under the name `OpenPresentation`. The rewrite happens during package normalization; the vendored PptxGenJS bytes are unchanged.
387
389
 
388
390
  Document colors that name a slot or role (`accent2`, `textSecondary`, `surface`, ...) become `a:schemeClr` in runs, table cell fills and text, and table borders. Theme-slot backgrounds (`{type:'theme', slot}` or a slot name) do the same. Slide content reaches the theme through the master color map, so `dark1`, `light1`, `dark2` and `light2` are written as `tx1`, `bg1`, `tx2` and `bg2`. A reference becomes `schemeClr` only when the deck theme slot holds exactly the color the slide resolved. PowerPoint has one theme per master, so a slide with its own `design.colorScheme` (or a slide theme that changes any slot) keeps **every** color literal, including values that happen to equal the deck theme, so a theme edit never partially recolors it.
389
391
 
@@ -400,7 +402,7 @@ These stay `a:srgbClr`:
400
402
  - translucent colors;
401
403
  - other engine-derived chrome: card fills and borders, chart panels, series and labels, timeline connectors and markers, and image and media placeholders, plus default text on a card or a literal background. These colors are contrast-selected or derived from roles, not named by the document, and are not yet theme references.
402
404
 
403
- Import reads the first slide master's theme. An exact twelve-slot match with a bundled catalog scheme returns its id; the `clrScheme` name breaks ties. Otherwise the importer returns inline slots, relative to the catalog scheme the `clrScheme` is named after when there is one (`{id:'boost', accent1:'#123456'}`). A theme whose name equals a catalog theme's name maps to `design.theme` only if the package's color scheme or heading/body fonts match that theme; otherwise `theme-unverified` is reported. A missing theme, or slots that are not opaque sRGB/system colors, report `unsupported-theme-colors` on `design.colorScheme`. Slide colors are still imported as resolved hex. Role overrides such as `primary` have no theme slot and are not recovered. `test/theme-colors.mjs` covers all 14 catalog schemes and 4 catalog themes, override, foreign and damaged themes, and the literal-color boundaries. `test/table-theme-colors.mjs` covers named and default table chrome on all 14 schemes and four backgrounds, literals, override slides, the link colors, re-import and a theme switch in the package. This establishes package structure and round-trip, not PowerPoint rendering.
405
+ Import reads the first slide master's theme and matches it against the host default catalog only (the first of `fromPptx`'s `catalogs`; nothing is bundled): the bare ids it imports resolve in a document that declares no catalogs. An exact twelve-slot match with one of its colour schemes returns that id; the `clrScheme` name breaks ties. Otherwise the importer returns inline slots, relative to the scheme the `clrScheme` is named after when there is one (`{id:'boost', accent1:'#123456'}`). A theme whose name equals one of its themes' names maps to `design.theme` only if the package's color scheme or heading/body fonts match that theme (its references resolved in its own group first); otherwise `theme-unverified` is reported. With no catalog registered, or a match only in another registered catalog, the colour scheme imports inline and no theme id is recovered; an FF-32 stored reference still restores. A missing theme, or slots that are not opaque sRGB/system colors, report `unsupported-theme-colors` on `design.colorScheme`. Slide colors are still imported as resolved hex. Role overrides such as `primary` have no theme slot and are not recovered. `test/theme-colors.mjs` covers all 14 gallery schemes and 4 gallery themes (registered as `defaultCatalog`), import without catalogs, override, foreign and damaged themes, and the literal-color boundaries. `test/table-theme-colors.mjs` covers named and default table chrome on all 14 schemes and four backgrounds, literals, override slides, the link colors, re-import and a theme switch in the package. This establishes package structure and round-trip, not PowerPoint rendering.
404
406
 
405
407
  ## JPEG orientation on import
406
408
 
@@ -97,14 +97,17 @@ export function importBackground(slidePath, dimensions, archive, report) {
97
97
  return readNativeBackground(drawingObject([style]), dimensions, report, context);
98
98
  }
99
99
 
100
- const imageEffects = new Set(['r:embed', 'r:link', 'cstate', 'a:alphaModFix', 'a:lum', 'a:extLst']);
100
+ const imageEffects = new Set(['r:embed', 'r:link', 'cstate', 'a:alphaModFix', 'a:grayscl', 'a:duotone', 'a:lum', 'a:extLst']);
101
101
  const inset = (rect, key) => Number(rect?.[key] ?? 0) / 100000;
102
102
  const near = (a, b) => Math.abs(a - b) <= .005;
103
+ const focusValue = value => Math.round(Math.max(0, Math.min(1, value)) * 10000) / 10000;
103
104
 
104
- // Import an embedded raster picture fill as an OPF image background. OPF's
105
- // cover/contain/tile fits are recovered from the stretch/tile geometry; any
106
- // other stretch (off-center crop, distortion) or unsupported picture effect is
107
- // imported as the closest centered cover and reported.
105
+ // Import an embedded raster picture fill as an OPF image background ({type: 'image', src, fit, focus, opacity}), the
106
+ // inverse of nativeImageBackgroundFill (FA-23): an intrinsic-size top-left tile is `tile`; a crop that keeps the slide's
107
+ // aspect is `cover`, with the focus point the crop centers (a crop pinned to an edge cannot say how far past the edge
108
+ // the focus was, so it reads as the edge-most focus that gives the same crop); centered fill-rectangle insets that keep
109
+ // the picture's aspect are `contain`; an uncropped fill of another aspect is `stretch`. Any other geometry or picture
110
+ // effect is imported as the closest fit and reported.
108
111
  function readImageBackground(fill, partPath, {relationships, bytes}, dimensions, report) {
109
112
  const blip = fill['a:blip'] ?? {};
110
113
  const relationship = blip['r:embed'] && relationships(partPath).get(blip['r:embed']);
@@ -116,23 +119,31 @@ function readImageBackground(fill, partPath, {relationships, bytes}, dimensions,
116
119
  }
117
120
  const approximate = reason => report({code: 'approximate-background-image', message: `The native background picture ${reason}; it was imported as the closest OPF image background.`});
118
121
  const lum = blip['a:lum'];
119
- if (Object.keys(blip).some(key => !imageEffects.has(key)) || (lum && Object.keys(lum).length)) approximate('uses picture effects outside OPF opacity');
122
+ if (Object.keys(blip).some(key => !imageEffects.has(key)) || (lum && Object.keys(lum).length)) approximate('uses picture effects outside OPF opacity and recolor');
123
+ // a:grayscl and an a:duotone of two literal colours are the OPF recolor (FA-22 draft 3).
124
+ let recolor;
125
+ if (blip['a:grayscl'] !== undefined) recolor = 'grayscale';
126
+ const duotone = blip['a:duotone'], literal = node => /^[\da-f]{6}$/i.test(node?.val ?? '') && Object.keys(node).every(key => key === 'val' || typeof key === 'symbol') ? `#${node.val.toUpperCase()}` : undefined;
127
+ if (duotone) {
128
+ const colors = Array.isArray(duotone['a:srgbClr']) ? duotone['a:srgbClr'] : [];
129
+ const [dark, light] = colors.map(literal);
130
+ if (colors.length === 2 && dark && light && Object.keys(duotone).every(key => key === 'a:srgbClr' || typeof key === 'symbol')) recolor = {dark, light};
131
+ else approximate('uses a duotone recolor that is not two literal colours');
132
+ }
133
+ if (recolor && blip['a:grayscl'] !== undefined && duotone) approximate('uses both a grayscale and a duotone recolor');
120
134
  const amount = blip['a:alphaModFix'] ? Number(blip['a:alphaModFix'].amt ?? 100000) / 100000 : 1;
121
135
  const opacity = Number.isFinite(amount) ? Math.max(0, Math.min(1, amount)) : 1;
122
- let fit = 'cover';
136
+ let fit = 'cover', focus;
123
137
  if (fill['a:tile']) {
124
138
  fit = 'tile';
125
- // OPF tile has one geometry: top-left min(w,h)/4 cells holding the whole
126
- // image (the default contain imageFill), at the raster's own resolution.
127
139
  const tile = fill['a:tile'], crop = fill['a:srcRect'];
128
- const expected = nativeTileScale(metadata, dimensions);
129
- const x = (1 - expected.cell / (metadata.width * expected.scale)) / 2, y = (1 - expected.cell / (metadata.height * expected.scale)) / 2;
140
+ const expected = nativeTileScale(metadata);
130
141
  const value = (raw, fallback) => raw === undefined ? fallback : Number(raw);
131
142
  const scaled = (raw, target) => Math.abs(value(raw, 100000) - target) <= Math.max(2, target * .005);
132
143
  const matches = scaled(tile.sx, expected.sx) && scaled(tile.sy, expected.sy)
133
144
  && Object.entries(nativeTileAlignment).every(([key, native]) => String(tile[key] ?? native) === native)
134
- && near(inset(crop, 'l'), x) && near(inset(crop, 'r'), x) && near(inset(crop, 't'), y) && near(inset(crop, 'b'), y);
135
- if (!matches) approximate('tiles with a scale, offset, alignment, flip or crop that the OPF tile fit does not express');
145
+ && ['l', 't', 'r', 'b'].every(key => near(inset(crop, key), 0));
146
+ if (!matches) approximate('tiles with a scale, offset, alignment, flip or crop that the OPF tile fit (intrinsic size from the top-left corner) does not express');
136
147
  } else {
137
148
  const crop = fill['a:srcRect'], box = fill['a:stretch']?.['a:fillRect'];
138
149
  const [l, t, r, b] = ['l', 't', 'r', 'b'].map(key => inset(crop, key));
@@ -141,10 +152,15 @@ function readImageBackground(fill, partPath, {relationships, bytes}, dimensions,
141
152
  const target = dimensions.width * (1 - fl - fr) / (dimensions.height * (1 - ft - fb));
142
153
  const aspect = Number.isFinite(source) && Number.isFinite(target) && source > 0 && target > 0 && Math.abs(source / target - 1) <= .005;
143
154
  const noCrop = [l, t, r, b].every(value => near(value, 0)), noInset = [fl, ft, fr, fb].every(value => near(value, 0));
144
- if (aspect && noInset && near(l, r) && near(t, b) && Math.min(l, t) >= 0) fit = 'cover';
145
- else if (aspect && noCrop && near(fl, fr) && near(ft, fb) && Math.min(fl, ft) >= 0) fit = 'contain';
146
- else approximate('is cropped off-center, distorted or offset');
155
+ if (noCrop && noInset && !aspect) fit = 'stretch';
156
+ else if (aspect && noInset && Math.min(l, t, r, b) >= -.00002) {
157
+ fit = 'cover';
158
+ // fitImage centers the focus point: visible share v = 1 - l - r, focus x = l + v / 2.
159
+ const x = focusValue(l + (1 - l - r) / 2), y = focusValue(t + (1 - t - b) / 2);
160
+ if (!near(x, .5) || !near(y, .5)) focus = {x, y};
161
+ } else if (aspect && noCrop && near(fl, fr) && near(ft, fb) && Math.min(fl, ft) >= 0) fit = 'contain';
162
+ else approximate('is cropped with padding, distorted or offset');
147
163
  }
148
164
  const binary = typeof Buffer !== 'undefined' ? Buffer.from(data).toString('base64') : btoa(Array.from(data, byte => String.fromCharCode(byte)).join(''));
149
- return {type: 'image', image: {src: `data:${metadata.mediaType};base64,${binary}`, fit}, ...(opacity === 1 ? {} : {opacity})};
165
+ return {type: 'image', src: `data:${metadata.mediaType};base64,${binary}`, ...(fit === 'cover' ? {} : {fit}), ...(focus ? {focus} : {}), ...(opacity === 1 ? {} : {opacity}), ...(recolor ? {recolor} : {})};
150
166
  }
@@ -71,39 +71,42 @@ export function nativeBackgroundFill(background, {width, height}, fallback = 'FF
71
71
  }
72
72
 
73
73
  const rectXml = (name, rect = {}) => `<a:${name}${['l', 't', 'r', 'b'].filter(key => rect[key]).map(key => ` ${key}="${rect[key]}"`).join('')}/>`;
74
- const percent = value => Math.round(value * 100000);
74
+ const percent = value => Math.round(value * 100000) || 0;
75
75
 
76
- // Picture fill for an OPF image background, following the SVG preview:
77
- // cover crops the centered source to the slide aspect, contain letterboxes it
78
- // with fill-rectangle insets, and tile repeats square cells of min(w,h)/4
79
- // from the top-left corner. Each cell holds the image by the deck imageFill
80
- // (crop = cover, otherwise contain; negative source insets are transparent
81
- // padding). With dpi="0", DrawingML sizes a tile from the raster's own
82
- // resolution (PNG pHYs, JPEG JFIF or EXIF; 96 dpi when absent), while the
83
- // preview draws CSS pixels, so the tile scale compensates on each axis.
76
+ // FA-23: the picture fill of an OPF image background (core SlideComposition.backgroundImage), drawn like the SVG
77
+ // preview. `fitImage` is core's shared fit math (composition `fitImage`), so every engine crops, letterboxes and
78
+ // focuses the same way:
79
+ // - stretch fills the slide (no crop);
80
+ // - cover crops the picture to the slide with the focus point kept in view (a:srcRect, positive insets);
81
+ // - contain centers the whole picture; the fill rectangle insets leave the rest of the slide unfilled;
82
+ // - tile repeats the picture at its intrinsic size (1 picture px = 1 reference px, 1/96 in) from the top-left corner.
83
+ // With dpi="0", DrawingML sizes a tile from the raster's own resolution (PNG pHYs, JPEG JFIF or EXIF; 96 dpi when
84
+ // absent), so the tile scale is the raster's dpi / 96 on each axis.
85
+ // A recolor (core backgroundImage.recolor, resolved: {type: 'grayscale'} or {type: 'duotone', dark: {hex}, light: {hex}})
86
+ // is a:grayscl or a:duotone on the blip, then opacity is a:alphaModFix (picture pixels only), as for image blocks.
84
87
  export const nativeTileAlignment = {tx: '0', ty: '0', flip: 'none', algn: 'tl'};
85
- export function nativeTileScale(image, {width, height, imageFill = 'fit'}) {
86
- const cell = Math.min(width, height) / 4;
87
- const scale = (imageFill === 'crop' ? Math.max : Math.min)(cell / image.width, cell / image.height);
88
- return {cell, scale, sx: percent(scale * (image.dpiX ?? 96) / 96), sy: percent(scale * (image.dpiY ?? 96) / 96)};
88
+ export function nativeTileScale(image) {
89
+ return {sx: percent((image.dpiX ?? 96) / 96), sy: percent((image.dpiY ?? 96) / 96)};
89
90
  }
90
- export function nativeImageBackgroundFill(relationshipId, image, {fit = 'cover', opacity = 1, width, height, imageFill = 'fit'}) {
91
+ export function nativeImageBackgroundFill(relationshipId, image, {fit = 'cover', focus, opacity = 1, recolor, width, height, fitImage}) {
91
92
  const alpha = clamp(opacity);
92
- const blip = `<a:blip r:embed="${relationshipId}">${alpha === 1 ? '' : `<a:alphaModFix amt="${percent(alpha)}"/>`}</a:blip>`;
93
+ const recolorXml = recolor?.type === 'grayscale' ? '<a:grayscl/>'
94
+ : recolor?.type === 'duotone' ? `<a:duotone><a:srgbClr val="${recolor.dark.hex}"></a:srgbClr><a:srgbClr val="${recolor.light.hex}"></a:srgbClr></a:duotone>` : '';
95
+ const blip = `<a:blip r:embed="${relationshipId}">${recolorXml}${alpha === 1 ? '' : `<a:alphaModFix amt="${percent(alpha)}"/>`}</a:blip>`;
93
96
  if (fit === 'tile') {
94
- const {cell, scale, sx, sy} = nativeTileScale(image, {width, height, imageFill});
95
- const x = percent((1 - cell / (image.width * scale)) / 2), y = percent((1 - cell / (image.height * scale)) / 2);
96
- const {tx, ty, flip, algn} = nativeTileAlignment;
97
- return `<a:blipFill dpi="0" rotWithShape="1">${blip}${rectXml('srcRect', {l: x, t: y, r: x, b: y})}<a:tile tx="${tx}" ty="${ty}" sx="${sx}" sy="${sy}" flip="${flip}" algn="${algn}"/></a:blipFill>`;
97
+ const {sx, sy} = nativeTileScale(image), {tx, ty, flip, algn} = nativeTileAlignment;
98
+ return `<a:blipFill dpi="0" rotWithShape="1">${blip}<a:tile tx="${tx}" ty="${ty}" sx="${sx}" sy="${sy}" flip="${flip}" algn="${algn}"/></a:blipFill>`;
98
99
  }
100
+ if (fit === 'stretch') return `<a:blipFill dpi="0" rotWithShape="1">${blip}<a:stretch><a:fillRect/></a:stretch></a:blipFill>`;
101
+ const frame = {x: 0, y: 0, width, height};
102
+ const placed = fitImage(frame, fit, image.width / image.height, focus);
99
103
  if (fit === 'contain') {
100
- const scale = Math.min(width / image.width, height / image.height);
101
- const x = percent((1 - image.width * scale / width) / 2), y = percent((1 - image.height * scale / height) / 2);
102
- return `<a:blipFill dpi="0" rotWithShape="1">${blip}<a:srcRect/><a:stretch>${rectXml('fillRect', {l: x, t: y, r: x, b: y})}</a:stretch></a:blipFill>`;
104
+ const box = placed.image;
105
+ const inset = {l: percent(box.x / width), t: percent(box.y / height), r: percent((width - box.x - box.width) / width), b: percent((height - box.y - box.height) / height)};
106
+ return `<a:blipFill dpi="0" rotWithShape="1">${blip}<a:srcRect/><a:stretch>${rectXml('fillRect', inset)}</a:stretch></a:blipFill>`;
103
107
  }
104
- const scale = Math.max(width / image.width, height / image.height);
105
- const x = percent((1 - width / (image.width * scale)) / 2), y = percent((1 - height / (image.height * scale)) / 2);
106
- return `<a:blipFill dpi="0" rotWithShape="1">${blip}${rectXml('srcRect', {l: x, t: y, r: x, b: y})}<a:stretch><a:fillRect/></a:stretch></a:blipFill>`;
108
+ const {left, top, right, bottom} = placed.crop;
109
+ return `<a:blipFill dpi="0" rotWithShape="1">${blip}${rectXml('srcRect', {l: percent(left), t: percent(top), r: percent(right), b: percent(bottom)})}<a:stretch><a:fillRect/></a:stretch></a:blipFill>`;
107
110
  }
108
111
 
109
112
  // Internal metadata supplied by the ordered XML reader. A Symbol cannot collide
@@ -2,8 +2,8 @@
2
2
  //
3
3
  // The core catalog keeps one chart type per Aspose.Slides ChartType
4
4
  // (spec/catalogs/chart-types, OpenPresentation/opf#121). The mapping is carried
5
- // here so the exporter stays self-contained. A catalog id maps to the exact
6
- // Office construct; any other id keeps the legacy substring heuristic.
5
+ // here so the exporter stays self-contained. Every id maps to the exact Office construct (OPF 0.15: chart.type is a
6
+ // schema enum, so there is no other id).
7
7
 
8
8
  const category = (pptx, extra = {}) => ({ family: 'category', pptx, ...extra });
9
9
 
@@ -76,27 +76,12 @@ export function chartTypeFromChartex(layoutIds) {
76
76
  return null;
77
77
  }
78
78
 
79
- const ALIASES = Object.freeze({ donut: 'doughnut' });
80
-
81
- // Resolve an OPF chart type id to {id, spec}. A catalog id resolves to itself;
82
- // anything else returns the legacy heuristic with id: null.
79
+ // Resolve an OPF chart type id to {id, spec}. `chart.type` is a schema enum in OPF 0.15 (core CHART_TYPES), so every
80
+ // validated document names one of these; an id outside the table (a caller that skipped validation) resolves to
81
+ // {id: null, spec: undefined}.
83
82
  export function resolveChartType(type) {
84
- const raw = String(type ?? '').trim().toLowerCase();
85
- const id = ALIASES[raw] ?? raw;
86
- if (Object.hasOwn(CHART_TYPES, id)) return { id, spec: CHART_TYPES[id] };
87
- return { id: null, spec: legacyChartType(raw) };
88
- }
89
-
90
- // The pre-FF-22 heuristic for ids outside the core catalog.
91
- function legacyChartType(normalized) {
92
- if (normalized.includes('pie')) return CHART_TYPES.pie;
93
- if (normalized.includes('doughnut') || normalized.includes('donut')) return CHART_TYPES.doughnut;
94
- if (normalized.includes('area')) return { ...CHART_TYPES.area, legacy: true };
95
- if (normalized.includes('line')) return { ...CHART_TYPES['line-with-markers'], legacy: true };
96
- if (normalized.includes('scatter')) return CHART_TYPES.scatter;
97
- if (normalized.includes('radar')) return { ...CHART_TYPES.radar, legacy: true };
98
- const grouping = normalized.includes('stacked') ? 'stacked' : 'clustered';
99
- return category('bar', { barDir: normalized.includes('bar') ? 'bar' : 'col', grouping, legacy: true });
83
+ const id = String(type ?? '').trim();
84
+ return Object.hasOwn(CHART_TYPES, id) ? { id, spec: CHART_TYPES[id] } : { id: null, spec: undefined };
100
85
  }
101
86
 
102
87
  // Native construct -> kept OPF id, for import. `node` is the parsed chart-type