@openpresentation/opf-pptx 0.18.2 → 0.19.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/DEPENDENCY-NOTES.md +2 -0
- package/README.md +4 -2
- package/dist/content-topology.js +17 -2
- package/dist/document-provenance.js +2 -2
- package/dist/index.d.ts +8 -5
- package/dist/index.js +128 -6
- package/dist/master-furniture.js +25 -7
- package/dist/template-layouts.js +514 -0
- package/package.json +5 -4
package/DEPENDENCY-NOTES.md
CHANGED
|
@@ -31,3 +31,5 @@ After `npm ci`, run `npm test` and `npm run test:packed`. The packed check insta
|
|
|
31
31
|
Run `npm run test:browser` for real browser WebP pixel preservation and rich, conditional and merged/styled table import/preview/export/reimport checks. Local Windows runs use installed Microsoft Edge; other environments first run `npx playwright install --with-deps chromium`. CI and release jobs run the Chromium checks on their built candidate. These assertions cover the named fixtures, not arbitrary Office files.
|
|
32
32
|
|
|
33
33
|
For a comparison against the preceding release, create a separate empty directory, run `npm init -y` there, then `npm install --ignore-scripts --no-audit --no-fund @openpresentation/opf-pptx@0.5.0`. From this repository run `npm run test:compare-published -- <absolute-consumer-directory> <report.json>`. The script verifies the reference version and registry lockfile integrity, then compares every installed core example's PPTX bytes. The older reference deliberately contains the dependency being removed; it is only a comparison input. Explicit replacement images and fallback fonts make the comparison reproducible. Matching bytes do not establish original-asset fidelity or native viewer equivalence.
|
|
34
|
+
|
|
35
|
+
RR-81 final 0.19 acceptance uses local core candidate e21c472d88057d9d90cfa507fe91b89063f3f29a and gallery 2 candidate e0a7eee2dfe996e680b34a907cabfe41905e6d99, with renderer 9fbddcb78dbebcb87355922ca92af7d26c24c48d. The gallery is imported directly by the converter. Its final direct dependency declaration and registry lock reconciliation are release gates pending the actual gallery 2 registry release; local linking is not evidence of a published dependency graph. An attempted lock-only install was rejected by automatic approval review because gallery 2 registry reconciliation was explicitly deferred; no interim gallery 1 lock entry was adopted. Packed browser/package-consumer checks against released dependencies remain gated by the existing requiresUnreleasedCore 0.19 contract.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# OPF PPTX
|
|
2
2
|
|
|
3
|
-
Catalogs (
|
|
3
|
+
Catalogs (OPF 0.19): export resolves the document's embedded records and host-registered catalogs (`Catalog[]`, never fetched). Register gallery 2 explicitly for export: `import { gallery } from "@openpresentation/gallery"; toPptx(deck, { catalogs: [gallery] })`. Bare ids resolve in the document's custom/default records, then the registered default catalog; `catalogs.default:false` closes that fallback. When import `catalogs` is omitted, foreign PowerPoint layout type/name mapping uses the default catalog from installed `@openpresentation/gallery`; theme and colour-scheme recovery still requires explicitly registered catalogs; `fromPptx(bytes, { catalogs: [] })` explicitly opts out. An unresolved reference emits core's `unresolved-reference` diagnostic and uses engine defaults; `strictReferences:true` refuses the export with `OPFPptxError`. Native layout tags preserve valid catalog sources and opt-outs, and damaged identity/source metadata emits diagnostics instead of creating invalid catalog groups.
|
|
4
4
|
|
|
5
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
6
|
|
|
@@ -426,7 +426,7 @@ These stay `a:srgbClr`:
|
|
|
426
426
|
- translucent colors;
|
|
427
427
|
- 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.
|
|
428
428
|
|
|
429
|
-
Import reads the first slide master's theme and matches it against the host default catalog only (the first of `fromPptx`'s `catalogs
|
|
429
|
+
Import reads the first slide master's theme and matches it against the host default catalog only (the first of `fromPptx`'s `catalogs`): recovered bare ids resolve in a document that declares no catalogs. Theme and colour-scheme recovery does not use the foreign-layout default: omitted catalogs and explicit `catalogs: []` both leave recovery unregistered. 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 omitted catalogs or explicit `catalogs: []`, 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.
|
|
430
430
|
|
|
431
431
|
## JPEG orientation on import
|
|
432
432
|
|
|
@@ -487,3 +487,5 @@ Missing, duplicated or inconsistent furniture tags fall back to current native c
|
|
|
487
487
|
Native header and footer placeholders (RR-11): the first footer text becomes a `ftr` placeholder, the first footer date a `dt` placeholder (a live `datetime1`-`datetime7` field for `date: true`) and the first footer slide number an `sldNum` placeholder, each at core's exact box with its furniture tag; the slide master and layout carry the three placeholders and `p:hf` flags, and the notes master records that notes pages carry a page number only. A zone `text` with a `{{slide.number}}` is the `sldNum` placeholder (its other words are fixed runs); any other zone `text` is the `ftr` placeholder. Header parts, socials, images and logos, second text/date/number parts and multi-line text stay tagged shapes (PowerPoint has no object for them). A footer text that shares its zone with a logo is only as wide as its words (core's row layout), so its slide placeholder has that box while the master and layout placeholder spans the zone. `fromPptx` reads the placeholders back with or without provenance, and a deck changed through PowerPoint's Insert > Header & Footer dialog imports the change. Every deck, with or without a footer, carries the three master and layout placeholders (flags off when unused), so Insert > Header & Footer works on a deck with no footer. The mapping table, the dialog-edit table and the vetoable decisions are in [docs/native-header-footer.md](docs/native-header-footer.md).
|
|
488
488
|
|
|
489
489
|
Master furniture (RR-72): a part with no placeholder (a logo or other zone image, socials, header text, a second footer text) that is drawn the same on two or more slides is written once. It goes on the slide master when every slide that shows such furniture draws it, else on a layout shared by the slides that do (one layout per distinct set, so a logo's `onLight` and `onDark` assets get a layout per background tone); a slide that hides it (`design.footer: false`) uses a layout with `showMasterSp="0"`. A part on one slide only, a part with a live field and a part that slide content covers stay on the slide (the last with `furniture-on-slide`, because PowerPoint draws master shapes beneath slide content). The lifted shapes keep their furniture tags (with the slot in place of the slide part), and each slide manifest lists them under `shared`, so `fromPptx` reads them from the layout or master: a master logo returns as its `var:organization.logo.*` reference on every slide. Importers before 0.18 ignore `shared`; shapes on a master or layout without OPF tags (a third-party template) are not imported. See [Master furniture](docs/native-header-footer.md#master-furniture-rr-72).
|
|
490
|
+
|
|
491
|
+
Multi-series Pareto exports in `chartex: "auto"` as an editable clustered column chart containing every authored series, with a `chart-data-adapted` / `chartex-fallback` diagnostic. This preserves data rather than claiming native multi-series Pareto support. Explicit `chartex: "native"` rejects it with `unsupported-chart-data`; single-series native Pareto is unchanged. `chartex: "fallback"` also retains all series. Native acceptance of any new behavior remains separate.
|
package/dist/content-topology.js
CHANGED
|
@@ -18,8 +18,10 @@
|
|
|
18
18
|
// | {form: 'root', fields: [{field, box?}]} // root shorthand with several payloads
|
|
19
19
|
// | {form: 'blocks', blocks: Node[]}
|
|
20
20
|
// | {form: 'regions', regions: {[key]: Node}} // key = promoted region key
|
|
21
|
-
// Node = {t: 'group', id?, ext?, comp?, typed?, blocks: Node[]}
|
|
22
|
-
// | {t: 'leaf', k, id?, ext?, typed?, bullets?, box?, lines?, wrap?} // box = [x, y, w, h] reference px, 1 decimal
|
|
21
|
+
// Node = {t: 'group', id?, ext?, comp?, typed?, pin?, in?, blocks: Node[]}
|
|
22
|
+
// | {t: 'leaf', k, id?, ext?, typed?, pin?, in?, bullets?, box?, lines?, wrap?} // box = [x, y, w, h] reference px, 1 decimal
|
|
23
|
+
// pin = the block's authored `region` pin (OPF 0.19 layout templates, RR-81), restored onto the block
|
|
24
|
+
// in = the template region a root block was drawn in (RR-81): where its shapes are, for editors and diagnostics; never restored
|
|
23
25
|
// typed = true when the authored block spelled out its `type`
|
|
24
26
|
// bullets = true when the authored list block used the `bullets` key (import names every list `items`), also under `type: 'text'`
|
|
25
27
|
// lines = N (>= 2) when a rich-text `text` payload exported as N native line
|
|
@@ -62,6 +64,8 @@ export const MAX_NODES = 256;
|
|
|
62
64
|
export const MAX_ID_LENGTH = 256;
|
|
63
65
|
// Native bounds may sit this far outside a stored leaf box (reference px).
|
|
64
66
|
export const BOX_TOLERANCE = 3;
|
|
67
|
+
// A template region name (OPF 0.19), as a `region` pin or the region a block was drawn in.
|
|
68
|
+
const REGION_NAME = /^[a-z][a-z0-9-]{0,63}$/;
|
|
65
69
|
|
|
66
70
|
const kindOfField = field => field === 'items' || field === 'bullets' ? 'list' : field;
|
|
67
71
|
const isGroup = block => object(block) && (block.type === 'group' || (block.type === undefined && Array.isArray(block.blocks)));
|
|
@@ -240,6 +244,12 @@ export function contentTopology(slide, items, slideIndex, report = () => {}) {
|
|
|
240
244
|
if (lines) wrapped.set(item.path, wrapRecord(lines));
|
|
241
245
|
}
|
|
242
246
|
const base = `slides.${slideIndex}`;
|
|
247
|
+
// RR-81: the template region each root block was drawn in (core's items[].region).
|
|
248
|
+
const regionOf = new Map();
|
|
249
|
+
for (const item of items ?? []) {
|
|
250
|
+
const root = typeof item?.path === 'string' && typeof item.region === 'string' ? /^(slides\.\d+\.blocks\.\d+)(?:\.|$)/.exec(item.path)?.[1] : undefined;
|
|
251
|
+
if (root && REGION_NAME.test(item.region) && !regionOf.has(root)) regionOf.set(root, item.region);
|
|
252
|
+
}
|
|
243
253
|
let nodes = 0;
|
|
244
254
|
const fail = reason => { throw new TopologyError(reason); };
|
|
245
255
|
const leafBox = path => {
|
|
@@ -256,6 +266,8 @@ export function contentTopology(slide, items, slideIndex, report = () => {}) {
|
|
|
256
266
|
}
|
|
257
267
|
if (object(block.extensions)) node.ext = clone(block.extensions);
|
|
258
268
|
if (typeof block.type === 'string') node.typed = true;
|
|
269
|
+
if (typeof block.region === 'string' && REGION_NAME.test(block.region)) node.pin = block.region;
|
|
270
|
+
if (regionOf.has(path)) node.in = regionOf.get(path);
|
|
259
271
|
return node;
|
|
260
272
|
};
|
|
261
273
|
const node = (block, path, depth) => {
|
|
@@ -431,6 +443,7 @@ export function validateTopology(value) {
|
|
|
431
443
|
if (!validId(item.id)) throw Error('Invalid content node id.');
|
|
432
444
|
if (item.ext !== undefined && !object(item.ext)) throw Error('Invalid content node extensions.');
|
|
433
445
|
if (item.typed !== undefined && item.typed !== true) throw Error('Invalid content node type flag.');
|
|
446
|
+
if ((item.pin !== undefined && !REGION_NAME.test(item.pin ?? '')) || (item.in !== undefined && !REGION_NAME.test(item.in ?? ''))) throw Error('Invalid content node region.');
|
|
434
447
|
if (item.t === 'group') {
|
|
435
448
|
if (depth >= MAX_GROUP_DEPTH) throw Error(`Content groups nest deeper than ${MAX_GROUP_DEPTH} levels.`);
|
|
436
449
|
if (item.comp !== undefined && !object(item.comp)) throw Error('Invalid group composition.');
|
|
@@ -564,6 +577,7 @@ export function rebuildContent(topology, blocks, bounds, placed) {
|
|
|
564
577
|
if (leaf.node.bullets === true) bulletsKey(payload, leaf.node.typed);
|
|
565
578
|
if (leaf.node.id !== undefined) { payload.id = leaf.node.id; ids.push(leaf.node.id); }
|
|
566
579
|
if (leaf.node.ext !== undefined) payload.extensions = clone(leaf.node.ext);
|
|
580
|
+
if (leaf.node.pin !== undefined) payload.region = leaf.node.pin;
|
|
567
581
|
return payload;
|
|
568
582
|
};
|
|
569
583
|
const build = item => {
|
|
@@ -577,6 +591,7 @@ export function rebuildContent(topology, blocks, bounds, placed) {
|
|
|
577
591
|
if (item.id !== undefined) { group.id = item.id; ids.push(item.id); }
|
|
578
592
|
if (item.ext !== undefined) group.extensions = clone(item.ext);
|
|
579
593
|
if (item.comp !== undefined) group.composition = clone(item.comp);
|
|
594
|
+
if (item.pin !== undefined) group.region = item.pin;
|
|
580
595
|
group.blocks = children;
|
|
581
596
|
return group;
|
|
582
597
|
};
|
|
@@ -345,7 +345,7 @@ const SOURCE_PREFIX = /^(asset:|data:|https?:|file:|\.\.?\/)/i;
|
|
|
345
345
|
// Metadata fields that hold content references (OPF 0.15: `id` or `name:id`; for audience and purpose a string that is not
|
|
346
346
|
// a reference is free text). `language` is an engine vocabulary (a BCP-47 tag), not a catalog reference.
|
|
347
347
|
const METADATA_REFERENCES = Object.freeze(['narrative', 'tone', 'purpose', 'audience']);
|
|
348
|
-
const LAYOUT_SCHEMA = 'https://openpresentation.org/schema/opf-layout/
|
|
348
|
+
const LAYOUT_SCHEMA = 'https://openpresentation.org/schema/opf-layout/v2';
|
|
349
349
|
const GROUP_NAME = /^[a-z][a-z0-9-]*$/;
|
|
350
350
|
|
|
351
351
|
function collectStrings(value, into = new Set()) {
|
|
@@ -1328,7 +1328,7 @@ export function layoutIntent(storedCatalogs, slideRecords, entries, group, repor
|
|
|
1328
1328
|
if (structureMatch && problem === 'source') report({code: 'invalid-document-provenance', path: `slides.${index}.layoutRecord`, message: `slides.${index}.layoutRecord belongs to catalogs.${stated.group}${stated.source ? ` (${stated.source})` : ''}, which the document declares with another source, so the slide's stored layout record was not restored.`});
|
|
1329
1329
|
const target = layout === undefined ? undefined : resolved.get(layout);
|
|
1330
1330
|
let restoreLayout = structureMatch;
|
|
1331
|
-
if (structureMatch && layout !== undefined) {
|
|
1331
|
+
if (structureMatch && layout !== undefined && layout !== 'auto') {
|
|
1332
1332
|
if (!target) {
|
|
1333
1333
|
restoreLayout = false;
|
|
1334
1334
|
report({code: 'unresolved-reference', path: `slides.${index}.layout`, message: `Layout '${layout}' resolves neither in the catalogs the PPTX stores nor in a registered catalog, so slides.${index}.layout was not restored; the imported slide keeps its observed arrangement.`});
|
package/dist/index.d.ts
CHANGED
|
@@ -89,7 +89,9 @@ export interface ToPptxOptions {
|
|
|
89
89
|
* clustered column chart as fallback) for the constructs desktop PowerPoint confirmed natively on 2026-09-30 (treemap,
|
|
90
90
|
* histogram, pareto, box-and-whisker, waterfall, funnel) and keeps `world` on the clustered column chart with
|
|
91
91
|
* `chart-data-adapted` (`chartex-fallback`) because the regionMap part, though accepted with `Requires="cx4"`, draws
|
|
92
|
-
* nothing until PowerPoint fetches map data online.
|
|
92
|
+
* nothing until PowerPoint fetches map data online. Multi-series Pareto also uses editable clustered columns with all series and this diagnostic;
|
|
93
|
+
* explicit native mode rejects it with unsupported-chart-data before returning bytes. Single-series Pareto stays native.
|
|
94
|
+
* "native" writes supported chartex parts, the map included (reports
|
|
93
95
|
* `chart-map-geodata`). "fallback" writes clustered columns only
|
|
94
96
|
* (`chartex-fallback`, `histogram-binned`), as releases before FF-22b did. Import of chartex parts is always on.
|
|
95
97
|
*/
|
|
@@ -166,7 +168,7 @@ export interface ToPptxOptions {
|
|
|
166
168
|
* against a document group's `catalogs.<group>.source`, and the first is the host default that bare ids use when the document
|
|
167
169
|
* omits `catalogs.default`. Never fetched. Every reference (slide layouts, themes, colour and font schemes) resolves in the
|
|
168
170
|
* document's embedded records first, then here; omitted or `[]`, only embedded records resolve. Register the gallery snapshot
|
|
169
|
-
* explicitly with `catalogs: [
|
|
171
|
+
* explicitly with `catalogs: [gallery]` from `@openpresentation/gallery`.
|
|
170
172
|
*/
|
|
171
173
|
catalogs?: readonly Catalog[];
|
|
172
174
|
}
|
|
@@ -177,9 +179,9 @@ export interface FromPptxOptions {
|
|
|
177
179
|
fallbackName?: string;
|
|
178
180
|
schema?: string;
|
|
179
181
|
/**
|
|
180
|
-
* The catalogs the host registered (core `Catalog[]`, as in ToPptxOptions; never fetched). Theme and colour-scheme recovery
|
|
182
|
+
* The catalogs the host registered (core `Catalog[]`, as in ToPptxOptions; never fetched). Omitted catalogs select the default from installed `@openpresentation/gallery` only for foreign layout mapping; explicit `[]` opts out. Theme and colour-scheme recovery requires explicitly registered catalogs and
|
|
181
183
|
* (FF-24) matches the package's theme against the first one, the host default: an exact colour-scheme match imports as its
|
|
182
|
-
* bare id and a corroborated theme name as `design.theme`.
|
|
184
|
+
* bare id and a corroborated theme name as `design.theme`. With omitted catalogs or explicit `catalogs: []` the colour scheme imports as an inline object and
|
|
183
185
|
* no theme id is recovered. A stored slide layout reference also resolves here.
|
|
184
186
|
*/
|
|
185
187
|
catalogs?: readonly Catalog[];
|
|
@@ -410,7 +412,8 @@ export interface PptxOpfLink {
|
|
|
410
412
|
* "block" fed a block (`path`, `blockType`); "title", "subtitle" and "tag" fed that slide field. Other roles name a shape the
|
|
411
413
|
* importer consumed without a block of its own: "furniture" (footer, date, number, section), "background" (the picture of an image background),
|
|
412
414
|
* "image-overlay" (the overlay shape of an image background or image block), "watermark", "logo",
|
|
413
|
-
* and the members of an OPF-tagged group ("code", "metric", "quote", "timeline", "media", "card-frame")
|
|
415
|
+
* and the members of an OPF-tagged group ("code", "metric", "quote", "timeline", "media", "card-frame"), and "prompt" (an empty
|
|
416
|
+
* placeholder: the prompt of a slide PowerPoint added from a layout).
|
|
414
417
|
*/
|
|
415
418
|
role: string;
|
|
416
419
|
/** Path in the presentation, for example "slides.2.blocks.1", "slides.0.title", or "slides.1.left" after a tagged round trip. */
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,8 @@ import {INVALID_XML_CHARACTER, nativeSections, writeSectionList} from './section
|
|
|
32
32
|
import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
|
|
33
33
|
import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, nativePlaceholderForPart, writeNativeMasters} from './native-furniture.js';
|
|
34
34
|
import {liftMasterFurniture} from './master-furniture.js';
|
|
35
|
+
import {gallery} from '@openpresentation/gallery';
|
|
36
|
+
import {applyTemplateLayouts, deckTemplates, foreignLayoutReference, placeholderRegion, readLayoutTag, slideTemplate, writeTemplateLayouts} from './template-layouts.js';
|
|
35
37
|
import {importImageOrientation} from './image-import.js';
|
|
36
38
|
import {extractSignals, normalizeSignalOptions, themeFactsFor} from './import-signals.js';
|
|
37
39
|
import {placeImages, importImages, importImagePlaceholders, isImagePlaceholderGroup, imageName, imageOverlayName, imagePlaceholderGroupName, backgroundImageName, backgroundOverlayName, IMAGE_TREATMENT_KEYS, BACKGROUND_IMAGE_KEYS, IMAGE_PLACEHOLDER_TAG} from './image-provenance.js';
|
|
@@ -290,6 +292,10 @@ export async function toPptx(input, options = {}) {
|
|
|
290
292
|
context.nativeFurnitureBands = new Map();
|
|
291
293
|
// RR-72: each slide's furniture parts and the object names of their shapes, for the slide master and layouts.
|
|
292
294
|
context.furnitureParts = [];
|
|
295
|
+
// RR-81: the 0.19 template each slide uses (its native slide layout) and the objects bound to a region placeholder.
|
|
296
|
+
context.templateSlides = [];
|
|
297
|
+
context.automaticSlides = [];
|
|
298
|
+
context.placeholderBindings = new Map();
|
|
293
299
|
context.hostDate = options.date;
|
|
294
300
|
context.authored = authored;
|
|
295
301
|
context.codeTags = new Map();
|
|
@@ -345,6 +351,22 @@ export async function toPptx(input, options = {}) {
|
|
|
345
351
|
await addSlide(pptx, presentation, presentation.slides[index], index, context, options);
|
|
346
352
|
}
|
|
347
353
|
|
|
354
|
+
// RR-81: when a slide uses a template, every template the deck reaches gets its native layout (registered catalogs, then embedded).
|
|
355
|
+
// A template's placeholders are composed with the options of the first slide that uses it; a template no slide uses, with the
|
|
356
|
+
// options of a slide that would (the deck's header and footer, the template's own design) and no placed-image band.
|
|
357
|
+
const reachableTemplates = deckTemplates(presentation, options.catalogs, reference => resolveReference(presentation, 'layouts', reference, options.catalogs !== undefined ? {catalogs: options.catalogs} : {}));
|
|
358
|
+
if (context.templateSlides.some(Boolean) || reachableTemplates.length || context.automaticSlides.some(Boolean)) {
|
|
359
|
+
const plans = context.templateSlides.filter(Boolean), first = plans[0] ?? context.automaticSlides.find(Boolean) ?? {areaOptions: context.firstSlideOptions};
|
|
360
|
+
const reachable = reachableTemplates;
|
|
361
|
+
for (const plan of plans) if (!reachable.some(template => template.key === plan.template.key)) reachable.push(plan.template);
|
|
362
|
+
context.deckTemplates = reachable.map(template => {
|
|
363
|
+
const used = plans.find(plan => plan.template.key === template.key);
|
|
364
|
+
if (used) return {...template, areaOptions: used.areaOptions};
|
|
365
|
+
const core = coreContext({...presentation, slides: [{layout: template.reference}]}, 0, options).options;
|
|
366
|
+
return {...template, areaOptions: {...core, fontFamilies: first.areaOptions.fontFamilies, textRasterPadding: options.textRasterPadding, placements: []}};
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
348
370
|
// FF-08: pitchFamily per exported family, from each slide's resolved scheme, then the font schemes the document embeds
|
|
349
371
|
// and the registered catalogs its groups name (core catalogRecords).
|
|
350
372
|
context.fontPitch = fontPitchFamilies(presentation.slides.map((slide, index) => exportSlideContext(presentation, index, context, options).fonts),
|
|
@@ -429,11 +451,26 @@ export async function fromPptx(input, options = {}) {
|
|
|
429
451
|
return inheritedParts.get(path);
|
|
430
452
|
};
|
|
431
453
|
const relatedPath = (path, type) => path ? [...cachedRelationships(path).values()].find(rel => rel.type === `http://schemas.openxmlformats.org/officeDocument/2006/relationships/${type}` && rel.targetMode !== 'External' && entries[rel.path])?.path : undefined;
|
|
454
|
+
// RR-81: the OPF_LAYOUT_V1 tag of each slide's layout (a 0.19 template layout), read once per layout part.
|
|
455
|
+
const layoutTags = new Map();
|
|
456
|
+
const layoutTagOf = (layoutPath, index) => {
|
|
457
|
+
if (!layoutPath) return undefined;
|
|
458
|
+
if (!layoutTags.has(layoutPath)) layoutTags.set(layoutPath, readLayoutTag(entries, layoutPath, message => options.onDiagnostic?.({code: 'invalid-layout-provenance', path: `slides.${index}.layout`, message})));
|
|
459
|
+
return layoutTags.get(layoutPath);
|
|
460
|
+
};
|
|
432
461
|
const furnitureContexts = slidePaths.map((slidePath, index) => {
|
|
433
462
|
const root = parseRequiredXml(entries, slidePath)['p:sld'];
|
|
434
463
|
if (!root) throw new OPFPptxError('invalid-pptx', `PPTX slide is not a PresentationML slide: ${slidePath}.`, {path: slidePath});
|
|
435
464
|
const tree = root['p:cSld']?.['p:spTree'], relationships = parseRelationships(entries, slidePath);
|
|
436
|
-
|
|
465
|
+
const layoutPath = relatedPath(slidePath, 'slideLayout'), layoutTag = layoutTagOf(layoutPath, index);
|
|
466
|
+
return {root, relationships, shapes: nativeTextShapes(tree), pictures: nativePictures(tree), layoutTag, foreignLayout: layoutTag ? undefined : foreignLayoutReference(cachedPart(layoutPath)?.['p:sldLayout'], options.catalogs),
|
|
467
|
+
// RR-81: a region placeholder typed into in PowerPoint has no xfrm of its own; it sits where the template layout puts it.
|
|
468
|
+
layoutBounds: layoutTag ? shape => {
|
|
469
|
+
const ph = shape?.['p:nvSpPr']?.['p:nvPr']?.['p:ph'];
|
|
470
|
+
if (ph?.idx === undefined) return undefined;
|
|
471
|
+
const match = asArray(cachedPart(layoutPath)?.['p:sldLayout']?.['p:cSld']?.['p:spTree']?.['p:sp']).find(candidate => candidate?.['p:nvSpPr']?.['p:nvPr']?.['p:ph']?.idx === ph.idx);
|
|
472
|
+
return match ? shapeBounds(match['p:spPr']?.['a:xfrm']) ?? undefined : undefined;
|
|
473
|
+
} : undefined,
|
|
437
474
|
// RR-11: native footer placeholders resolve their position through the layout and master.
|
|
438
475
|
path: slidePath, slideWidth: Number(presentationRoot['p:sldSz']?.cx), relationshipsOf: cachedRelationships, readPart: cachedPart,
|
|
439
476
|
paragraphs: nativeShapeParagraphs(decodeText(entries[slidePath])),
|
|
@@ -512,6 +549,28 @@ export async function fromPptx(input, options = {}) {
|
|
|
512
549
|
report({code: "invalid-document-provenance", path: "", message: `${errorMessage(error)} Ordinary import keeps the values observed in the PPTX.`});
|
|
513
550
|
}
|
|
514
551
|
if (slideProvenance.length !== imported.slides.length) throw new OPFPptxError("invalid-import-opf", "Slide provenance does not match the imported slides.");
|
|
552
|
+
// RR-81: a slide on a 0.19 template layout takes that layout's reference (a slide added in PowerPoint, Change Layout), and a
|
|
553
|
+
// block read from a region placeholder keeps its region with a pin where plain binding would move it (src/template-layouts.js).
|
|
554
|
+
// Every tagged layout of the package, used or not: an embedded template no slide uses comes back from its tag.
|
|
555
|
+
const packageLayoutTags = Object.keys(entries).filter(path => /^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(path)).sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]))
|
|
556
|
+
.map(path => layoutTagOf(path, 0)).filter(Boolean);
|
|
557
|
+
if (packageLayoutTags.length) {
|
|
558
|
+
const restoredContent = new Set(restoredGroups.filter(group => group.applied && group.contentPaths).map(group => group.contentPaths.slide));
|
|
559
|
+
applyTemplateLayouts(imported, furnitureContexts.map((context, index) => ({tag: context.layoutTag, blockRegions: context.blockRegions, restored: restoredContent.has(index), preservedAutomatic: slideProvenance[index]?.structure !== 'untagged', storedLayout: slideProvenance[index]?.layout})),
|
|
560
|
+
index => resolveCoreSlideContext(imported, index, options.catalogs !== undefined ? {catalogs: options.catalogs} : {}).resolved.layout, report, packageLayoutTags);
|
|
561
|
+
}
|
|
562
|
+
for (const [index, context] of furnitureContexts.entries()) {
|
|
563
|
+
if (!context.layoutTag && slideProvenance[index]?.structure === 'untagged' && context.foreignLayout !== undefined && imported.slides[index].layout === undefined)
|
|
564
|
+
{
|
|
565
|
+
if (context.foreignLayout.startsWith('default:')) {
|
|
566
|
+
const found = resolveReference({}, 'layouts', context.foreignLayout, {catalogs: options.catalogs ?? [gallery]});
|
|
567
|
+
const existing = imported.catalogs?.default;
|
|
568
|
+
if (existing === false || (existing?.source !== undefined && existing.source !== found?.source)) continue;
|
|
569
|
+
if (found?.source) imported.catalogs = {...imported.catalogs, default: {...imported.catalogs?.default, source: found.source}};
|
|
570
|
+
}
|
|
571
|
+
imported.slides[index].layout = context.foreignLayout;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
515
574
|
// RR-34: marker runs become cite/footnote on the runs before them, and the references list is rebuilt
|
|
516
575
|
// from the stored record and the footnote boxes (an edited note keeps its edited text).
|
|
517
576
|
restoreCitations(imported, furnitureContexts.map(context => context.annotations?.notes ?? []), report);
|
|
@@ -824,6 +883,18 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
824
883
|
.map((item) => ({payload: payloadFromSlideItem(item, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`})), bounds: item.visualBounds ?? item.bounds, sources: item.sources ?? []}))
|
|
825
884
|
.filter((entry) => entry.payload);
|
|
826
885
|
const blocks = content.map((entry) => entry.payload);
|
|
886
|
+
// RR-81: the region of each block read from one region placeholder of the slide's 0.19 template layout (its p:ph idx).
|
|
887
|
+
if (nativeContext.layoutTag) {
|
|
888
|
+
const frames = asArray(slideRoot['p:cSld']?.['p:spTree']?.['p:graphicFrame']);
|
|
889
|
+
const nodeOf = source => {
|
|
890
|
+
const [kind, position] = source.split(':');
|
|
891
|
+
return kind === 'sp' ? nativeContext.shapes[position] : kind === 'pic' ? nativeContext.pictures[position] : kind === 'frame' ? frames[position] : undefined;
|
|
892
|
+
};
|
|
893
|
+
nativeContext.blockRegions = content.map(entry => {
|
|
894
|
+
const regions = new Set(entry.sources.map(source => placeholderRegion(nodeOf(source), nativeContext.layoutTag)));
|
|
895
|
+
return regions.size === 1 ? [...regions][0] : undefined;
|
|
896
|
+
});
|
|
897
|
+
}
|
|
827
898
|
// RR-34: a tagged caption re-attaches to the block whose native media shape it names (never by position).
|
|
828
899
|
const annotations = nativeContext.annotations;
|
|
829
900
|
if (annotations?.captions.size) {
|
|
@@ -932,9 +1003,13 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
932
1003
|
if (furniture.text.has(index) || nativeContext.imageShapes?.has(index) || nativeContext.watermarkShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index) || placeholders.consumed.has(index)) continue;
|
|
933
1004
|
if (annotations.consumed.has(shape)) { roles.set(shapeKeys.get(shape), 'annotation'); continue; }
|
|
934
1005
|
if (code.consumed.has(shape)||metric.consumed.has(shape)||cards.has(shape)||media.consumed.has(shape)||headings.consumed.has(shape)||plainText.consumed.has(shape)||timelines.consumed.has(shape)||quotes.consumed.has(shape)) continue;
|
|
1006
|
+
// RR-81: a placeholder with no text (no runs, or only empty paragraphs) is PowerPoint's prompt ("Click to add text" on a slide
|
|
1007
|
+
// added from a layout), not content. A picture, chart or table in a placeholder is a p:pic or p:graphicFrame, read below.
|
|
1008
|
+
if (shape['p:nvSpPr']?.['p:nvPr']?.['p:ph'] !== undefined && !(paragraphs[index] ?? []).some(paragraph => paragraph.text)) { roles.set(shapeKeys.get(shape), 'prompt'); continue; }
|
|
935
1009
|
const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
|
|
936
1010
|
const item = importShape(shape, dimensions, paragraphs[index], ordinaryBody || furniture.taggedText.has(index) || media.captionShapes.has(shape));
|
|
937
1011
|
if (item && ordinaryBody) item.readNativeBody = () => readBody(index);
|
|
1012
|
+
if (item && !item.bounds && nativeContext.layoutBounds) item.bounds = nativeContext.layoutBounds(shape) ?? item.bounds;
|
|
938
1013
|
// A damaged/edited furniture group falls back to current native text,
|
|
939
1014
|
// including cleared text boxes, without inventing a title or shape label.
|
|
940
1015
|
if (item && (furniture.taggedText.has(index) || media.captionShapes.has(shape))) item.sourceText = true;
|
|
@@ -2002,6 +2077,17 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
|
|
|
2002
2077
|
// Core resolves every shared design key once (slide design, deck design, then the layout record's design): the text alignment of
|
|
2003
2078
|
// each item (item.alignment), and each image item's own fit and treatment (item.image).
|
|
2004
2079
|
const geometry = composeSlide(opfSlide, composeOptions);
|
|
2080
|
+
if (slideIndex === 0) context.firstSlideOptions = composeOptions;
|
|
2081
|
+
// RR-81: a list core broke into columns (listColumns) is drawn as one measured list whose lines sit in their columns: each line
|
|
2082
|
+
// is its own shape at core's geometry, as in every list, and line names, numbering and provenance run across the columns.
|
|
2083
|
+
// Core's item.text is the whole list (every entry at its column's position); the lines are taken from listColumns, which replace
|
|
2084
|
+
// the entries rather than add to them, so each entry is drawn once.
|
|
2085
|
+
for (const item of geometry.items) if (item.listColumns?.length > 1 && item.text?.listEntries) item.text = {...item.text, listEntries: item.listColumns.flatMap(column => column.text?.listEntries ?? [])};
|
|
2086
|
+
// RR-81: a slide on a 0.19 template uses that template's native slide layout (src/template-layouts.js), whose placeholders
|
|
2087
|
+
// core composes with this slide's options (canvas, the deck's header and footer, direction) and its placed-image bands.
|
|
2088
|
+
const template = slideTemplate(slideContext.core);
|
|
2089
|
+
if (template) context.templateSlides[slideIndex] = {template, areaOptions: {...composeOptions, placements: placedImageBands(opfSlide)}};
|
|
2090
|
+
else if (opfSlide.layout === undefined || opfSlide.layout === 'auto') context.automaticSlides[slideIndex] = {areaOptions: composeOptions, title: geometry.items.find(item => item.field === 'title')};
|
|
2005
2091
|
// The default footer band (date left, text center, number right): where a footer added natively lands (RR-11).
|
|
2006
2092
|
if (slideIndex === 0) context.defaultFooterOptions = composeOptions;
|
|
2007
2093
|
slideContext.zoneBand = zoneBands(composeOptions);
|
|
@@ -2035,6 +2121,13 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
|
|
|
2035
2121
|
// Cover and section slides: the deck logo core composed at the top-left of the free area, after the watermark and before content.
|
|
2036
2122
|
if (geometry.logo) await addLogo(slide, presentation, geometry.logo, slideIndex, slideContext, context, options);
|
|
2037
2123
|
for (const item of geometry.items) {
|
|
2124
|
+
// RR-81: the lone picture, chart or table of a `none` region binds to that region's placeholder on the slide layout. A captioned
|
|
2125
|
+
// object or one drawn on a card stays a free shape: Change Layout would move it away from its caption or card.
|
|
2126
|
+
const boundRegion = template && item.region !== undefined && ['image', 'chart', 'table'].includes(item.field) && !item.caption && !item.frameBox
|
|
2127
|
+
? geometry.regions?.find(region => region.name === item.region && region.flow === 'none' && region.content?.length === 1 && !region.overflow?.length) : undefined;
|
|
2128
|
+
if ((template || context.automaticSlides[slideIndex]) && item.field === 'title' && typeof item.value === 'string' && item.text?.lines?.length === 1 && !item.text.overflow && !item.text.richLines)
|
|
2129
|
+
context.placeholderBindings.set(`OPF heading ${item.path} line 0`, {slide: slideIndex, region: 'Title', object: 'title'});
|
|
2130
|
+
const drawnBefore = boundRegion && {images: context.images.size, placeholders: context.imagePlaceholders.size, charts: context.chartHeadings.size, tables: context.tableHeaders.size};
|
|
2038
2131
|
// Card text sits on a literal card fill, not the slide background: keep it literal.
|
|
2039
2132
|
const itemContext = item.frameBox ? {...slideContext, textColor: slideContext.colors.text, mutedColor: slideContext.colors.mutedText} : slideContext;
|
|
2040
2133
|
const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
|
|
@@ -2091,6 +2184,12 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
|
|
|
2091
2184
|
addCaption(slide, item, mediaName, itemContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: item.caption.path}));
|
|
2092
2185
|
}
|
|
2093
2186
|
}
|
|
2187
|
+
if (drawnBefore) {
|
|
2188
|
+
const added = (map, size) => [...map.keys()].slice(size);
|
|
2189
|
+
const names = item.field === 'image' ? (context.imagePlaceholders.size === drawnBefore.placeholders ? added(context.images, drawnBefore.images) : [])
|
|
2190
|
+
: item.field === 'chart' ? added(context.chartHeadings, drawnBefore.charts).filter(name => !context.chartex.has(name)) : added(context.tableHeaders, drawnBefore.tables);
|
|
2191
|
+
if (names.length === 1) context.placeholderBindings.set(names[0], {slide: slideIndex, region: boundRegion.name, object: item.field === 'image' ? 'picture' : item.field});
|
|
2192
|
+
}
|
|
2094
2193
|
}
|
|
2095
2194
|
// RR-34: the footnote area core reserved above the footer band (rule plus one tagged text box per listed line).
|
|
2096
2195
|
addFootnotes(slide, geometry.footnotes, slideIndex, slideContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: `slides.${slideIndex}`}));
|
|
@@ -2106,6 +2205,13 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
|
|
|
2106
2205
|
}
|
|
2107
2206
|
}
|
|
2108
2207
|
|
|
2208
|
+
// RR-81: the placed-image bands of a slide (its image blocks with a `placement`), which a template layout reserves as the slide does.
|
|
2209
|
+
function placedImageBands(slide) {
|
|
2210
|
+
return (Array.isArray(slide?.blocks) ? slide.blocks : [])
|
|
2211
|
+
.filter(block => isPlainObject(block) && isPlainObject(block.placement) && typeof block.placement.edge === 'string' && (block.image !== undefined || block.type === 'image'))
|
|
2212
|
+
.map(({placement}) => ({edge: placement.edge, ...(placement.size !== undefined ? {size: placement.size} : {}), ...(placement.inset !== undefined ? {inset: placement.inset} : {})}));
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2109
2215
|
// One slide's export context: core's resolveSlideContext (`core`: composeSlide's options, the records and the diagnostics) and
|
|
2110
2216
|
// the exporter's colours and measurement-resolved families derived from it.
|
|
2111
2217
|
function exportSlideContext(presentation, slideIndex, baseContext, options) {
|
|
@@ -2536,7 +2642,7 @@ function applyChartTextSize(xml, size) {
|
|
|
2536
2642
|
}
|
|
2537
2643
|
|
|
2538
2644
|
function addChartPayload(slide, chart, region, context, options = {}, path = "chart", presentation) {
|
|
2539
|
-
const chartData = toPptxChartData(chart, context.chartexMode, presentation);
|
|
2645
|
+
const chartData = toPptxChartData(chart, context.chartexMode, presentation, path);
|
|
2540
2646
|
if (!chartData.series) {
|
|
2541
2647
|
// Never lose a chart silently: the placeholder frame stands in for it, and a diagnostic names the reason.
|
|
2542
2648
|
options.onDiagnostic?.({code: "chart-data-unplottable", path, message: chartData.message, reason: chartData.reason});
|
|
@@ -3454,7 +3560,7 @@ function textRuns(value, context, fallbackFontSize) {
|
|
|
3454
3560
|
* goes through core's strict `chartNumber` (a string that is not a plain decimal is a gap, reported once per chart as
|
|
3455
3561
|
* `chart-value-not-numeric`). `formats` holds the Excel codes of the columns' number formats per exported series.
|
|
3456
3562
|
*/
|
|
3457
|
-
function toPptxChartData(chart, chartexMode = 'auto', presentation) {
|
|
3563
|
+
function toPptxChartData(chart, chartexMode = 'auto', presentation, path = 'chart') {
|
|
3458
3564
|
const unplottable = (reason, summary, message) => ({reason, summary, message: `${message} No native chart was exported; a placeholder frame stands in for it.`});
|
|
3459
3565
|
// A dataset reference is inlined from the document first (composition normally hands over the inline copy already).
|
|
3460
3566
|
const source = isDatasetRef(chart?.data) ? inlineChartData(chart, presentation) : chart;
|
|
@@ -3473,19 +3579,25 @@ function toPptxChartData(chart, chartexMode = 'auto', presentation) {
|
|
|
3473
3579
|
const columnCodes = (resolvedData.formats ?? []).map(excelCode);
|
|
3474
3580
|
const diagnostics = resolvedData.diagnostics.filter((entry) => entry.severity === 'warning' && entry.code !== 'chart-value-not-numeric');
|
|
3475
3581
|
const rejected = resolvedData.diagnostics.filter((entry) => entry.code === 'chart-value-not-numeric');
|
|
3476
|
-
const resolved = resolveChartType(chart.type)
|
|
3582
|
+
const {id: chartId, spec: resolved} = resolveChartType(chart.type);
|
|
3477
3583
|
// A chartex type (treemap, histogram, pareto, box & whisker, waterfall, funnel, map) has no classic construct. Natively
|
|
3478
3584
|
// it is written as its cx:chartSpace part and the clustered column chart of the same data is its mc:Fallback
|
|
3479
3585
|
// (src/chartex.js). 'auto' (the default) does that for the constructs the native PowerPoint check confirmed and keeps
|
|
3480
3586
|
// the unconfirmed map on the clustered column chart alone; 'fallback' keeps every chartex id there. The fallback is
|
|
3481
3587
|
// reported as chartex-fallback, never silent.
|
|
3482
|
-
const
|
|
3588
|
+
const multiSeriesPareto = chartId === 'pareto' && data.columns.length > 2;
|
|
3589
|
+
if (multiSeriesPareto && chartexMode === 'native') {
|
|
3590
|
+
throw new OPFPptxError('unsupported-chart-data', 'Native Pareto supports one value series. Use chartex auto or fallback to export every series as an editable clustered column chart.', {path, series: data.columns.slice(1)});
|
|
3591
|
+
}
|
|
3592
|
+
const native = chartexMode === 'native' || (chartexMode === 'auto' && resolved.family === 'chartex' && !resolved.unconfirmed && !multiSeriesPareto);
|
|
3483
3593
|
const chartex = resolved.family === 'chartex' && native ? resolved : null;
|
|
3484
3594
|
const spec = resolved.family === 'chartex' ? CHARTEX_FALLBACK : resolved;
|
|
3485
3595
|
const typeName = stringifyText(chart.type);
|
|
3486
3596
|
const adaptations = [];
|
|
3487
3597
|
if (resolved.family === 'chartex' && !native) {
|
|
3488
|
-
const reason = chartexMode === '
|
|
3598
|
+
const reason = multiSeriesPareto && chartexMode === 'auto'
|
|
3599
|
+
? 'that cannot represent multiple value series natively; all authored series are preserved in the fallback'
|
|
3600
|
+
: chartexMode === 'fallback'
|
|
3489
3601
|
? 'that this exporter does not write'
|
|
3490
3602
|
: 'that PowerPoint has not yet accepted natively from this exporter (pass chartex: \'native\' to write its chartex part)';
|
|
3491
3603
|
adaptations.push({adaptation: "chartex-fallback", message: `The '${typeName}' chart is a PowerPoint extension (chartex) chart ${reason}; its data is exported as a native clustered column chart instead.`});
|
|
@@ -4059,6 +4171,16 @@ async function normalizePptxZip(raw, context) {
|
|
|
4059
4171
|
}
|
|
4060
4172
|
reportPerSlideNotesScriptFonts(context.scriptFonts, slideThemes);
|
|
4061
4173
|
}
|
|
4174
|
+
// RR-81: one native slide layout per 0.19 template the slides use, under each slide's master, and the objects bound to its
|
|
4175
|
+
// placeholders (src/template-layouts.js). Before RR-72, which copies a slide's own layout for a furniture variant.
|
|
4176
|
+
if (context.templateSlides.some(Boolean) || context.deckTemplates?.length || context.automaticSlides.some(Boolean)) {
|
|
4177
|
+
try {
|
|
4178
|
+
writeTemplateLayouts(output, context.templateSlides, context.deckTemplates ?? [], context.placeholderBindings,
|
|
4179
|
+
(path, xml) => context.scriptFonts ? partScriptFonts(path, xml, context.scriptFonts, 0) : xml, {records: context.provenanceMode === 'full', automaticSlides: context.automaticSlides});
|
|
4180
|
+
} catch (error) {
|
|
4181
|
+
throw new OPFPptxError('packaging-failed', 'Native slide layouts for the layout templates could not be written.', {cause: errorMessage(error)});
|
|
4182
|
+
}
|
|
4183
|
+
}
|
|
4062
4184
|
// RR-72: furniture drawn the same on several slides moves to the slide master or a layout (src/master-furniture.js).
|
|
4063
4185
|
try {
|
|
4064
4186
|
liftMasterFurniture(output, context.furnitureParts, diagnostic => context.reportDiagnostic?.(diagnostic));
|
package/dist/master-furniture.js
CHANGED
|
@@ -157,7 +157,7 @@ export function liftMasterFurniture(output, slides, report = () => {}) {
|
|
|
157
157
|
const showsMaster = plan.candidates.size ? common.every(slot => free(plan, slot)) : !common.length;
|
|
158
158
|
const extras = [...plan.candidates.keys()].filter(slot => free(plan, slot) && (showsMaster ? !common.includes(slot) && counts.get(id(slot, value(plan, slot))) >= 2
|
|
159
159
|
: common.includes(slot) || counts.get(id(slot, value(plan, slot))) >= 2)).sort();
|
|
160
|
-
const key = `${showsMaster ? 1 : 0}\u0001${extras.map(slot => id(slot, value(plan, slot))).join('\u0001')}`;
|
|
160
|
+
const key = `${plan.layout}\u0002${showsMaster ? 1 : 0}\u0001${extras.map(slot => id(slot, value(plan, slot))).join('\u0001')}`;
|
|
161
161
|
plan.extras = extras;
|
|
162
162
|
plan.lifted = new Map([...(showsMaster ? common.map(slot => [slot, 'master']) : []), ...extras.map(slot => [slot, 'layout'])]);
|
|
163
163
|
plan.layoutKey = showsMaster && !extras.length ? undefined : key;
|
|
@@ -228,20 +228,38 @@ export function liftMasterFurniture(output, slides, report = () => {}) {
|
|
|
228
228
|
for (const {master, group, common, layouts} of assignments) {
|
|
229
229
|
const showing = group.find(plan => common.every(slot => free(plan, slot)) && plan.candidates.size);
|
|
230
230
|
for (const slot of common) transplant(master, showing, slot);
|
|
231
|
-
// The layout
|
|
232
|
-
|
|
231
|
+
// The layout a slide uses now, copied once per layout key: the master's one layout, or the slide's 0.19 template layout (RR-81),
|
|
232
|
+
// whose variant keeps its placeholders and its OPF_LAYOUT_V1 tag (`Pillars (no furniture)`).
|
|
233
233
|
let masterXml = partXml(master), masterRels = partRels(master);
|
|
234
234
|
let number = 0;
|
|
235
|
+
const templateNumbers = new Map();
|
|
235
236
|
for (const layout of layouts.values()) {
|
|
237
|
+
const base = layout.source.layout;
|
|
236
238
|
const path = `ppt/slideLayouts/slideLayout${nextLayout++}.xml`;
|
|
237
|
-
const
|
|
239
|
+
const baseRels = has(relsOf(base)) ? read(relsOf(base)) : '';
|
|
240
|
+
const tagRels = relationships(baseRels).filter(rel => rel.type === `${REL}/tags` && !rel.external);
|
|
241
|
+
const baseName = unescapeAttribute(read(base).match(/<p:cSld\b[^>]*\bname="([^"]*)"/)?.[1] ?? '');
|
|
242
|
+
let name;
|
|
243
|
+
if (tagRels.length) {
|
|
244
|
+
const count = (templateNumbers.get(base) ?? 0) + (layout.source.extras.length ? 1 : 0);
|
|
245
|
+
templateNumbers.set(base, count);
|
|
246
|
+
name = layout.source.extras.length ? `${baseName} (furniture ${count})` : `${baseName} (no furniture)`;
|
|
247
|
+
} else name = layout.source.extras.length ? `OPF furniture ${++number}` : 'OPF no furniture';
|
|
238
248
|
let xml = read(base).replace(/(<p:cSld\b[^>]*?\bname=")[^"]*(")/, `$1${escapeAttribute(name)}$2`);
|
|
239
249
|
if (!layout.showsMaster) xml = xml.replace(/<p:sldLayout\b[^>]*>/, open => `${open.slice(0, -1).replace(/\s+showMasterSp="[^"]*"/, '')} showMasterSp="0">`);
|
|
240
|
-
|
|
241
|
-
// A copied layout keeps only its master relationship; the furniture brings its own.
|
|
250
|
+
// A copied layout keeps only its master relationship and its own tags (a copy of each tag part); the furniture brings its own.
|
|
242
251
|
const masterRel = relationships(baseRels).find(rel => rel.type === `${REL}/slideMaster`);
|
|
243
252
|
if (!masterRel) throw new Error(`${base} has no slide master relationship.`);
|
|
244
|
-
|
|
253
|
+
let copiedRels = masterRel.node;
|
|
254
|
+
for (const rel of tagRels) {
|
|
255
|
+
const source = resolvePart(base, rel.target);
|
|
256
|
+
let n = 0, copy;
|
|
257
|
+
do copy = `ppt/tags/opfLayoutVariant${++n}.xml`; while (has(copy));
|
|
258
|
+
output[copy] = [output[source][0], options];
|
|
259
|
+
types = types.replace('</Types>', `<Override PartName="/${copy}" ContentType="${TAGS_TYPE}"/></Types>`);
|
|
260
|
+
copiedRels += `<Relationship Id="${rel.id}" Type="${REL}/tags" Target="${escapeAttribute(relativeTarget(path, copy))}"/>`;
|
|
261
|
+
}
|
|
262
|
+
stage(path, xml, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${copiedRels}</Relationships>`);
|
|
245
263
|
types = types.replace('</Types>', `<Override PartName="/${path}" ContentType="${LAYOUT_TYPE}"/></Types>`);
|
|
246
264
|
for (const slot of layout.source.extras) transplant(path, layout.source, slot);
|
|
247
265
|
const result = relationshipId(masterRels, `${REL}/slideLayout`, relativeTarget(master, path), false);
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
// RR-81: OPF 0.19 layout templates as native PowerPoint slide layouts (core docs/programs/release-readiness/0.19-layouts.md,
|
|
2
|
+
// section 8).
|
|
3
|
+
//
|
|
4
|
+
// Export. When a slide of the deck uses a template, every template the deck can reach becomes one `p:sldLayout` under each
|
|
5
|
+
// slide master (one per theme and script profile), so Change Layout reaches all of them (design question 7): the templates of
|
|
6
|
+
// the registered catalogs in catalog order, then the templates the document embeds (`custom` first, then the other groups by
|
|
7
|
+
// name), each by id. Automatic slides also get an OPF auto titleOnly layout. Named by the record's `name`, with one placeholder per area of the empty layout that core's
|
|
8
|
+
// `composeLayoutAreas` composes at the deck size (nothing collapsed): `title` (`ctrTitle` with a `subTitle` below it on
|
|
9
|
+
// `cover` and `section`), `subTitle` for a `subtitle` area, and per body region a placeholder typed by what it accepts
|
|
10
|
+
// (`placeholderType`). The layout keeps RR-11's date, footer and slide-number placeholders and `p:hf` (it starts as a copy of
|
|
11
|
+
// the layout the slide used), and carries an OPF_LAYOUT_V1 tag in its common slide data: the layout's id, group, source, the
|
|
12
|
+
// reference its first slide wrote, a hash of the record and the placeholder index of every region. Slides relate to it. A
|
|
13
|
+
// slide whose areas collapse still uses the full layout: its own geometry is explicit.
|
|
14
|
+
//
|
|
15
|
+
// A `none` region that holds one picture, chart or table binds that object to its placeholder (`p:ph idx` at the composed
|
|
16
|
+
// geometry), so PowerPoint's Reset, Change Picture and Change Layout move it. Blocks of flowing regions, text blocks and
|
|
17
|
+
// everything else stay editable shapes at core's geometry (the slide's OPF_SLIDE_V1 content topology names the region of
|
|
18
|
+
// each root block); the slide does not instantiate those placeholders, so a filled slide shows no prompt.
|
|
19
|
+
//
|
|
20
|
+
// Accepted one-line plain titles bind to the native title placeholder; long and rich titles remain tagged line shapes.
|
|
21
|
+
// Automatic slides use OPF auto (titleOnly), independently of registered catalogs. Layout prompts carry explicit OPF typography.
|
|
22
|
+
//
|
|
23
|
+
// Import. Foreign layouts map by type or a case-insensitive built-in name when v2 built-ins are registered. A slide whose layout carries OPF_LAYOUT_V1 takes that layout's reference when it has none (a slide added in
|
|
24
|
+
// PowerPoint, or an export without provenance) or when its stored layout names another layout (Change Layout:
|
|
25
|
+
// `layout-changed`). A block read from a placeholder of a region gets a `region` pin when plain binding would put it
|
|
26
|
+
// elsewhere; content that document provenance restored is never pinned.
|
|
27
|
+
|
|
28
|
+
import {composeLayoutAreas, composeSlide, layoutTemplate, bindRegions, regionAccepts} from '@openpresentation/opf/composition';
|
|
29
|
+
import {validateCatalogRecord, catalogRecords, resolveReference} from '@openpresentation/opf';
|
|
30
|
+
import {gallery} from '@openpresentation/gallery';
|
|
31
|
+
import {decodeTextTag, encodeTextTag} from './code-provenance.js';
|
|
32
|
+
|
|
33
|
+
export const LAYOUT_TAG = 'OPF_LAYOUT_V1';
|
|
34
|
+
const dec = new TextDecoder('utf-8', {fatal: true}), enc = new TextEncoder();
|
|
35
|
+
const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
|
36
|
+
const NS = 'http://schemas.openxmlformats.org/presentationml/2006/main';
|
|
37
|
+
const LAYOUT_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml';
|
|
38
|
+
const TAGS_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.tags+xml';
|
|
39
|
+
const EMU = 9525;
|
|
40
|
+
// Region placeholder indexes (coordinator decisions on RR-81): a base per placeholder kind plus the region's position among the
|
|
41
|
+
// layout's regions of that kind, in the record's `regions` order: `obj` 100, `body` 300, `pic` 500, `chart` 700, `tbl` 900,
|
|
42
|
+
// `media` 1100 (so the first chart region of every layout is 700, its second 701). PowerPoint's Change Layout pairs a slide's
|
|
43
|
+
// placeholders with the new layout's by `idx`, so only regions of one kind pair (Pillars to Text keeps `obj` with `obj`; a chart
|
|
44
|
+
// bound to Chart beside's `chart` region never lands in Table beside's `tbl` region, as it did with one sequence for every kind).
|
|
45
|
+
// The indexes stay clear of PowerPoint's subtitle (1) and date, footer and slide-number placeholders (10, 11, 12, RR-11), and
|
|
46
|
+
// Each kind reserves 200 indexes so a 144-region custom grid cannot collide across kinds. They
|
|
47
|
+
// depend on the record only, so they are the same under every slide master.
|
|
48
|
+
export const REGION_IDX_BASE = Object.freeze({obj: 100, body: 300, pic: 500, chart: 700, tbl: 900, media: 1100});
|
|
49
|
+
export const SUBTITLE_IDX = 1;
|
|
50
|
+
const NAME = /^[a-z][a-z0-9-]*$/;
|
|
51
|
+
const MAX_REGIONS = 144;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The PowerPoint layout type of a built-in id (design section 8); every other template is `cust`. `statement` is `cust` too
|
|
55
|
+
* (coordinator decision on RR-81): `titleOnly` would contradict its message placeholder.
|
|
56
|
+
*/
|
|
57
|
+
export const POWERPOINT_LAYOUT_TYPES = Object.freeze({
|
|
58
|
+
cover: 'title', section: 'secHead', text: 'obj', list: 'obj', agenda: 'obj', faq: 'obj',
|
|
59
|
+
'two-column': 'twoObj', comparison: 'twoObj', 'image-beside': 'picTx',
|
|
60
|
+
});
|
|
61
|
+
const CENTERED_TITLE = new Set(['cover', 'section']);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The placeholder type of a body region: `pic` when it accepts only pictures (or is a `media` region whose leaf kinds are
|
|
65
|
+
* pictures and video), `chart` when only charts, `tbl` when only tables, `media` when only video, `body` when only text and
|
|
66
|
+
* lists, otherwise a content placeholder, `obj` (written without a type, as PowerPoint does).
|
|
67
|
+
*/
|
|
68
|
+
export function placeholderType(region) {
|
|
69
|
+
const accepts = new Set(region.accepts);
|
|
70
|
+
const only = (...kinds) => [...accepts].every(kind => kinds.includes(kind));
|
|
71
|
+
if (only('image')) return 'pic';
|
|
72
|
+
if (only('chart')) return 'chart';
|
|
73
|
+
if (only('table')) return 'tbl';
|
|
74
|
+
if (only('video')) return 'media';
|
|
75
|
+
if (region.role === 'media' && accepts.has('image') && only('image', 'video', 'group')) return 'pic';
|
|
76
|
+
if (only('text', 'list')) return 'body';
|
|
77
|
+
return 'obj';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Which slide objects a placeholder of each type takes (binding a `none` region's lone object). */
|
|
81
|
+
const BINDS = {title: ['title'], ctrTitle: ['title'], pic: ['picture'], chart: ['chart'], tbl: ['table'], obj: ['picture', 'chart', 'table']};
|
|
82
|
+
export const bindsObject = (type, object) => (BINDS[type] ?? []).includes(object);
|
|
83
|
+
|
|
84
|
+
/** The placeholder index of each region: its kind's base plus its position among the regions of that kind (REGION_IDX_BASE). */
|
|
85
|
+
export function regionIndexes(record) {
|
|
86
|
+
const regions = new Map(layoutTemplate(record).regions.map(region => [region.name, region]));
|
|
87
|
+
const counts = {};
|
|
88
|
+
return Object.fromEntries(Object.keys(record?.regions ?? {}).filter(name => regions.has(name)).map(name => {
|
|
89
|
+
const type = placeholderType(regions.get(name));
|
|
90
|
+
counts[type] = (counts[type] ?? -1) + 1;
|
|
91
|
+
return [name, REGION_IDX_BASE[type] + counts[type]];
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// FNV-1a (64-bit) of the record's canonical JSON: evidence of which record the layout was made from.
|
|
96
|
+
function canonical(value) {
|
|
97
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
98
|
+
if (value !== null && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
|
|
99
|
+
return JSON.stringify(value);
|
|
100
|
+
}
|
|
101
|
+
export function recordHash(record) {
|
|
102
|
+
let hash = 0xcbf29ce484222325n;
|
|
103
|
+
for (const byte of enc.encode(canonical(record))) hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 0x100000001b3n);
|
|
104
|
+
return hash.toString(16).padStart(16, '0');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The template a slide uses, from core's slide context (`resolveSlideContext`): undefined for automatic composition and 0.18
|
|
109
|
+
* records. `provenance` is core's provenance of the resolved layout reference.
|
|
110
|
+
*/
|
|
111
|
+
export function slideTemplate(core) {
|
|
112
|
+
const record = core?.resolved?.layout, provenance = core?.resolved?.provenance?.layout;
|
|
113
|
+
if (!record || !provenance || !Object.hasOwn(record, 'areas')) return undefined;
|
|
114
|
+
try { layoutTemplate(record); } catch { return undefined; }
|
|
115
|
+
return {record, id: provenance.id, group: provenance.group, ...(provenance.source !== undefined ? {source: provenance.source} : {}), reference: provenance.reference, origin: provenance.origin,
|
|
116
|
+
key: templateKey(provenance)};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const templateKey = ({group, id, source}) => `${group}\u0000${id}\u0000${source ?? ''}`;
|
|
120
|
+
function isTemplate(record) {
|
|
121
|
+
if (record === null || typeof record !== 'object' || !Object.hasOwn(record, 'areas')) return false;
|
|
122
|
+
try { layoutTemplate(record); return true; } catch { return false; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Every template the deck can reach, in the order its layouts are written: the registered catalogs' templates in catalog order
|
|
127
|
+
* (each catalog's ids as it lists them), then the document's embedded templates (`custom` first, then the other groups by name,
|
|
128
|
+
* each by id). `resolve(reference)` is core's resolveReference for the deck's layouts; a record is listed under the first
|
|
129
|
+
* reference that resolves to it, and a record no reference reaches is left out.
|
|
130
|
+
*/
|
|
131
|
+
export function deckTemplates(document, catalogs, resolve) {
|
|
132
|
+
const result = [], seen = new Set();
|
|
133
|
+
const add = (references, matches) => {
|
|
134
|
+
for (const reference of references) {
|
|
135
|
+
let found;
|
|
136
|
+
try { found = resolve(reference); } catch { found = undefined; }
|
|
137
|
+
if (!found || !matches(found) || !isTemplate(found.record)) continue;
|
|
138
|
+
const template = {record: found.record, id: found.id, group: found.group, ...(found.source !== undefined ? {source: found.source} : {}), reference, origin: found.origin};
|
|
139
|
+
template.key = templateKey(template);
|
|
140
|
+
if (!seen.has(template.key)) { seen.add(template.key); result.push(template); }
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const groups = document?.catalogs !== null && typeof document?.catalogs === 'object' ? document.catalogs : {};
|
|
145
|
+
for (const catalog of Array.isArray(catalogs) ? catalogs : []) {
|
|
146
|
+
const named = Object.keys(groups).filter(name => name !== 'custom' && groups[name]?.source === catalog?.source);
|
|
147
|
+
for (const [id, record] of Object.entries(catalog?.layouts ?? {})) {
|
|
148
|
+
if (!NAME.test(id) || !isTemplate(record)) continue;
|
|
149
|
+
add([id, `default:${id}`, ...named.map(name => `${name}:${id}`)], found => found.origin === 'host' && found.id === id && found.source === catalog.source);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const names = Object.keys(groups).filter(name => NAME.test(name)).sort((a, b) => a === 'custom' ? -1 : b === 'custom' ? 1 : a < b ? -1 : a > b ? 1 : 0);
|
|
153
|
+
for (const group of names) for (const id of Object.keys(groups[group]?.layouts ?? {}).filter(id => NAME.test(id)).sort()) {
|
|
154
|
+
add(group === 'custom' ? [id, `custom:${id}`] : [`${group}:${id}`], found => found.origin === 'document' && found.group === group && found.id === id);
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const escapeXml = value => String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
160
|
+
const attribute = (xml, name) => new RegExp(`\\s${name}="([^"]*)"`).exec(xml)?.[1];
|
|
161
|
+
const relsOf = path => path.replace(/([^/]+)$/, '_rels/$1.rels');
|
|
162
|
+
const unescapeAttribute = value => value.replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
163
|
+
|
|
164
|
+
function resolvePart(source, target) {
|
|
165
|
+
if (target.startsWith('/')) return target.slice(1);
|
|
166
|
+
const parts = source.split('/').slice(0, -1);
|
|
167
|
+
for (const piece of target.split('/')) {
|
|
168
|
+
if (piece === '..') parts.pop();
|
|
169
|
+
else if (piece !== '.' && piece) parts.push(piece);
|
|
170
|
+
}
|
|
171
|
+
return parts.join('/');
|
|
172
|
+
}
|
|
173
|
+
function relativeTarget(source, path) {
|
|
174
|
+
const from = source.split('/').slice(0, -1), to = path.split('/');
|
|
175
|
+
let common = 0;
|
|
176
|
+
while (common < from.length && common < to.length - 1 && from[common] === to[common]) common++;
|
|
177
|
+
return [...Array(from.length - common).fill('..'), ...to.slice(common)].join('/');
|
|
178
|
+
}
|
|
179
|
+
const relationships = xml => [...xml.matchAll(/<Relationship\b[^>]*\/>/g)].map(([node]) => ({node, id: attribute(node, 'Id'), type: attribute(node, 'Type') ?? '',
|
|
180
|
+
target: unescapeAttribute(attribute(node, 'Target') ?? ''), external: attribute(node, 'TargetMode') === 'External'}));
|
|
181
|
+
function addRelationship(relsXml, type, target) {
|
|
182
|
+
const taken = new Set(relationships(relsXml).map(rel => rel.id));
|
|
183
|
+
let n = 1, rid;
|
|
184
|
+
do rid = `rId${n++}`; while (taken.has(rid));
|
|
185
|
+
return {rid, relsXml: relsXml.replace('</Relationships>', `<Relationship Id="${rid}" Type="${type}" Target="${escapeXml(target)}"/></Relationships>`)};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The placeholders of one template layout in EMU: [{name, type, idx?, x, y, cx, cy, anchor?}]. Core's composeLayoutAreas
|
|
190
|
+
* composes the empty layout as composeSlide composes a slide with a one-line title and subtitle and every region kept:
|
|
191
|
+
* `template.areaOptions` are composeSlide's options of a slide on the template (canvas, the deck's header and footer, direction)
|
|
192
|
+
* plus its placed-image `placements`, so each box is where that slide's region lands, bled regions reaching the slide edge.
|
|
193
|
+
*/
|
|
194
|
+
export function layoutPlaceholders(template) {
|
|
195
|
+
const {record, id} = template;
|
|
196
|
+
const parsed = layoutTemplate(record);
|
|
197
|
+
const {areas} = composeLayoutAreas(record, {...template.areaOptions, textMeasurement: undefined});
|
|
198
|
+
const regions = new Map(parsed.regions.map(region => [region.name, region]));
|
|
199
|
+
const indexes = regionIndexes(record);
|
|
200
|
+
const emu = box => ({x: Math.round(box.x * EMU), y: Math.round(box.y * EMU), cx: Math.max(0, Math.round(box.width * EMU)), cy: Math.max(0, Math.round(box.height * EMU))});
|
|
201
|
+
const result = [];
|
|
202
|
+
for (const area of areas) {
|
|
203
|
+
if (area.name === 'title') {
|
|
204
|
+
if (CENTERED_TITLE.has(id) && area.parts) {
|
|
205
|
+
// `cover` and `section`: PowerPoint's centred title over its subtitle, at core's title and subtitle sub-boxes.
|
|
206
|
+
result.push({name: 'Title', type: 'ctrTitle', anchor: 'b', ...emu(area.parts.title)});
|
|
207
|
+
result.push({name: 'Subtitle', type: 'subTitle', idx: SUBTITLE_IDX, anchor: 't', ...emu(area.parts.subtitle)});
|
|
208
|
+
} else result.push({name: 'Title', type: CENTERED_TITLE.has(id) ? 'ctrTitle' : 'title', ...emu(area.box)});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (area.name === 'subtitle') { result.push({name: 'Subtitle', type: 'subTitle', idx: SUBTITLE_IDX, anchor: 't', ...emu(area.box)}); continue; }
|
|
212
|
+
const region = regions.get(area.name);
|
|
213
|
+
if (region) result.push({name: region.name, type: placeholderType(region), idx: indexes[region.name], ...emu(area.box)});
|
|
214
|
+
}
|
|
215
|
+
const sample = composeSlide({title: 'Title', subtitle: 'Subtitle', blocks: [{text: 'Body'}]}, {...template.areaOptions, layout: record, textMeasurement: undefined});
|
|
216
|
+
for (const placeholder of result) {
|
|
217
|
+
const field = placeholder.type === 'title' || placeholder.type === 'ctrTitle' ? 'title' : placeholder.type === 'subTitle' ? 'subtitle' : 'text';
|
|
218
|
+
const item = sample.items.find(item => item.field === field);
|
|
219
|
+
if (item?.textStyle && item.text?.fontSize) placeholder.style = {...item.textStyle, fontSize: item.text.fontSize, alignment: item.alignment};
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const PROMPTS = {title: 'Click to edit Master title style', ctrTitle: 'Click to edit Master title style', subTitle: 'Click to edit Master subtitle style', body: 'Click to edit Master text styles', obj: 'Click to edit Master text styles'};
|
|
225
|
+
function placeholderShape(placeholder, id) {
|
|
226
|
+
const style = placeholder.style;
|
|
227
|
+
const align = {left: 'l', center: 'ctr', right: 'r'}[style?.alignment] ?? 'l';
|
|
228
|
+
// Each copy inherits its own master's chosen heading/body fonts (including script-profile variants).
|
|
229
|
+
const fontRole = placeholder.type === 'title' || placeholder.type === 'ctrTitle' ? 'mj' : 'mn';
|
|
230
|
+
const defaults = style ? `<a:defRPr sz="${Math.round(style.fontSize * 75)}"${Number(style.fontWeight) >= 600 ? ' b="1"' : ''}><a:latin typeface="${fontRole === 'mj' ? '+mj-lt' : '+mn-lt'}"/><a:ea typeface="+${fontRole}-ea"/><a:cs typeface="+${fontRole}-cs"/></a:defRPr>` : '';
|
|
231
|
+
const listStyle = defaults ? `<a:lstStyle>${Array.from({length: 9}, (_, level) => `<a:lvl${level + 1}pPr algn="${align}">${defaults}</a:lvl${level + 1}pPr>`).join('')}</a:lstStyle>` : '<a:lstStyle/>';
|
|
232
|
+
const typeAttribute = placeholder.type === 'obj' ? '' : ` type="${placeholder.type}"`;
|
|
233
|
+
const idx = placeholder.idx !== undefined ? ` idx="${placeholder.idx}"` : '';
|
|
234
|
+
const prompt = PROMPTS[placeholder.type];
|
|
235
|
+
const paragraph = prompt ? `<a:p><a:r><a:rPr lang="en-US"/><a:t>${prompt}</a:t></a:r></a:p>` : '<a:p><a:endParaRPr lang="en-US"/></a:p>';
|
|
236
|
+
return `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="${escapeXml(placeholder.name)}"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph${typeAttribute}${idx}/></p:nvPr></p:nvSpPr>` +
|
|
237
|
+
`<p:spPr><a:xfrm><a:off x="${placeholder.x}" y="${placeholder.y}"/><a:ext cx="${placeholder.cx}" cy="${placeholder.cy}"/></a:xfrm></p:spPr>` +
|
|
238
|
+
`<p:txBody><a:bodyPr${placeholder.anchor ? ` anchor="${placeholder.anchor}"` : ''}/>${listStyle}${paragraph}</p:txBody></p:sp>`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The OPF_LAYOUT_V1 value of a template layout. With `record` (full provenance) a template the document embeds carries its
|
|
243
|
+
* record, so a template no slide uses comes back on import (document provenance stores only the records slides reference).
|
|
244
|
+
*/
|
|
245
|
+
export function layoutTagValue(template, {record = false} = {}) {
|
|
246
|
+
return {v: 1, id: template.id, group: template.group, ...(template.source !== undefined ? {source: template.source} : {}), reference: template.reference,
|
|
247
|
+
hash: recordHash(template.record), regions: Object.fromEntries(Object.entries(regionIndexes(template.record)).map(([name, idx]) => [String(idx), name])),
|
|
248
|
+
...(record && template.origin === 'document' ? {record: template.record} : {})};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Write the template layouts of the finished package and bind the placeholder objects. `output` maps a package path to
|
|
253
|
+
* [bytes, zipOptions]; runs after the per-script slide masters and before RR-72 lifts furniture. `slides[i]` is slide i's
|
|
254
|
+
* template plan ({template, areaOptions}) or undefined; `templates` (deckTemplates, each with the `areaOptions` its placeholders
|
|
255
|
+
* are composed with) are the layouts to write under every slide master, in order (a slide's own template is added when missing). `bindings`
|
|
256
|
+
* maps an object name on a slide to {slide, region, object} (`picture`, `chart` or `table`). `partText(path, xml)` applies the
|
|
257
|
+
* deck's language to a generated part.
|
|
258
|
+
*/
|
|
259
|
+
export function writeTemplateLayouts(output, slides, templates, bindings = new Map(), partText = (path, xml) => xml, {records = false, automaticSlides = []} = {}) {
|
|
260
|
+
if (!slides.some(Boolean) && !templates.length && !automaticSlides.some(Boolean)) return;
|
|
261
|
+
const all = [...templates];
|
|
262
|
+
for (const plan of slides) if (plan && !all.some(template => template.key === plan.template.key)) all.push({...plan.template, areaOptions: plan.areaOptions});
|
|
263
|
+
const has = path => Object.hasOwn(output, path);
|
|
264
|
+
const read = path => dec.decode(output[path][0]);
|
|
265
|
+
const options = output['ppt/presentation.xml'][1];
|
|
266
|
+
const write = (path, xml) => { output[path] = [enc.encode(xml), output[path]?.[1] ?? options]; };
|
|
267
|
+
let types = read('[Content_Types].xml');
|
|
268
|
+
const presentation = read('ppt/presentation.xml'), presentationRels = relationships(read('ppt/_rels/presentation.xml.rels'));
|
|
269
|
+
// Slide parts in presentation order.
|
|
270
|
+
const slidePaths = [...presentation.matchAll(/<p:sldId\b[^>]*\br:id="([^"]+)"/g)].map(match => {
|
|
271
|
+
const rel = presentationRels.find(item => item.id === match[1]);
|
|
272
|
+
return rel ? resolvePart('ppt/presentation.xml', rel.target) : undefined;
|
|
273
|
+
});
|
|
274
|
+
const related = (path, type) => {
|
|
275
|
+
if (!has(relsOf(path))) return undefined;
|
|
276
|
+
const rel = relationships(read(relsOf(path))).find(item => !item.external && item.type === `${REL}/${type}`);
|
|
277
|
+
return rel ? resolvePart(path, rel.target) : undefined;
|
|
278
|
+
};
|
|
279
|
+
const used = [...presentation.matchAll(/<p:sldMasterId\b[^>]*\bid="(\d+)"/g), ...Object.keys(output).filter(path => /^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(path))
|
|
280
|
+
.flatMap(path => [...read(path).matchAll(/<p:sldLayoutId\b[^>]*\bid="(\d+)"/g)])].map(match => Number(match[1]));
|
|
281
|
+
let nextLayoutId = Math.max(2147483648, ...used) + 1;
|
|
282
|
+
let nextLayout = Math.max(0, ...Object.keys(output).map(path => Number(/^ppt\/slideLayouts\/slideLayout(\d+)\.xml$/.exec(path)?.[1] ?? 0))) + 1;
|
|
283
|
+
let nextTag = Math.max(0, ...Object.keys(output).map(path => Number(/^ppt\/tags\/opfLayout(\d+)\.xml$/.exec(path)?.[1] ?? 0))) + 1;
|
|
284
|
+
const created = new Map(); // master + template key -> {path, placeholders}
|
|
285
|
+
// Every slide master that holds a slide, in slide order, with the layout its slides use now (the copy each new layout starts from).
|
|
286
|
+
const masters = new Map();
|
|
287
|
+
for (const [index, slidePath] of slidePaths.entries()) {
|
|
288
|
+
if (!slidePath || !has(slidePath)) throw new Error(`Slide ${index + 1} is missing.`);
|
|
289
|
+
const base = related(slidePath, 'slideLayout'), master = base && related(base, 'slideMaster');
|
|
290
|
+
if (!base || !master) throw new Error(`Slide ${index + 1} has no slide layout and master.`);
|
|
291
|
+
if (!masters.has(master)) masters.set(master, base);
|
|
292
|
+
}
|
|
293
|
+
for (const [master, base] of masters) {
|
|
294
|
+
const taken = new Set(Object.keys(output).filter(part => /^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(part) && related(part, 'slideMaster') === master)
|
|
295
|
+
.map(part => unescapeAttribute(read(part).match(/<p:cSld\b[^>]*\bname="([^"]*)"/)?.[1] ?? '')));
|
|
296
|
+
for (const template of all) {
|
|
297
|
+
const key = `${master}\u0001${template.key}`;
|
|
298
|
+
const path = `ppt/slideLayouts/slideLayout${nextLayout++}.xml`, tagPath = `ppt/tags/opfLayout${nextTag++}.xml`;
|
|
299
|
+
let name = typeof template.record.name === 'string' && template.record.name.trim() ? template.record.name : template.id;
|
|
300
|
+
if (taken.has(name)) name = `${name} (${template.group}:${template.id})`;
|
|
301
|
+
taken.add(name);
|
|
302
|
+
const type = POWERPOINT_LAYOUT_TYPES[template.id] ?? 'cust';
|
|
303
|
+
const placeholders = layoutPlaceholders(template);
|
|
304
|
+
let xml = read(base);
|
|
305
|
+
xml = xml.replace(/<p:sldLayout\b[^>]*>/, open => `${open.slice(0, -1).replace(/\s+(?:type|userDrawn|showMasterSp)="[^"]*"/g, '')} type="${type}" userDrawn="1">`);
|
|
306
|
+
xml = xml.replace(/(<p:cSld\b[^>]*?)(\s+name="[^"]*")?(\s*>)/, (match, open, _name, close) => `${open} name="${escapeXml(name)}"${close}`);
|
|
307
|
+
let nextId = Math.max(1, ...[...xml.matchAll(/<p:cNvPr\b[^>]*\bid="(\d+)"/g)].map(match => Number(match[1]))) + 1;
|
|
308
|
+
const shapes = placeholders.map(placeholder => placeholderShape(placeholder, nextId++)).join('');
|
|
309
|
+
const groupEnd = xml.indexOf('</p:grpSpPr>');
|
|
310
|
+
if (groupEnd < 0 || /<p:custDataLst>\s*<p:tags\b[^>]*\/>\s*<\/p:custDataLst>\s*<\/p:cSld>/.test(xml)) throw new Error(`${base} cannot take template placeholders.`);
|
|
311
|
+
xml = xml.slice(0, groupEnd + '</p:grpSpPr>'.length) + shapes + xml.slice(groupEnd + '</p:grpSpPr>'.length);
|
|
312
|
+
// A copied layout keeps only its master relationship; the tag brings its own.
|
|
313
|
+
const masterRel = relationships(has(relsOf(base)) ? read(relsOf(base)) : '').find(rel => rel.type === `${REL}/slideMaster`);
|
|
314
|
+
if (!masterRel) throw new Error(`${base} has no slide master relationship.`);
|
|
315
|
+
const tagged = addRelationship(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${masterRel.node}</Relationships>`,
|
|
316
|
+
`${REL}/tags`, relativeTarget(path, tagPath));
|
|
317
|
+
xml = xml.replace('</p:spTree>', `</p:spTree><p:custDataLst><p:tags r:id="${tagged.rid}"/></p:custDataLst>`);
|
|
318
|
+
write(path, partText(path, xml));
|
|
319
|
+
write(relsOf(path), tagged.relsXml);
|
|
320
|
+
output[tagPath] = [enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${LAYOUT_TAG}" val="${encodeTextTag(layoutTagValue(template, {record: records}))}"/></p:tagLst>`), options];
|
|
321
|
+
types = types.replace('</Types>', `<Override PartName="/${path}" ContentType="${LAYOUT_TYPE}"/><Override PartName="/${tagPath}" ContentType="${TAGS_TYPE}"/></Types>`);
|
|
322
|
+
const masterRels = has(relsOf(master)) ? read(relsOf(master)) : '';
|
|
323
|
+
const added = addRelationship(masterRels, `${REL}/slideLayout`, relativeTarget(master, path));
|
|
324
|
+
write(relsOf(master), added.relsXml);
|
|
325
|
+
const masterXml = read(master);
|
|
326
|
+
if (!masterXml.includes('</p:sldLayoutIdLst>')) throw new Error(`${master} has no layout list.`);
|
|
327
|
+
write(master, masterXml.replace('</p:sldLayoutIdLst>', `<p:sldLayoutId id="${nextLayoutId++}" r:id="${added.rid}"/></p:sldLayoutIdLst>`));
|
|
328
|
+
created.set(key, {path, placeholders: new Map(placeholders.map(item => [item.name, item]))});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// Automatic composition is its own titleOnly layout, independently of catalog region templates.
|
|
332
|
+
for (const [master, base] of masters) {
|
|
333
|
+
const first = automaticSlides.find((plan, index) => plan && related(related(slidePaths[index], 'slideLayout'), 'slideMaster') === master);
|
|
334
|
+
if (!first) continue;
|
|
335
|
+
const path = `ppt/slideLayouts/slideLayout${nextLayout++}.xml`, tagPath = `ppt/tags/opfLayout${nextTag++}.xml`;
|
|
336
|
+
// Reuse accepted title geometry; an empty slide needs only the engine's default prompt, never a re-fit through the caller's measurement provider.
|
|
337
|
+
const title = first.title ?? composeSlide({title: 'Title'}, {...first.areaOptions, layout: undefined, textMeasurement: undefined}).items.find(item => item.field === 'title');
|
|
338
|
+
const placeholder = {name: 'Title', type: 'title', x: Math.round(title.box.x * EMU), y: Math.round(title.box.y * EMU), cx: Math.round(title.box.width * EMU), cy: Math.round(title.box.height * EMU), style: {...title.textStyle, fontSize: title.text.fontSize, alignment: title.alignment}};
|
|
339
|
+
let xml = read(base).replace(/<p:sldLayout\b[^>]*>/, open => `${open.slice(0, -1).replace(/\s+(?:type|userDrawn|showMasterSp)="[^"]*"/g, '')} type="titleOnly" userDrawn="1">`);
|
|
340
|
+
xml = xml.replace(/(<p:cSld\b[^>]*?)(\s+name="[^"]*")?(\s*>)/, (match, open, _name, close) => `${open} name="OPF auto"${close}`);
|
|
341
|
+
const nextId = Math.max(1, ...[...xml.matchAll(/<p:cNvPr\b[^>]*\bid="(\d+)"/g)].map(match => Number(match[1]))) + 1;
|
|
342
|
+
xml = xml.replace('</p:grpSpPr>', `</p:grpSpPr>${placeholderShape(placeholder, nextId)}`);
|
|
343
|
+
const masterRel = relationships(read(relsOf(base))).find(rel => rel.type === `${REL}/slideMaster`);
|
|
344
|
+
const tagged = addRelationship(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${masterRel.node}</Relationships>`, `${REL}/tags`, relativeTarget(path, tagPath));
|
|
345
|
+
xml = xml.replace('</p:spTree>', `</p:spTree><p:custDataLst><p:tags r:id="${tagged.rid}"/></p:custDataLst>`);
|
|
346
|
+
write(path, partText(path, xml)); write(relsOf(path), tagged.relsXml);
|
|
347
|
+
output[tagPath] = [enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${LAYOUT_TAG}" val="${encodeTextTag({v: 1, id: 'auto', group: 'custom', reference: 'auto', hash: recordHash({auto: true}), regions: {}})}"/></p:tagLst>`), options];
|
|
348
|
+
types = types.replace('</Types>', `<Override PartName="/${path}" ContentType="${LAYOUT_TYPE}"/><Override PartName="/${tagPath}" ContentType="${TAGS_TYPE}"/></Types>`);
|
|
349
|
+
const added = addRelationship(read(relsOf(master)), `${REL}/slideLayout`, relativeTarget(master, path));
|
|
350
|
+
write(relsOf(master), added.relsXml);
|
|
351
|
+
write(master, read(master).replace('</p:sldLayoutIdLst>', `<p:sldLayoutId id="${nextLayoutId++}" r:id="${added.rid}"/></p:sldLayoutIdLst>`));
|
|
352
|
+
created.set(`${master}\u0001auto`, {path, placeholders: new Map([['Title', placeholder]])});
|
|
353
|
+
}
|
|
354
|
+
for (const [index, plan] of slidePaths.map((_, index) => slides[index]).entries()) {
|
|
355
|
+
if (!plan && !automaticSlides[index]) continue;
|
|
356
|
+
const slidePath = slidePaths[index];
|
|
357
|
+
const master = related(related(slidePath, 'slideLayout'), 'slideMaster');
|
|
358
|
+
const layout = created.get(`${master}\u0001${plan ? plan.template.key : 'auto'}`);
|
|
359
|
+
if (!layout) throw new Error(`Slide ${index + 1} has no template layout.`);
|
|
360
|
+
const slideRels = relsOf(slidePath);
|
|
361
|
+
write(slideRels, read(slideRels).replace(/<Relationship\b[^>]*\/>/g, node => attribute(node, 'Type') === `${REL}/slideLayout` ? node.replace(/\sTarget="[^"]*"/, ` Target="${relativeTarget(slidePath, layout.path)}"`) : node));
|
|
362
|
+
// The lone picture, chart or table of a `none` region becomes its placeholder's content.
|
|
363
|
+
let slideXml = read(slidePath), changed = false;
|
|
364
|
+
for (const [objectName, binding] of bindings) {
|
|
365
|
+
if (binding.slide !== index) continue;
|
|
366
|
+
const placeholder = layout.placeholders.get(binding.region);
|
|
367
|
+
if (!placeholder || !bindsObject(placeholder.type, binding.object)) continue;
|
|
368
|
+
const ph = `<p:ph${placeholder.type === 'obj' ? '' : ` type="${placeholder.type}"`}${placeholder.idx !== undefined ? ` idx="${placeholder.idx}"` : ''}/>`;
|
|
369
|
+
const element = binding.object === 'picture' ? 'pic' : binding.object === 'title' ? 'sp' : 'graphicFrame';
|
|
370
|
+
const pattern = new RegExp(`<p:${element}>[\\s\\S]*?<\\/p:${element}>`, 'g');
|
|
371
|
+
slideXml = slideXml.replace(pattern, shape => {
|
|
372
|
+
if (unescapeAttribute(shape.match(/<p:cNvPr\b[^>]*\bname="([^"]*)"/)?.[1] ?? '') !== objectName || /<p:ph\b/.test(shape)) return shape;
|
|
373
|
+
changed = true;
|
|
374
|
+
let next = shape.replace(/<p:nvPr\s*\/>/, '<p:nvPr></p:nvPr>').replace(/<p:nvPr>/, `<p:nvPr>${ph}`);
|
|
375
|
+
if (element === 'pic') {
|
|
376
|
+
next = /<a:picLocks\b[^>]*\/>/.test(next) ? next.replace(/<a:picLocks\b([^>]*?)\s*\/>/, (match, rest) => /noGrp=/.test(rest) ? match : `<a:picLocks noGrp="1"${rest}/>`)
|
|
377
|
+
: next.replace(/<p:cNvPicPr\s*\/>/, '<p:cNvPicPr><a:picLocks noGrp="1"/></p:cNvPicPr>').replace(/<p:cNvPicPr>(?!<a:picLocks)/, '<p:cNvPicPr><a:picLocks noGrp="1"/>');
|
|
378
|
+
} else if (element === 'sp') {
|
|
379
|
+
next = next.replace(/<p:cNvSpPr\b([^>]*)\/>/, '<p:cNvSpPr$1><a:spLocks noGrp="1"/></p:cNvSpPr>');
|
|
380
|
+
} else if (element === 'graphicFrame') {
|
|
381
|
+
next = /<a:graphicFrameLocks\b[^>]*\/>/.test(next) ? next.replace(/<a:graphicFrameLocks\b([^>]*?)\s*\/>/, (match, rest) => /noGrp=/.test(rest) ? match : `<a:graphicFrameLocks noGrp="1"${rest}/>`)
|
|
382
|
+
: next.replace(/<p:cNvGraphicFramePr\s*\/>/, '<p:cNvGraphicFramePr><a:graphicFrameLocks noGrp="1"/></p:cNvGraphicFramePr>');
|
|
383
|
+
}
|
|
384
|
+
return next;
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
if (changed) write(slidePath, slideXml);
|
|
388
|
+
}
|
|
389
|
+
write('[Content_Types].xml', types);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// Import
|
|
394
|
+
|
|
395
|
+
function tagIdentityError(tag) {
|
|
396
|
+
if (!NAME.test(tag?.id ?? '') || !NAME.test(tag?.group ?? '')) return 'invalid id or group';
|
|
397
|
+
const reference = /^(?:([a-z][a-z0-9-]*):)?([a-z][a-z0-9-]*)$/.exec(tag.reference ?? '');
|
|
398
|
+
if (!reference || reference[2] !== tag.id || (reference[1] !== undefined && reference[1] !== tag.group)) return 'reference does not match id and group';
|
|
399
|
+
if (reference[1] === undefined && !['custom', 'default'].includes(tag.group)) return 'bare reference cannot name an external catalog';
|
|
400
|
+
if (tag.source !== undefined && (typeof tag.source !== 'string' || !/^(?:https:\/\/[^\s]+|pkg:[^\s]+)$/.test(tag.source))) return 'invalid catalog source';
|
|
401
|
+
if (tag.group === 'custom' && tag.source !== undefined) return 'custom catalog cannot have a source';
|
|
402
|
+
if (tag.group !== 'custom' && tag.source === undefined) return 'catalog source is missing';
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The validated OPF_LAYOUT_V1 value of a slide layout part, or undefined. `report(message)` names a damaged tag. */
|
|
407
|
+
export function readLayoutTag(entries, layoutPath, report = () => {}) {
|
|
408
|
+
if (!layoutPath || !entries[layoutPath]) return undefined;
|
|
409
|
+
const xml = dec.decode(entries[layoutPath]);
|
|
410
|
+
const rid = xml.match(/<\/p:spTree>\s*<p:custDataLst>([\s\S]*?)<\/p:custDataLst>/)?.[1]?.match(/<p:tags\b[^>]*\br:id="([^"]+)"/)?.[1];
|
|
411
|
+
if (!rid || !entries[relsOf(layoutPath)]) return undefined;
|
|
412
|
+
const rel = relationships(dec.decode(entries[relsOf(layoutPath)])).find(item => item.id === rid && item.type === `${REL}/tags` && !item.external);
|
|
413
|
+
const tagPath = rel && resolvePart(layoutPath, rel.target);
|
|
414
|
+
if (!tagPath || !entries[tagPath]) return undefined;
|
|
415
|
+
const tag = [...dec.decode(entries[tagPath]).matchAll(/<p:tag\b[^>]*\/>/g)].map(([node]) => node).find(node => attribute(node, 'name') === LAYOUT_TAG);
|
|
416
|
+
if (!tag) return undefined;
|
|
417
|
+
try {
|
|
418
|
+
const value = decodeTextTag(unescapeAttribute(attribute(tag, 'val') ?? ''));
|
|
419
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value) || value.v !== 1) throw Error('unknown version');
|
|
420
|
+
const identityError = tagIdentityError(value);
|
|
421
|
+
if (identityError) throw Error(identityError);
|
|
422
|
+
if (typeof value.reference !== 'string' || !/^(?:[a-z][a-z0-9-]*:)?[a-z][a-z0-9-]*$/.test(value.reference) || typeof value.hash !== 'string') throw Error('invalid reference');
|
|
423
|
+
if (value.record !== undefined && (value.record === null || typeof value.record !== 'object' || Array.isArray(value.record))) throw Error('invalid record');
|
|
424
|
+
const regions = value.regions;
|
|
425
|
+
if (regions === null || typeof regions !== 'object' || Array.isArray(regions) || Object.keys(regions).length > MAX_REGIONS
|
|
426
|
+
|| Object.entries(regions).some(([idx, name]) => !/^\d{1,4}$/.test(idx) || typeof name !== 'string' || !NAME.test(name) || name.length > 64)) throw Error('invalid regions');
|
|
427
|
+
return value;
|
|
428
|
+
} catch (error) {
|
|
429
|
+
report(`The OPF layout tag of ${layoutPath} is damaged (${error.message}); the slide layout is read as a foreign layout.`);
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The region of a native slide object from its placeholder index, through the layout tag. */
|
|
435
|
+
export function placeholderRegion(node, tag) {
|
|
436
|
+
if (!tag) return undefined;
|
|
437
|
+
const nv = node?.['p:nvSpPr'] ?? node?.['p:nvPicPr'] ?? node?.['p:nvGraphicFramePr'];
|
|
438
|
+
const idx = nv?.['p:nvPr']?.['p:ph']?.idx;
|
|
439
|
+
return idx === undefined ? undefined : tag.regions[String(idx)];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const bareId = reference => /^(?:([a-z][a-z0-9-]*):)?([a-z][a-z0-9-]*)$/.exec(reference ?? '');
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* After document provenance: each slide's layout from its tagged slide layout, and the region pins of blocks read from region
|
|
446
|
+
* placeholders. `slides[i]` = {tag, blockRegions, restored}; `recordOf(index)` resolves the slide's layout record.
|
|
447
|
+
*/
|
|
448
|
+
export function applyTemplateLayouts(imported, slides, recordOf, report = () => {}, layoutTags = []) {
|
|
449
|
+
const usable = tag => {
|
|
450
|
+
const error = tagIdentityError(tag);
|
|
451
|
+
const existing = imported.catalogs?.[tag?.group];
|
|
452
|
+
const conflict = existing === false || (existing?.source !== undefined && existing.source !== tag.source);
|
|
453
|
+
if (!error && !conflict) return true;
|
|
454
|
+
report({code: 'invalid-layout-provenance', path: `catalogs.${tag?.group}`, message: `The PowerPoint layout tag was not restored (${error ?? 'catalog source conflicts with document provenance'}).`});
|
|
455
|
+
return false;
|
|
456
|
+
};
|
|
457
|
+
// Restore unused embedded templates only with consistent, schema-valid catalog provenance.
|
|
458
|
+
for (const tag of layoutTags) {
|
|
459
|
+
if (!tag?.record || !usable(tag) || recordHash(tag.record) !== tag.hash) continue;
|
|
460
|
+
if (imported.catalogs?.[tag.group]?.layouts?.[tag.id] !== undefined) continue;
|
|
461
|
+
if (!isTemplate(tag.record)) continue;
|
|
462
|
+
let valid = false;
|
|
463
|
+
try { valid = validateCatalogRecord('layouts', {$schema: 'https://openpresentation.org/schema/opf-layout/v2', id: tag.id, ...tag.record}).valid; } catch { valid = false; }
|
|
464
|
+
if (!valid) { report({code: 'invalid-layout-provenance', path: `catalogs.${tag.group}.layouts.${tag.id}`, message: `The layout record stored with the PowerPoint layout of ${tag.reference} does not validate; it was not restored.`}); continue; }
|
|
465
|
+
imported.catalogs = {...imported.catalogs, [tag.group]: {...(tag.source ? {source: tag.source} : {}), ...imported.catalogs?.[tag.group], layouts: {...imported.catalogs?.[tag.group]?.layouts, [tag.id]: structuredClone(tag.record)}}};
|
|
466
|
+
}
|
|
467
|
+
for (const [index, entry] of slides.entries()) {
|
|
468
|
+
const slide = imported.slides[index], tag = entry?.tag;
|
|
469
|
+
if (!slide || !tag || !usable(tag)) continue;
|
|
470
|
+
const qualified = bareId(tag.reference)?.[1];
|
|
471
|
+
if (qualified && tag.source && (!imported.catalogs?.[qualified] || typeof imported.catalogs[qualified] !== 'object'))
|
|
472
|
+
imported.catalogs = {...imported.catalogs, [qualified]: {source: tag.source}};
|
|
473
|
+
if (tag.id === 'auto' && entry.preservedAutomatic && (entry.storedLayout === undefined || entry.storedLayout === 'auto')) { if (entry.storedLayout === 'auto') slide.layout = 'auto'; continue; }
|
|
474
|
+
if (slide.layout === undefined) slide.layout = tag.reference;
|
|
475
|
+
else if (slide.layout !== tag.reference) {
|
|
476
|
+
const match = bareId(slide.layout);
|
|
477
|
+
const same = match && match[2] === tag.id && (match[1] === undefined || match[1] === tag.group);
|
|
478
|
+
if (!same) {
|
|
479
|
+
report({code: 'layout-changed', path: `slides.${index}.layout`, message: `The slide uses the PowerPoint layout of ${tag.reference} (Change Layout), not its stored layout ${slide.layout}; slides.${index}.layout is now ${tag.reference} and its content is bound by that layout.`});
|
|
480
|
+
slide.layout = tag.reference;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (entry.restored || !Array.isArray(slide.blocks) || !entry.blockRegions?.some(Boolean)) continue;
|
|
484
|
+
let record;
|
|
485
|
+
try { record = recordOf(index); layoutTemplate(record); } catch { continue; }
|
|
486
|
+
const regions = new Map(layoutTemplate(record).regions.map(region => [region.name, region]));
|
|
487
|
+
for (const [position, name] of entry.blockRegions.entries()) {
|
|
488
|
+
const block = slide.blocks[position], region = regions.get(name);
|
|
489
|
+
if (!name || !region || !block || typeof block !== 'object' || block.region !== undefined || !regionAccepts(region, block)) continue;
|
|
490
|
+
const bound = bindRegions(slide, record, {slideIndex: index}).regions.find(item => [...item.blocks, ...item.overflow].some(node => node.block === position));
|
|
491
|
+
if (bound?.name !== name) block.region = name;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Map foreign PowerPoint layouts to available v2 built-ins; no layout reconstruction or migration aliases. */
|
|
497
|
+
export function foreignLayoutReference(layout, catalogs = [gallery]) {
|
|
498
|
+
const types = {title: 'cover', secHead: 'section', obj: 'text', tx: 'text', twoObj: 'two-column', twoTxTwoObj: 'two-column', picTx: 'image-beside'};
|
|
499
|
+
const type = layout?.type;
|
|
500
|
+
if (type === 'blank' || type === 'titleOnly') return 'auto';
|
|
501
|
+
const options = catalogs === undefined ? {} : {catalogs};
|
|
502
|
+
// Use core's default catalog resolution: a same-id record in another host catalog must never supply a misleading name match.
|
|
503
|
+
const builtins = catalogRecords({}, 'layouts', options).filter(entry => entry.group === 'default' && isTemplate(entry.record))
|
|
504
|
+
.filter(entry => Object.hasOwn(POWERPOINT_LAYOUT_TYPES, entry.id) || ['image', 'gallery', 'chart', 'chart-beside', 'table', 'table-beside', 'code', 'pillars', 'process', 'timeline', 'quote', 'metrics', 'scorecard', 'hero', 'dashboard', 'team', 'logos', 'statement', 'closing'].includes(entry.id))
|
|
505
|
+
.filter(entry => resolveReference({}, 'layouts', `default:${entry.id}`, options)?.source === entry.source);
|
|
506
|
+
const id = types[type];
|
|
507
|
+
if (id) return builtins.some(entry => entry.id === id) ? `default:${id}` : undefined;
|
|
508
|
+
if (type === 'cust') {
|
|
509
|
+
const name = String(layout?.['p:cSld']?.name ?? '').trim().toLowerCase();
|
|
510
|
+
const matched = builtins.find(entry => entry.id.toLowerCase() === name || String(entry.record.name ?? '').trim().toLowerCase() === name);
|
|
511
|
+
return matched ? `default:${matched.id}` : 'auto';
|
|
512
|
+
}
|
|
513
|
+
return 'auto';
|
|
514
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-pptx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -64,8 +64,9 @@
|
|
|
64
64
|
"test:determinism": "node test/determinism.mjs"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
+
"@openpresentation/gallery": "^2.0.0",
|
|
67
68
|
"@node-projects/jszip": "4.3.0",
|
|
68
|
-
"@openpresentation/opf": "^0.
|
|
69
|
+
"@openpresentation/opf": "^0.19.0",
|
|
69
70
|
"@rgrove/parse-xml": "4.2.3",
|
|
70
71
|
"fast-xml-parser": "^5.8.0",
|
|
71
72
|
"fflate": "^0.8.3",
|
|
@@ -73,7 +74,7 @@
|
|
|
73
74
|
"sharp": "0.35.5"
|
|
74
75
|
},
|
|
75
76
|
"peerDependencies": {
|
|
76
|
-
"@openpresentation/opf-render": "^0.
|
|
77
|
+
"@openpresentation/opf-render": "^0.19.1"
|
|
77
78
|
},
|
|
78
79
|
"peerDependenciesMeta": {
|
|
79
80
|
"@openpresentation/opf-render": {
|
|
@@ -97,7 +98,7 @@
|
|
|
97
98
|
"@expo-google-fonts/roboto": "0.4.3",
|
|
98
99
|
"@expo-google-fonts/roboto-mono": "0.4.2",
|
|
99
100
|
"@expo-google-fonts/tinos": "0.4.2",
|
|
100
|
-
"@openpresentation/opf-render": "0.
|
|
101
|
+
"@openpresentation/opf-render": "0.19.1",
|
|
101
102
|
"@resvg/resvg-js": "2.6.2",
|
|
102
103
|
"esbuild": "0.28.2",
|
|
103
104
|
"playwright": "1.63.0"
|