@openpresentation/opf-pptx 0.14.0 → 0.16.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,14 +1,16 @@
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
 
9
11
  Chart emphasis (FA-14, unreleased): `chart.highlight` exports as native series fills (`c:ser/c:spPr`, plus `c:marker/c:spPr` on line, scatter and radar series) and per-point fills (`c:dPt`: `c:spPr` on a column or bar, a circular `c:marker` on a line, the slice fill on a pie or doughnut). The accent is `a:schemeClr accent1` when the deck theme holds the primary exactly (a theme edit in PowerPoint recolors it) and an sRGB literal when the contrast adjustment moved it; the muted color is an sRGB literal (a mix of two theme colors is not a scheme color). Labels inside a mark take the color that contrasts with that mark. The highlight is not inferred on import: in `full` provenance mode it is recorded in the chart's `OPF_DATA_V1` record and restored while the chart's cached data is unchanged. Chartex constructs are not highlighted. See core `docs/chart-options.md`.
10
12
 
11
- Chart and table data (RR-54, unreleased; core `docs/chart-table-data.md`): chart values pass core `chartNumber` (a string that is not a plain decimal, such as `"12%"`, `"$5"` or `"(5)"`, is a gap and one `chart-value-not-numeric` diagnostic per chart, where the exporter used to strip it to a number). A column number format (`{"name": "Revenue", "format": "$#,##0.0"}`) is written as the series format code, the data-label and value-axis number formats (the axis takes the first plotted series' format, as the preview does) and the embedded workbook cell formats (Edit Data shows it), on classic and chartex charts. Top-level `datasets` referenced by `{"dataset": "<id>", "fields"?}` and `chart.mapping` export exactly like their inline equivalent; dataset tables and formatted table cells export their display text. With `full` provenance, `OPF_DATASETS_V1` (beside the document tag) and `OPF_DATA_V1` (on each chart or table frame that uses these fields) restore the datasets, references, mapping, formats and `data.source` on import while the native caches (a plain PowerPoint save keeps them), the referenced datasets and the cell texts are unchanged (tags are limited to 16 MiB and shape-checked; a bad one is reported as `invalid-data-provenance`); a deck without them maps every series format code that core `fromExcelNumberFormat` maps back to the column format and reports any other code (`chart-number-format-adapted`). A deck that uses none of these fields exports byte for byte as before.
13
+ Chart and table data (RR-54, unreleased; core `docs/chart-table-data.md`): chart values pass core `chartNumber` (a string that is not a plain decimal, such as `"12%"`, `"$5"` or `"(5)"`, is a gap and one `chart-value-not-numeric` diagnostic per chart, where the exporter used to strip it to a number). A column number format (`{"name": "Revenue", "format": "$#,##0.0"}`) is written as the series format code, the data-label and value-axis number formats (the axis takes the first plotted series' format, as the preview does) and the embedded workbook cell formats (Edit Data shows it), on classic and chartex charts. Top-level `datasets` referenced by `{"dataset": "<id>", "fields"?}` and `chart.mapping` export exactly like their inline equivalent; dataset tables and formatted table cells export their display text. With `full` provenance, `OPF_DATASETS_V1` (beside the document tag) and `OPF_DATA_V1` (on each chart frame that uses these fields and every table frame) restore the datasets, references, mapping, formats and `data.source` on import while the native caches (a plain PowerPoint save keeps them), the referenced datasets and the native table evidence are unchanged (tags are limited to 16 MiB and shape-checked; a bad one is reported as `invalid-data-provenance`); a deck without them maps every series format code that core `fromExcelNumberFormat` maps back to the column format and reports any other code (`chart-number-format-adapted`). Full provenance also records inline tables as authored, including scalar numbers and absent styling. Unchanged native cells restore that representation; cell text, formatting, table geometry or theme/style edits retain the observed native cells and report `table-data-provenance-changed`. `references-only` and no-provenance tables import displayed text and observed formatting, so scalar types and authored style absence are lossy.
12
14
 
13
15
  Version 0.9.1 kept core `^0.11.0` and raised the optional `@openpresentation/opf-render` peer to `^0.9.0` so it coexists with editor 0.8.0. ColorRef / `variables` on content colors still hex-resolve through core `resolveColorRef()` before PptxGenJS `srgbClr` export. Unrecognized run colors such as `color:'invalid'` still validate and fall back to the theme text color. Native DrawingML `schemeClr` and theme `clrScheme` writes, and native `p:hf` headers/footers, are not in this release. Import still flattens theme colors to hex. Metric, quote and timeline layout placeholders and the corrected text-bullet contract from 0.8.1 are retained.
14
16
 
@@ -61,17 +63,17 @@ const bytes = await toPptx({
61
63
  await fs.promises.writeFile("quarterly-review.pptx", bytes);
62
64
  ```
63
65
 
64
- `toPptx` returns a `Uint8Array` containing a PowerPoint-openable `.pptx`. It does not fetch remote assets. Data URI images and local paths can be embedded directly; hosts that need private asset loading should pass `imageResolver(src, context)`. Set `strictAssets: true` to turn unresolved or remote image assets into structured `OPFPptxError` failures instead of editable placeholder boxes.
66
+ `toPptx` returns a `Uint8Array` containing a PowerPoint-openable `.pptx`. It does not fetch remote assets. Data URI images and local paths can be embedded directly; hosts that need private asset loading should pass `imageResolver(src, context)`. Every picture path (image blocks and region images, quote photos, slide and deck background pictures, watermarks, cover logos, header and footer images and logos, picture bullets) reports an unresolved source with exactly one `unresolved-asset` diagnostic at its OPF path and draws the preview's fallback (the placeholder, the background colour, no watermark, or the character bullets), so strict CLI export (`--fail-on warning`) gates it. A local file path that cannot be read is unresolved like any other source (`reason: 'file-unreadable'`), instead of failing the export. Set `strictAssets: true` to turn unresolved or remote image assets into structured `OPFPptxError` failures instead of editable placeholder boxes.
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
- 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.
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.
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. 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.
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,25 +216,24 @@ 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.
223
+ - Measured export uses the optional renderer's public script planner for each resolved slide and each run language. The same loaded `fonts` handle can measure Arabic/Latin text in SVG and PPTX; native font names still follow the document's selected scheme. Custom measurement providers remain active when the optional renderer is absent. These controls establish shared line geometry and editable source recovery; native Office appearance is separate.
221
224
  - Per-slide script fonts (opf-pptx#168): the presentation theme is built from the first slide. A slide whose own font scheme, slide theme or inline `eastAsian`/`complexScript` selects script fonts that theme does not carry gets its own slide master: a copy of slide master 1 and its layout, with a theme that is the presentation theme plus that slide's East Asian / complex-script slots and per-script entry (named `1_<theme>`, `2_<theme>`, ... as PowerPoint names a second master's theme). Slides with the same script fonts share a master, a slide that selects none stays on master 1, and masters are numbered in slide order. A deck with one script profile keeps one master and is byte-identical to the export before this change. A presentation has one notes master, whose theme is a copy of the presentation theme, so the notes of a slide on another master draw in the presentation theme's script fonts: `script-font-notes-not-exported` (path `slides.N.notes`) reports each such slide with notes.
222
225
  - In a right-to-left deck, each slide and notes paragraph takes its direction from core `paragraphDirection(text, direction)`, the same rule the renderer uses: `rtl="1"` when its first strong character is right-to-left or it has none (digits, punctuation, empty), else an explicit `rtl="0"` (an English quote, a code line). The master, layout and presentation default paragraph levels start right-to-left only in a right-to-left deck. Left-to-right decks write no paragraph direction. Alignment is logical (RR-05, needs the core release that composes right-to-left decks): `left` is the start edge, so a right-to-left paragraph writes `rtl="1"` with `algn="r"` (PowerPoint's `algn` is physical, and `marL`/`indent` are the start-side margin and hanging indent, so a bullet hangs at the right), and every wrapped line shares its paragraph's direction and alignment (core computes the direction once per paragraph, not per line). The composition is mirrored through core: the `left` region and the first column are drawn at the right, a right-to-left table writes `a:tblPr rtl="1"` with its columns in logical order, column, line and area charts write `c:catAx` orientation `maxMin` (first category and value axis at the right), cover logos and header/footer zones swap sides, the master, layout and notes default levels start right-aligned, and notes paragraphs start at the right. Each Latin phrase inside an Arabic or Hebrew run (letters, joining spaces and word punctuation, digits) is written as its own `en-US` run, so PowerPoint orders `v2.0` and `PowerPoint 365` left to right instead of splitting them into items; digits that touch Arabic words stay in the Arabic run. Re-import reads right alignment of a right-to-left table cell as the logical start. Left-to-right decks are written exactly as before.
223
226
  - The PPTX names the chosen fonts only and never embeds font programs. Licensed fonts are never embedded; only open fonts could be, through the explicit FF-13 embed path. Catalog names such as Meiryo must be installed where the deck is opened.
224
227
 
225
228
  Charts keep their `c:lang` and left-to-right label paragraphs. The embedded chart workbook is untouched.
226
229
 
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:
230
+ 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
231
 
229
232
  - `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
233
  - `rtl-language-mismatch`: right-to-left paragraphs under a left-to-right language.
233
234
  - `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
235
 
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.
236
+ 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
237
 
237
238
  ## Small conveniences (FA-13)
238
239
 
@@ -315,20 +316,22 @@ A local macOS Quick Look check opened both Roboto and system-Arial specimens. Qu
315
316
 
316
317
  ## Image geometry
317
318
 
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.
319
+ 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
320
 
320
321
  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
322
 
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.
323
+ 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
324
 
324
- Slide-image treatments export from core's normalized geometry as native DrawingML:
325
+ Image block treatments export from core's normalized geometry as native DrawingML, the mapping 0.14's slide image used:
325
326
 
326
327
  - `shape` becomes the picture's `a:prstGeom` (`rect`, `roundRect`, `ellipse` or `hexagon`) with core's guide values.
327
328
  - `border` becomes a centered solid `a:ln` with a miter join.
328
329
  - `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.
330
+ - `overlay` becomes one tagged `OPF image N overlay` shape directly above the picture, in the frame's shape or as an edge band.
331
+
332
+ 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
333
 
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.
334
+ 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
335
 
333
336
  ### Brand assets, picture bullets and the accent font (spec-gap closure A)
334
337
 
@@ -339,7 +342,7 @@ These design fields need the core that composes them (`resolveLogo`, `geometry.l
339
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.
340
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.
341
344
 
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.
345
+ 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
346
 
344
347
  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
348
 
@@ -376,14 +379,14 @@ Pattern backgrounds export as a native `<a:pattFill>` with explicit foreground a
376
379
 
377
380
  `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
381
 
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`.
382
+ 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
383
 
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.
384
+ 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
385
 
383
386
 
384
387
  ## Theme color scheme
385
388
 
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.
389
+ 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
390
 
388
391
  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
392
 
@@ -400,7 +403,7 @@ These stay `a:srgbClr`:
400
403
  - translucent colors;
401
404
  - 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
405
 
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.
406
+ 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
407
 
405
408
  ## JPEG orientation on import
406
409
 
@@ -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