@openpresentation/opf-pptx 0.16.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -155,7 +155,7 @@ The first importer is mechanical and schema-compatible:
155
155
  - Unknown non-text shapes and unsupported graphic frames become editable text fallback blocks instead of failing the import.
156
156
  - Catalog references (`design.theme`, `colorScheme`, `fontScheme`, `dimensions`, `background`, slide `layout`), slide ids and authoring metadata (`narrative`, `tone`, `audience`, `purpose`, `language`, `organization`, `speaker`, ...) are stored at export in `OPF_DOCUMENT_V1` / `OPF_SLIDE_V1` customer-data tags. Import restores a reference while the theme colors, theme fonts, slide size, background or slide arrangement it produced are unchanged. After an edit, the observed native values stay and `design-reference-changed` / `layout-reference-changed` name the reference. `toPptx` option `provenance: 'references-only' | false` limits or disables these invisible tags. In `full` mode the document tag also lists the document-level defaults the document left to the engine (`$schema`, `name`, `language`, the theme, colour scheme, font scheme and slide size), and import leaves them absent while the package still shows the default, so an untouched round trip is deep-equal to the source (RR-59); a value edited in PowerPoint imports as observed. Slide layout intent (layout reference, type, composition, composition hints and the embedded layout record with its catalogs group and source) lives in each `OPF_SLIDE_V1` record, so a slide keeps its layout even without the document tag, for example when it is pasted into another deck (FF-29). A layout that resolves only in a registered catalog stores no record and restores where `fromPptx` is given that catalog. See [document round trips](docs/document-roundtrip.md) for exactly what is embedded.
157
157
  - The slide's content structure is part of `OPF_SLIDE_V1` too (`full` mode): nested `group` blocks, promoted regions (`left`, `top:left`, ...), a root payload (`text`, `items`, `chart`, ...) versus `blocks`, block `id`s and `extensions`, group `composition`, with the reference-pixel box of every leaf from the same composition the export drew. Import matches the native shapes to those boxes and rebuilds the authored form while the slide's arrangement is unchanged, so `slide.type` validates again; a slide whose blocks no longer fit the stored boxes keeps its flat blocks and reports `content-structure-changed` at `slides.N`; a duplicated slide reports `duplicate-block-id` and drops the repeated id. The document tag also stores `filename`, root `extensions`, slide `section` and `extensions`, `design.logo` (deck and slide) and the whole `assets` registry; a `data:` source that is not an exported picture is stored inline up to 256 KiB, so an organization logo or speaker photo given as a data URI round-trips.
158
- - Slide `section` labels are written as PowerPoint's native section list (`p14:sectionLst` in `ppt/presentation.xml`, whatever the `provenance` option): one section per run of consecutive slides with the same label, runs without a label as `Default Section`, with deterministic ids. Import reads the list back (`Default Section` means no section); it wins over the stored value and over the section text a footer shows (`section-reference-changed` at `slides.N.section`), except that an edited footer line keeps its text while the list still equals the stored value. The native PowerPoint check of the sections pane and save/reopen is a separate gate (`scratchpad/spec-gaps-native/`).
158
+ - Slide `section` labels are written as PowerPoint's native section list (`p14:sectionLst` in `ppt/presentation.xml`, whatever the `provenance` option): one section per run of consecutive slides with the same label, runs without a label as `Default Section`, with deterministic ids. Import reads the list back (`Default Section` means no section); it wins over the stored value (a footer's `{{slide.section}}` is only the token, so no footer text names the section), except that the stored label stands while the list still names it. The native PowerPoint check of the sections pane and save/reopen is a separate gate (`scratchpad/spec-gaps-native/`).
159
159
 
160
160
  There is no AI classification pass in the OSS runtime. Hosts can run optional cleanup or semantic remapping after `fromPptx` returns. A host that wants to rebuild structure (code, metrics, quotes, timelines, regions) in a deck that did not come from OPF can ask for the raw shape facts such a step needs; see [Import signals](#import-signals).
161
161
 
@@ -453,12 +453,12 @@ Version 0.5.0 requires core 0.7.0 and renderer 0.5.0 for coordinated previews. `
453
453
 
454
454
  Version 0.8.0 exports editable native lines with explicit four-space tab stops. Boundary tags contain no original source words, so native edits and deletions remain authoritative. XML-unrepresentable scalar controls reject with an actionable path rather than silently losing characters. Twenty wide/portrait measured/estimated export cases, current-text mutation controls and damaged-tag fallbacks pass; native Office acceptance remains separate. The coordinated core browser workflow also verifies editing, undo and exact reimport.
455
455
 
456
- Version 0.9.1 exports core's accepted header/footer text and picture geometry as editable tagged slide shapes. Since RR-11 the footer's first text, date and slide number are native PowerPoint Header & Footer placeholders ([core issue87](https://github.com/OpenPresentation/opf/issues/87), [native header and footer](docs/native-header-footer.md)); releases up to 0.11.7 wrote every part as a tagged shape. Dedicated `OPF_FURNITURE_V1` tags describe roles, line boundaries, inactive flags and global/local scope. A slide manifest represents empty or disabled definitions without adding a visible shape. Tags contain no original text, image bytes or alt text. Reimport reads those values from current native shapes; complete groups recover literal dates, section/organization metadata and page-number intent when the current number matches the current slide position.
456
+ Version 0.9.1 exports core's accepted header/footer text and picture geometry as editable tagged slide shapes. Since RR-11 the footer's first text, date and slide number are native PowerPoint Header & Footer placeholders ([core issue87](https://github.com/OpenPresentation/opf/issues/87), [native header and footer](docs/native-header-footer.md)); releases up to 0.11.7 wrote every part as a tagged shape. Dedicated `OPF_FURNITURE_V1` tags describe roles, line boundaries, inactive flags and global/local scope. A slide manifest represents empty or disabled definitions without adding a visible shape. Tags contain no original text, image bytes or alt text. Reimport reads those values from current native shapes; complete groups recover literal dates and the authored zone `text` with its slide-scoped tokens while the current words still match this slide.
457
457
 
458
- Slide numbers inside those shapes are native PowerPoint slide-number fields (`<a:fld type="slidenum">`), so PowerPoint renumbers them when slides move. A `slideNumberFormat` such as `"A-{current}"` or `"{current} / {total}"` keeps its literal text and `{total}` (the exported slide count) as fixed runs around the field; PowerPoint has no slide-count field. A current date (`date: true`) needs the host's `date` option (ISO `YYYY-MM-DD`); it becomes a native `datetime1`–`datetime7` field when its `dateFormat` matches that en-US field type, and otherwise fixed text with a `furniture-date-fixed` diagnostic. An eligible date or number range that spans accepted text lines stays static and reports `furniture-field-fixed` at its source path; no native field is created by merging shapes. A string date with `dateFormat` is fixed text. The slide manifest records `slideNumberFormat`/`dateFormat` settings, never their rendered words: reimport keeps a format only while the current native text still matches it (a slide number at its current position, a fixed date that parses back to the same ISO date), and a live date keeps the pattern of its current field type. Otherwise the current words import as literal text. Hiding furniture on a title slide is a slide-level `design.footer: false`/`design.header: false`, which round-trips as before.
458
+ A header or footer zone's `text` is the author's own string (FA-31): generated values are `{{ }}` variables in it. Each `{{slide.number}}` is a native PowerPoint slide-number field (`<a:fld type="slidenum">`) when its line fits one accepted text line, so PowerPoint renumbers it when slides move; the text around it (`"A-{{slide.number}}"`, `"Page {{slide.number}} of {{deck.slideCount}}"`) is ordinary runs. `{{deck.slideCount}}` (the exported slide count after pagination) and `{{slide.section}}` are fixed text, because PowerPoint has no slide-count or section field; the deck-wide variables (`{{organization.name}}`, `{{customer}}`) resolve before export like any other string. In body text every slide-scoped token is fixed text with the slide's value. Each slide is composed and drawn from core's substituted slide, so text is measured with the real value. A current date (`date: true`) needs the host's `date` option (ISO `YYYY-MM-DD`); it becomes a native `datetime1`–`datetime7` field when its `dateFormat` matches that en-US field type, and otherwise fixed text with a `furniture-date-fixed` diagnostic. An eligible date or number range that spans accepted text lines stays static and reports `furniture-field-fixed` at its source path; no native field is created by merging shapes. A string date with `dateFormat` is fixed text. The slide manifest records `dateFormat` settings and, for a zone whose `{{ }}` tokens are all built-in variables, that authored text as given to `toPptx`, before the deck-wide pass (`templates`); it never records rendered dates. Reimport keeps a format or the authored text only while the current native text still matches it (a fixed date that parses back to the same ISO date; the authored text drawn for this slide equals the current words: deck-wide built-ins resolved from the stored organization and speakers, then the slide's number, count and section), and a live date keeps the pattern of its current field type. Otherwise the current words import as literal text, with each native slide-number field as `{{slide.number}}` (typed words that spell a token are escaped as `\{{`). Hiding furniture on a title slide is a slide-level `design.footer: false`/`design.header: false`, which round-trips as before.
459
459
 
460
460
  When a supported current-date field wraps across lines, full provenance records a versioned static-date reason, its format and exact line fingerprints, without cached date words. Complete unchanged soft-wrapped lines can then recover `date: true` and the authored format on reimport, so a later OPF export can use a new host date. The original PPTX date remains static: this does not establish native refresh or Office compatibility. Actual current native date fields take precedence. Edited or cleared text, damaged evidence, older unmarked exports, `references-only` and `provenance: false` remain literal/current-content fallbacks. Fingerprints detect changed text; writable tags are not authentication. Native formatting and geometry are not reconstructed.
461
461
 
462
- Missing, duplicated or inconsistent furniture tags fall back to current native content with an `invalid-furniture-provenance` diagnostic. Conflicting organization or section values also fall back. Inherited definitions become global only when every slide has a valid definition/override and all inherited values agree; otherwise recovered definitions stay local. Reordering slides with unchanged visible page numbers preserves those numbers as ordinary text instead of silently renumbering them. Native typography, positioning, crop and unrelated metadata are not reconstructed; `furniture-import-reflow` requests review. `npm run test:furniture` exercises XML conversion and mutation controls. Native Office, visual corpus and clean installed-package acceptance remain separate gates; published tagged-shape furniture (0.9.1 to 0.11.7) does not establish native Header/Footer support.
462
+ Missing, duplicated or inconsistent furniture tags fall back to current native content with an `invalid-furniture-provenance` diagnostic. Social profiles (`socials: true`) belong to the organization the manifest names by id, which only the stored document record can supply, so a file without it (`provenance: false`, another tool) imports the profile lines as ordinary text with an `invalid-furniture-provenance` diagnostic. Inherited definitions become global only when every slide has a valid definition/override and all inherited values agree; otherwise recovered definitions stay local. A moved slide keeps its live slide-number field (it imports as `{{slide.number}}` and renumbers in PowerPoint). Native typography, positioning, crop and unrelated metadata are not reconstructed; `furniture-import-reflow` requests review. `npm run test:furniture` exercises XML conversion and mutation controls. Native Office, visual corpus and clean installed-package acceptance remain separate gates; published tagged-shape furniture (0.9.1 to 0.11.7) does not establish native Header/Footer support.
463
463
 
464
- Native header and footer placeholders (RR-11): the first footer text becomes a `ftr` placeholder, the first footer date a `dt` placeholder (a live `datetime1`-`datetime7` field for `date: true`) and the first footer slide number an `sldNum` placeholder, each at core's exact box with its furniture tag; the slide master and layout carry the three placeholders and `p:hf` flags, and the notes master records that notes pages carry a page number only. Header parts, organization, section, socials, images, second text/date/number parts and multi-line text stay tagged shapes (PowerPoint has no object for them). `fromPptx` reads the placeholders back with or without provenance, and a deck changed through PowerPoint's Insert > Header & Footer dialog imports the change. Every deck, with or without a footer, carries the three master and layout placeholders (flags off when unused), so Insert > Header & Footer works on a deck with no footer. The mapping table, the dialog-edit table and the vetoable decisions are in [docs/native-header-footer.md](docs/native-header-footer.md).
464
+ Native header and footer placeholders (RR-11): the first footer text becomes a `ftr` placeholder, the first footer date a `dt` placeholder (a live `datetime1`-`datetime7` field for `date: true`) and the first footer slide number an `sldNum` placeholder, each at core's exact box with its furniture tag; the slide master and layout carry the three placeholders and `p:hf` flags, and the notes master records that notes pages carry a page number only. A zone `text` with a `{{slide.number}}` is the `sldNum` placeholder (its other words are fixed runs); any other zone `text` is the `ftr` placeholder. Header parts, socials, images, second text/date/number parts and multi-line text stay tagged shapes (PowerPoint has no object for them). `fromPptx` reads the placeholders back with or without provenance, and a deck changed through PowerPoint's Insert > Header & Footer dialog imports the change. Every deck, with or without a footer, carries the three master and layout placeholders (flags off when unused), so Insert > Header & Footer works on a deck with no footer. The mapping table, the dialog-edit table and the vetoable decisions are in [docs/native-header-footer.md](docs/native-header-footer.md).
@@ -91,7 +91,7 @@ export function nativeShapeParagraphs(xml, rootElement = 'p:sld') {
91
91
  };
92
92
  for (const child of paragraph) {
93
93
  if (child['a:br'] !== undefined) text += '\n';
94
- if (child['a:fld'] !== undefined) fields.push({type: String(child[':@']?.type ?? ''), text: children(child['a:fld'],'a:t').map(plainText).join('')});
94
+ if (child['a:fld'] !== undefined) fields.push({type: String(child[':@']?.type ?? ''), text: children(child['a:fld'],'a:t').map(plainText).join(''), start: text.length});
95
95
  text += readRuns(child);
96
96
  if (child['mc:AlternateContent'] !== undefined) {
97
97
  // FF-45: an a14:m math zone (an OMML equation, m:oMathPara) has no OPF model. Its mc:Fallback runs (the text PowerPoint
@@ -928,6 +928,20 @@ export function slideListBreaks(entries, path, root, rels) {
928
928
  }
929
929
  }
930
930
 
931
+ /**
932
+ * The authoring metadata the stored document record (OPF_DOCUMENT_V1) holds (organization, speaker, ...), or {} when it is missing
933
+ * or unreadable. Furniture import reads it before the slides: a socials part belongs to an organization by id, and a stored
934
+ * header or footer text with built-in variables ({{organization.name}}) is drawn from it. Read only; nothing is restored here.
935
+ */
936
+ export function storedMetadata(entries, presentationRoot, presentationRels) {
937
+ try {
938
+ const found = readTag(entries, presentationRoot?.['p:custDataLst'], presentationRels, DOCUMENT_TAG);
939
+ if (found.missing) return {};
940
+ const metadata = validateDocument(found.value).metadata;
941
+ return object(metadata) ? metadata : {};
942
+ } catch { return {}; }
943
+ }
944
+
931
945
  /**
932
946
  * Read OPF_DOCUMENT_V1 / OPF_SLIDE_V1 and decide what still matches the
933
947
  * package. Nothing is modified here: the result lists restore groups (one per
@@ -949,7 +963,7 @@ export function slideListBreaks(entries, path, root, rels) {
949
963
  * `nativeSections` is the package's native section list as one name (or
950
964
  * undefined) per slide, or null when the package has no list.
951
965
  */
952
- export function restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, slides, organizationConflict = false, speakerConflict = false, catalogs: hostCatalogs, nativeSections = null, schemaOption = false}, report) {
966
+ export function restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, slides, catalogs: hostCatalogs, nativeSections = null, schemaOption = false}, report) {
953
967
  const invalid = message => report({code: 'invalid-document-provenance', path: '', message: `${message} Ordinary import keeps the values observed in the PPTX.`});
954
968
  let document;
955
969
  try {
@@ -1018,27 +1032,24 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
1018
1032
  return true;
1019
1033
  };
1020
1034
 
1021
- // Sections: three sources can name a slide's section. The native section
1022
- // list (PowerPoint's sections pane), the section text a footer shows, and the
1023
- // stored OPF_SLIDE_V1 value. The list is native data and wins, except when
1024
- // the list still equals the stored value and the footer text differs: then
1025
- // the footer was edited and its text is kept, as before. Without a list the
1026
- // stored value is the fallback for a slide whose footer shows none.
1027
- // Labels compare the way the native list stores them: tab, LF and CR are
1028
- // spaces in an XML attribute, and a blank label is no section.
1029
- const blank = value => typeof value !== 'string' || value.trim() === '';
1030
- const normalized = value => blank(value) ? '' : value.replace(/[\t\n\r]/g, ' ');
1031
- const sameSection = (a, b) => normalized(a) === normalized(b);
1035
+ // Sections: two sources name a slide's section, the native section list (PowerPoint's sections pane) and the stored
1036
+ // OPF_SLIDE_V1 value. The list is native data and wins, except that the stored label stands while the list still names it:
1037
+ // a label is an XML attribute, so tab, LF and CR come back from the list as spaces (src/sections.js) and compare as such.
1038
+ // Without a list the stored value is the fallback. (A footer's `{{slide.section}}` is only the token, FA-31: no footer
1039
+ // shows the section text any more.)
1040
+ const spaced = value => (typeof value !== 'string' || value.trim() === '') ? '' : value.replace(/[\t\n\r]/g, ' ');
1032
1041
  const reconcileSection = (index, stored) => {
1033
- const shown = imported.slides[index]?.section;
1034
1042
  if (!nativeSections) {
1035
- if (stored !== undefined && shown === undefined) group(`slides.${index}.section`, [set(['slides', index, 'section'], clone(stored))]);
1043
+ if (stored !== undefined) group(`slides.${index}.section`, [set(['slides', index, 'section'], clone(stored))]);
1036
1044
  return;
1037
1045
  }
1038
1046
  const listed = nativeSections[index];
1039
- if (sameSection(shown, listed) || (stored !== undefined && sameSection(listed, stored) && shown !== undefined)) return;
1040
- if (shown !== undefined) report({code: 'section-reference-changed', path: `slides.${index}.section`, message: `The footer of slides.${index} shows section '${shown}' but PowerPoint's section list ${listed === undefined ? 'has the slide in no named section' : `names '${listed}'`}; the section list wins and the footer keeps its current text.`});
1041
- group(`slides.${index}.section`, [listed === undefined ? remove(['slides', index, 'section']) : set(['slides', index, 'section'], listed)]);
1047
+ // A slide in no named section: the list cannot say a blank label, so the stored blank one (if authored) returns.
1048
+ if (listed === undefined) {
1049
+ if (stored !== undefined && spaced(stored) === '') group(`slides.${index}.section`, [set(['slides', index, 'section'], clone(stored))]);
1050
+ return;
1051
+ }
1052
+ group(`slides.${index}.section`, [set(['slides', index, 'section'], stored !== undefined && spaced(stored) === spaced(listed) ? clone(stored) : listed)]);
1042
1053
  };
1043
1054
 
1044
1055
  // Without document provenance (a slide pasted into another deck, or a
@@ -1189,21 +1200,13 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
1189
1200
  }
1190
1201
 
1191
1202
  // Authoring metadata has no native counterpart. Fields read from native
1192
- // content (the furniture organization name, linked socials) win over their
1193
- // stored values; stored-only fields (logo, role ...) return.
1203
+ // content (linked socials) win over their stored values; stored-only fields
1204
+ // (name, logo, role ...) return.
1194
1205
  const metadata = document.metadata ?? {};
1195
1206
  // The exporter writes the default creator when no author was authored: with the document record present, that default is not the author.
1196
1207
  if (metadata.author === undefined && imported.author === DEFAULT_AUTHOR) group('author', [remove(['author'])]);
1197
1208
  for (const key of METADATA) {
1198
1209
  if (metadata[key] === undefined) continue;
1199
- if (key === 'organization' && organizationConflict) {
1200
- report({code: 'metadata-reference-changed', path: 'organization', message: 'Slides now show different organization names in their furniture, so the stored organization was not restored; each slide keeps its visible text.'});
1201
- continue;
1202
- }
1203
- if (key === 'speaker' && speakerConflict) {
1204
- report({code: 'metadata-reference-changed', path: 'speaker', message: 'Slides now show different speaker names in their furniture, so the stored speaker was not restored; each slide keeps its visible text.'});
1205
- continue;
1206
- }
1207
1210
  if (key === 'author') {
1208
1211
  // Native evidence: the stored authored form only stands for the creator it was joined into.
1209
1212
  if (joinAuthors(imported.author) === joinAuthors(metadata.author)) group('author', [set(['author'], clone(metadata.author))]);
@@ -1222,14 +1225,6 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
1222
1225
  else if (Array.isArray(value)) value[index] = {...list[index], ...merge(list[index], clone(observed))};
1223
1226
  else value = {...value, ...merge(value, clone(observed))};
1224
1227
  }
1225
- if (key === 'speaker' && object(imported.speaker)) {
1226
- // The furniture speaker line (name and title) is native content and wins over the stored speaker with the same id.
1227
- const observed = imported.speaker, list = array(value), index = list.findIndex(item => object(item) && item.id === observed.id);
1228
- const merged = stored => { const {title: _title, ...rest} = stored; return {...rest, ...clone(observed)}; };
1229
- if (index < 0) value = clone(observed);
1230
- else if (Array.isArray(value)) value[index] = merged(list[index]);
1231
- else value = merged(value);
1232
- }
1233
1228
  group(key, [set([key], value)]);
1234
1229
  }
1235
1230
 
@@ -107,19 +107,51 @@ export function parseDate(text, pattern) {
107
107
  return formatDate(iso, pattern) === text ? iso : null;
108
108
  }
109
109
 
110
- export function formatSlideNumber(format, current, total) {
111
- if (typeof format !== 'string' || !format.includes('{current}') || (format.includes('{total}') && !Number.isSafeInteger(total))) return null;
112
- return format.split(/(\{current\}|\{total\})/).map(piece => piece === '{current}' ? String(current) : piece === '{total}' ? String(total) : piece).join('');
110
+ /**
111
+ * Live field ranges for one accepted furniture text part: core's `part.fields` (one `slideNumber` per substituted
112
+ * `{{slide.number}}`, one `date` for a current date), as half-open UTF-16 offsets into `part.text`.
113
+ */
114
+ export function furniturePartFields(part) {
115
+ return Array.isArray(part.fields) ? part.fields : [];
113
116
  }
114
117
 
118
+ /** True when a core text part carries at least one slide-number field. */
119
+ export const hasSlideNumberField = part => furniturePartFields(part).some(field => field.type === 'slideNumber');
120
+
121
+ export const SLIDE_NUMBER_TOKEN = '{{slide.number}}';
122
+ // The slide-scoped tokens core substitutes per slide (core SLIDE_TOKEN_BODY): typed text that spells one must be
123
+ // escaped, or the next export would turn it into a live value.
124
+ const literalSlideToken = /\{\{(?=\s*(?:slide(?:\.[A-Za-z0-9_-]+){1,2}|deck\.slideCount)\s*(?:\|[^{}]*)?\}\})/g;
125
+ const slideTokenUsed = /\{\{\s*(?:slide\.|deck\.slideCount)/;
126
+ /** True when a string mentions a slide-scoped token (escaped or not). */
127
+ export const mentionsSlideToken = text => typeof text === 'string' && slideTokenUsed.test(text);
128
+ const anyToken = /(\\)?\{\{\s*([^{}|]*?)\s*(?:\|[^{}]*)?\}\}/g;
129
+ const builtinName = /^(?:speakers|(?:deck|speaker|organization|slide)(?:\.[A-Za-z0-9_-]+){1,2})$/;
115
130
  /**
116
- * Live field ranges for one accepted furniture text part. Core >= FF-27 supplies
117
- * `part.fields`; earlier cores only supply a bare generated number, which is
118
- * still a whole slide-number field.
131
+ * True when a zone text uses {{ }} and every unescaped token in it is a built-in variable (core's pattern: deck.*, speaker.*,
132
+ * speakers, organization.*, slide.*, deck.slideCount). Such an authored text is stored in the furniture manifest because the deck-wide
133
+ * pass resolves its built-ins before export; a text with a user variable ({{customer}}) is not: the filled deck is what round-trips.
119
134
  */
120
- export function furniturePartFields(part) {
121
- if (Array.isArray(part.fields)) return part.fields;
122
- return part.field === 'slideNumber' && /^\d+$/.test(part.text) ? [{type: 'slideNumber', start: 0, end: part.text.length}] : [];
135
+ export function isBuiltinTemplate(text) {
136
+ if (typeof text !== 'string' || !text.includes('{{')) return false;
137
+ for (const match of text.matchAll(anyToken)) if (!match[1] && !builtinName.test(match[2])) return false;
138
+ return true;
139
+ }
140
+ export const escapeSlideTokens = text => text.replace(literalSlideToken, '\\{{');
141
+
142
+ /**
143
+ * The zone `text` a native footer or header line stands for: every native slide-number field (`{type, text, start}`,
144
+ * `start` an offset into `text`) becomes `{{slide.number}}`, and typed words that spell a slide-scoped token stay
145
+ * literal. A field whose words are not at its recorded offset is left as the words it shows.
146
+ */
147
+ export function slideNumberTemplate(text, nativeFields) {
148
+ let cursor = 0, out = '';
149
+ for (const field of [...nativeFields].filter(field => field.type === 'slidenum').sort((a, b) => a.start - b.start)) {
150
+ if (!(field.start >= cursor) || text.slice(field.start, field.start + field.text.length) !== field.text) continue;
151
+ out += escapeSlideTokens(text.slice(cursor, field.start)) + SLIDE_NUMBER_TOKEN;
152
+ cursor = field.start + field.text.length;
153
+ }
154
+ return out + escapeSlideTokens(text.slice(cursor));
123
155
  }
124
156
 
125
157
  /** Native <a:fld> type for a core field, or null when the value must stay fixed text. */
@@ -1,7 +1,7 @@
1
1
  import {XMLParser} from 'fast-xml-parser';
2
2
  import {attachTextTags, decodeTextTag, encodeTextTag} from './code-provenance.js';
3
3
  import {sourceLineParagraphs} from './text-provenance.js';
4
- import {DEFAULT_DATE_FORMAT, NATIVE_DATE_FIELDS, formatSlideNumber, parseDate} from './furniture-fields.js';
4
+ import {DEFAULT_DATE_FORMAT, NATIVE_DATE_FIELDS, isBuiltinTemplate, mentionsSlideToken, parseDate, slideNumberTemplate} from './furniture-fields.js';
5
5
  import {schemas} from '@openpresentation/opf';
6
6
  import {LOGO_TAG} from './logo-provenance.js';
7
7
  import {NATIVE_PLACEHOLDERS, footerValue, isNativePlaceholderType, readNativePlaceholders} from './native-furniture.js';
@@ -15,8 +15,8 @@ const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal: true});
15
15
  const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, trimValues: false});
16
16
  const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
17
17
  const kinds = ['header', 'footer'], zones = ['left', 'center', 'right'];
18
- const fields = ['text', 'image', 'organization', 'speaker', 'socials', 'section', 'slideNumber', 'date'];
19
- const settings = ['slideNumberFormat', 'dateFormat'];
18
+ const fields = ['text', 'image', 'socials', 'date'];
19
+ const settings = ['dateFormat'];
20
20
  const dateFormatForField = Object.fromEntries(Object.entries(NATIVE_DATE_FIELDS).map(([format, type]) => [type, format]));
21
21
  // Platform keys follow the Socials schema's propertyNames pattern exactly, so
22
22
  // every key a valid document can carry re-imports (and nothing else does).
@@ -72,12 +72,11 @@ function unchangedStaticDate(marker, format, ordered, paragraphs) {
72
72
  }), 'Current static date text or boundaries differ from the exported lines.');
73
73
  }
74
74
 
75
- // This manifest contains topology, identities, inactive flags and format
76
- // settings only. Current native shapes supply all words, image bytes and alt
77
- // text, even after edits; a recorded format is kept only while the current text
78
- // still matches it exactly.
79
- export function furnitureManifest(presentation, slide, layout, slideIndex, staticDates = new Map(), natives = new Map()) {
80
- const definitions = {}, formats = {};
75
+ // This manifest contains topology, identities, inactive flags, format settings and the authored text of a zone whose
76
+ // `text` uses built-in variables (`templates`, FA-31; `authored` is the document as given to toPptx, before the deck-wide pass). Current native shapes supply all words, image bytes and
77
+ // alt text, even after edits; a recorded format or template is kept only while the current text still matches it exactly.
78
+ export function furnitureManifest(presentation, slide, layout, slideIndex, staticDates = new Map(), natives = new Map(), authored) {
79
+ const definitions = {}, formats = {}, templates = {};
81
80
  for (const kind of kinds) {
82
81
  const local = slide.design?.[kind] !== undefined;
83
82
  const source = local ? slide.design[kind] : presentation.design?.[kind];
@@ -91,13 +90,19 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
91
90
  }
92
91
  const format = Object.fromEntries(settings.filter(setting => typeof source[zone][setting] === 'string').map(setting => [setting, source[zone][setting]]));
93
92
  if (Object.keys(format).length) formats[`${kind}.${zone}`] = format;
93
+ // The authored text with its built-in tokens, so import restores them while the words still match this slide:
94
+ // {{slide.number}} is a native field; {{deck.slideCount}} and {{slide.section}} are fixed words; the deck-wide built-ins
95
+ // ({{organization.name}}) were resolved before export. A text that also uses a user variable is not stored (the filled deck
96
+ // round-trips); then only a slide-scoped token in the resolved text is.
97
+ const original = (local ? authored?.slide?.design?.[kind] : authored?.presentation?.design?.[kind])?.[zone]?.text;
98
+ if (isBuiltinTemplate(original)) templates[`${kind}.${zone}`] = original;
99
+ else if (mentionsSlideToken(source[zone].text)) templates[`${kind}.${zone}`] = source[zone].text;
94
100
  }
95
101
  definitions[kind] = {scope: local ? 'local' : 'global', value};
96
102
  }
97
103
  if (!Object.keys(definitions).length) return null;
98
104
  const organizations = array(presentation.organization);
99
105
  const organization = organizations.find(item => item.role === 'primary') ?? organizations[0];
100
- const speaker = array(presentation.speaker)[0];
101
106
  // Generated deck logos (logo: true) are listed beside the topology, never inside it: the part list and the field
102
107
  // definitions are validated strictly by every released importer (0.11.6 and earlier reject an unknown field there),
103
108
  // so they keep describing only what those importers know. `drawn` is false when no logo resolved at export.
@@ -109,13 +114,11 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
109
114
  }
110
115
  }
111
116
  return {v: 1, role: 'slide', group: String(slideIndex), definitions, ...(Object.keys(formats).length ? {formats} : {}),
112
- ...(layout.parts.some(part => part.field === 'organization' || part.field === 'socials') ? {organizationId: organization?.id} : {}),
113
- ...(layout.parts.some(part => part.field === 'speaker') ? {speakerId: speaker?.id} : {}),
117
+ ...(Object.keys(templates).length ? {templates} : {}),
118
+ ...(layout.parts.some(part => part.field === 'socials') ? {organizationId: organization?.id} : {}),
114
119
  parts: layout.parts.flatMap((part, index) => part.field === 'logo' ? [] : [{kind: part.kind, zone: part.zone, field: part.field,
115
120
  type: part.type, count: part.type === 'image' ? 1 : part.fit.lines.length,
116
121
  ...(part.field === 'socials' ? {socials: socialLines(part)} : {}),
117
- // The speaker part is one line, "Name, Title" or "Name": the name's length lets import split it again (lengths, never words).
118
- ...(part.field === 'speaker' ? {nameLength: speaker.name.length} : {}),
119
122
  ...(staticDates.has(index) ? {staticDate: staticDates.get(index)} : {}),
120
123
  // RR-11: this part is a native PowerPoint placeholder (dt, ftr or sldNum); deleting it in PowerPoint's dialog is then intent.
121
124
  ...(natives.has(index) ? {ph: natives.get(index)} : {})}]),
@@ -194,6 +197,14 @@ function validateManifest(manifest) {
194
197
  seen.add(slot);
195
198
  }
196
199
  }
200
+ if (manifest.templates !== undefined) {
201
+ check(object(manifest.templates), 'Invalid furniture texts.');
202
+ for (const [slot, template] of Object.entries(manifest.templates)) {
203
+ const [kind, zone, extra] = slot.split('.');
204
+ check(extra === undefined && manifest.definitions[kind]?.value?.[zone]?.text === 'literal' && typeof template === 'string' &&
205
+ template.length <= 100000 && template.includes('{{'), 'Invalid furniture text.');
206
+ }
207
+ }
197
208
  if (manifest.formats !== undefined) {
198
209
  check(object(manifest.formats), 'Invalid furniture formats.');
199
210
  for (const [slot, format] of Object.entries(manifest.formats)) {
@@ -213,13 +224,9 @@ function validateManifest(manifest) {
213
224
  && part.socials.every(line => object(line) && platformId.test(line.platform) && schemes.includes(line.scheme))
214
225
  && new Set(part.socials.map(line => line.platform)).size === part.socials.length, 'Invalid social profile lines.');
215
226
  else check(part.socials === undefined, 'Unexpected social profile lines.');
216
- if (part.field === 'speaker') check(Number.isSafeInteger(part.nameLength) && part.nameLength >= 1 && part.nameLength <= 100000, 'Invalid speaker name length.');
217
- else check(part.nameLength === undefined, 'Unexpected speaker name length.');
218
227
  expected.delete(key(part));
219
228
  }
220
- if (manifest.parts.some(part => part.field === 'speaker')) check(typeof manifest.speakerId === 'string' && /^[a-zA-Z0-9_-]+$/.test(manifest.speakerId), 'Invalid speaker identity.');
221
- else check(manifest.speakerId === undefined, 'Unexpected speaker identity.');
222
- if (manifest.parts.some(part => part.field === 'organization' || part.field === 'socials')) check(typeof manifest.organizationId === 'string' && /^[a-zA-Z0-9_-]+$/.test(manifest.organizationId), 'Invalid organization identity.');
229
+ if (manifest.parts.some(part => part.field === 'socials')) check(typeof manifest.organizationId === 'string' && /^[a-zA-Z0-9_-]+$/.test(manifest.organizationId), 'Invalid organization identity.');
223
230
  }
224
231
 
225
232
  // The logo tags of a picture: any other OPF tag next to one is not an identity conflict for the furniture reader.
@@ -233,7 +240,37 @@ function readLogoTags(container, relationships, entries) {
233
240
  return found;
234
241
  }
235
242
 
236
- function readSlide(context, entries, slideIndex, slideCount, report, taggedText) {
243
+ // The native fields of the lines of one tagged text part, with offsets into the text sourceLineParagraphs joins from them.
244
+ function orderedFields(ordered, paragraphs) {
245
+ const fields = [];
246
+ let base = 0;
247
+ for (const item of ordered) {
248
+ const current = paragraphs[item.index] ?? [];
249
+ let at = base;
250
+ for (const paragraph of current) {
251
+ for (const field of paragraph.fields ?? []) fields.push({...field, start: at + field.start});
252
+ at += paragraph.text.length + 1;
253
+ }
254
+ base += current.map(paragraph => paragraph.text).join('\n').length + item.data.separator.length;
255
+ }
256
+ return fields;
257
+ }
258
+
259
+ // A zone `text`: the stored template while it still draws exactly the words now on the slide (`position.draw` resolves its
260
+ // deck-wide built-ins from the document's own metadata and its slide-scoped tokens for this slide); otherwise the words as they
261
+ // stand, with each native slide-number field as `{{slide.number}}`.
262
+ function importedZoneText(text, nativeFields, template, position) {
263
+ if (template !== undefined) {
264
+ const drawn = position.draw(template, position);
265
+ if (typeof drawn !== 'string') return slideNumberTemplate(text, nativeFields);
266
+ // A section name is an XML attribute in the native list: tab, LF and CR come back as spaces (src/sections.js).
267
+ const spaced = value => value.replace(/[\t\n\r]/g, ' ');
268
+ if (drawn === text || (position.section !== undefined && spaced(drawn) === spaced(text))) return template;
269
+ }
270
+ return slideNumberTemplate(text, nativeFields);
271
+ }
272
+
273
+ function readSlide(context, entries, slideIndex, slideCount, report, taggedText, section, draw) {
237
274
  const {root, shapes, paragraphs, pictures, relationships, readPicture} = context;
238
275
  const candidates = {};
239
276
  const records = [];
@@ -277,7 +314,7 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
277
314
  if (value !== false) for (const zone of zones) if (definition.value[zone] !== undefined) {
278
315
  value[zone] = Object.fromEntries(Object.entries(definition.value[zone]).filter(([, flag]) => flag === false));
279
316
  }
280
- const candidate = {scope: definition.scope, value, text: [], pictures: [], organizations: [], speakers: [], socials: [], sections: []};
317
+ const candidate = {scope: definition.scope, value, text: [], pictures: [], socials: []};
281
318
  // logo: true returns as its flag; the generated picture is consumed, never content or an image field.
282
319
  for (const logo of (manifest.logos ?? []).filter(item => item.kind === kind)) {
283
320
  const found = logoPictures.filter(item => item.data.v === 1 && item.data.group === manifest.group && item.data.furniture === kind && item.data.zone === logo.zone);
@@ -305,21 +342,12 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
305
342
  }
306
343
  const text = sourceLineParagraphs(ordered, paragraphs)[0].text;
307
344
  const format = manifest.formats?.[`${kind}.${part.zone}`] ?? {};
308
- if (part.field === 'slideNumber') {
309
- // The live field renumbers in PowerPoint; any fixed text around it
310
- // must still match the recorded format at this slide's position.
311
- check(text === formatSlideNumber(format.slideNumberFormat ?? '{current}', slideIndex + 1, slideCount), 'Current slide number differs from its position; retain the visible number as ordinary text.');
312
- if (format.slideNumberFormat !== undefined) value[part.zone].slideNumberFormat = format.slideNumberFormat;
313
- }
314
- if (part.field === 'section') candidate.sections.push(text);
315
- if (part.field === 'organization') candidate.organizations.push({id: manifest.organizationId, name: text});
316
- if (part.field === 'speaker') candidate.speakers.push(importedSpeaker(manifest.speakerId, text, part.nameLength));
317
345
  if (part.field === 'socials') {
318
346
  const lines = text.split('\n');
319
347
  check(lines.length === part.socials.length && lines.every(line => line.trim()), 'Current social profile lines no longer match their platforms.');
320
348
  candidate.socials.push({id: manifest.organizationId, socials: Object.fromEntries(part.socials.map((line, index) => [line.platform, line.scheme + lines[index]]))});
321
349
  }
322
- value[part.zone][part.field] = part.field === 'text' ? text : part.field === 'date' ? importedDate(definition.value[part.zone].date, format, text, ordered.flatMap(record => (paragraphs[record.index] ?? []).flatMap(paragraph => paragraph.fields ?? [])), value[part.zone], part.staticDate, ordered, paragraphs, message => report(slideIndex, `${kind}.${part.zone}.date: ${message}`)) : true;
350
+ value[part.zone][part.field] = part.field === 'text' ? importedZoneText(text, orderedFields(ordered, paragraphs), manifest.templates?.[`${kind}.${part.zone}`], {index: slideIndex, count: slideCount, section, draw}) : part.field === 'date' ? importedDate(definition.value[part.zone].date, format, text, ordered.flatMap(record => (paragraphs[record.index] ?? []).flatMap(paragraph => paragraph.fields ?? [])), value[part.zone], part.staticDate, ordered, paragraphs, message => report(slideIndex, `${kind}.${part.zone}.date: ${message}`)) : true;
323
351
  candidate.text.push(...ordered.map(record => record.index));
324
352
  }
325
353
  }
@@ -339,14 +367,6 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
339
367
  return candidates;
340
368
  }
341
369
 
342
- // The generated speaker line is "Name, Title" (or "Name"). The recorded name length splits it again; a line edited in
343
- // PowerPoint that no longer has the separator at that position is the visible text as the name, with no title.
344
- function importedSpeaker(id, text, nameLength) {
345
- const rest = text.slice(nameLength);
346
- if (text.length > nameLength && rest.startsWith(', ') && rest.length > 2) return {id, name: text.slice(0, nameLength), title: rest.slice(2)};
347
- return {id, name: text};
348
- }
349
-
350
370
  // A current native date field wins. Full-mode static-wrap evidence can recover
351
371
  // generated OPF intent without turning the existing PPTX text into a live field. A fixed
352
372
  // date keeps its ISO value and pattern only when the current text round-trips.
@@ -374,37 +394,21 @@ function importedDate(flag, format, text, nativeFields, zone, marker, ordered, p
374
394
  // Reconcile metadata before consuming any shapes. Global inheritance is promoted
375
395
  // only when every slide has a valid definition/override and all inherited values
376
396
  // agree. A missing tag must never cause another slide's furniture to reappear.
377
- export function importFurniture(contexts, entries, onDiagnostic) {
397
+ // `options.sections` are the native section names by slide index (a slide's `{{slide.section}}` is checked against them),
398
+ // `options.organizations` the stored organizations (OPF_DOCUMENT_V1) a socials part can name by id, and `options.draw(template,
399
+ // {index, count, section})` draws a stored zone text for a slide (its built-in variables resolved from the document's metadata).
400
+ export function importFurniture(contexts, entries, onDiagnostic, {sections = [], organizations = [], draw = () => undefined} = {}) {
378
401
  const report = (index, message) => onDiagnostic?.({code: 'invalid-furniture-provenance', path: `slides.${index}.design`, message: `${message} Ordinary import retains current native content; no old source words are restored.`});
379
402
  const taggedText = contexts.map(() => new Set());
380
- const candidates = contexts.map((context, index) => readSlide(context, entries, index, contexts.length, report, taggedText[index]));
381
- const organizations = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.organizations));
382
- const organizationConflict = organizations.some(item => !same(item, organizations[0]));
383
- if (organizationConflict) {
384
- for (const [index, slide] of candidates.entries()) for (const kind of kinds) if (slide[kind]?.organizations.length) {
385
- delete slide[kind]; report(index, `${kind}: Current organization metadata disagrees across repeated fields.`);
386
- }
387
- }
388
- const speakers = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.speakers));
389
- const speakerConflict = speakers.some(item => !same(item, speakers[0]));
390
- if (speakerConflict) {
391
- for (const [index, slide] of candidates.entries()) for (const kind of kinds) if (slide[kind]?.speakers.length) {
392
- delete slide[kind]; report(index, `${kind}: Current speaker metadata disagrees across repeated fields.`);
393
- }
394
- }
395
- for (const [index, slide] of candidates.entries()) {
396
- const sections = Object.values(slide).flatMap(candidate => candidate.sections);
397
- if (sections.some(section => section !== sections[0])) for (const kind of kinds) if (slide[kind]?.sections.length) {
398
- delete slide[kind]; report(index, `${kind}: Current section metadata disagrees on this slide.`);
399
- }
400
- }
401
- // Social profiles belong to the organization named by repeated furniture; without
402
- // that name (or with disagreeing values) the lines stay ordinary current text.
403
- const owner = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.organizations))[0];
403
+ const candidates = contexts.map((context, index) => readSlide(context, entries, index, contexts.length, report, taggedText[index], sections[index], draw));
404
+ // Social profiles belong to the organization the manifest names by id, which only the stored document metadata can supply
405
+ // (no furniture part shows the organization's name any more); without it (or with disagreeing values) the lines stay
406
+ // ordinary current text.
404
407
  const socials = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.socials));
408
+ const owner = socials.length ? organizations.find(item => item.id === socials[0].id) : undefined;
405
409
  if (socials.length && (!owner || socials.some(item => item.id !== owner.id || !same(item, socials[0])))) {
406
410
  for (const [index, slide] of candidates.entries()) for (const kind of kinds) if (slide[kind]?.socials.length) {
407
- delete slide[kind]; report(index, `${kind}: ${owner ? 'Current social profile metadata disagrees across repeated fields.' : 'Social profiles need a repeated organization name to rebuild organization metadata.'}`);
411
+ delete slide[kind]; report(index, `${kind}: ${owner ? 'Current social profile metadata disagrees across repeated fields.' : 'Social profiles need the stored organization metadata to rebuild organization.socials.'}`);
408
412
  }
409
413
  }
410
414
  // RR-11: PowerPoint's own date, footer and slide-number placeholders that no OPF tag claims (a deck authored in
@@ -422,7 +426,7 @@ export function importFurniture(contexts, entries, onDiagnostic) {
422
426
  }
423
427
  if (!accepted.length) return;
424
428
  const merged = footerValue(accepted), indexes = accepted.map(item => item.index);
425
- if (!existing) slide.footer = {scope: 'native', value: merged, text: indexes, pictures: [], organizations: [], speakers: [], socials: [], sections: []};
429
+ if (!existing) slide.footer = {scope: 'native', value: merged, text: indexes, pictures: [], socials: []};
426
430
  else {
427
431
  if (existing.value === false) existing.value = {};
428
432
  for (const zone of Object.keys(merged)) existing.value[zone] = {...existing.value[zone], ...merged[zone]};
@@ -430,20 +434,13 @@ export function importFurniture(contexts, entries, onDiagnostic) {
430
434
  }
431
435
  });
432
436
  const result = {design: {}, slides: candidates.map((slide, index) => {
433
- const values = Object.values(slide), sections = values.flatMap(candidate => candidate.sections);
437
+ const values = Object.values(slide);
434
438
  return {design: Object.fromEntries(Object.entries(slide).map(([kind, candidate]) => [kind, candidate.value])),
435
- ...(sections.length ? {section: sections[0]} : {}),
436
439
  text: new Set(values.flatMap(candidate => candidate.text)), pictures: new Set(values.flatMap(candidate => candidate.pictures)), taggedText: taggedText[index]};
437
440
  })};
438
- const validOrganizations = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.organizations));
439
- if (validOrganizations.length) result.organization = validOrganizations[0];
441
+ // The organization the socials belong to: its stored identity, with the profiles the slides show now.
440
442
  const validSocials = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.socials));
441
- if (result.organization && validSocials.length) result.organization = {...result.organization, socials: validSocials[0].socials};
442
- // Stored document metadata (FF-32) must not override disagreeing visible names.
443
- if (organizationConflict) result.organizationConflict = true;
444
- const validSpeakers = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.speakers));
445
- if (validSpeakers.length) result.speaker = validSpeakers[0];
446
- if (speakerConflict) result.speakerConflict = true;
443
+ if (owner && validSocials.length) result.organization = {id: owner.id, name: owner.name, socials: validSocials[0].socials};
447
444
  for (const kind of kinds) {
448
445
  const inherited = candidates.map(slide => slide[kind]).filter(candidate => candidate?.scope === 'global');
449
446
  if (inherited.length && candidates.every(slide => slide[kind]) && inherited.every(candidate => same(candidate.value, inherited[0].value))) {
package/dist/index.d.ts CHANGED
@@ -172,7 +172,7 @@ export interface ToPptxOptions {
172
172
  }
173
173
 
174
174
  export interface FromPptxOptions {
175
- /** Reports native details that import cannot preserve, including code provenance fallback/reflow and grouped text transforms. Table paths identify native frame and row/cell indexes (including headers). Stored catalog references that no longer match the package report `design-reference-changed` / `layout-reference-changed` at the reference path; a stored slide layout reference that resolves neither in the stored document catalogs, nor in the slide's own stored record, nor in `catalogs` reports `unresolved-reference`; a slide whose imported blocks no longer fit the stored content structure reports `content-structure-changed` at `slides.N`, a block id repeated by a duplicated slide `duplicate-block-id`, and a footer section text that disagrees with PowerPoint's section list `section-reference-changed` (docs/document-roundtrip.md). */
175
+ /** Reports native details that import cannot preserve, including code provenance fallback/reflow and grouped text transforms. Table paths identify native frame and row/cell indexes (including headers). Stored catalog references that no longer match the package report `design-reference-changed` / `layout-reference-changed` at the reference path; a stored slide layout reference that resolves neither in the stored document catalogs, nor in the slide's own stored record, nor in `catalogs` reports `unresolved-reference`; a slide whose imported blocks no longer fit the stored content structure reports `content-structure-changed` at `slides.N`, a block id repeated by a duplicated slide `duplicate-block-id` (docs/document-roundtrip.md). */
176
176
  onDiagnostic?: (diagnostic: {code: string; path: string; message: string}) => void;
177
177
  fallbackName?: string;
178
178
  schema?: string;
package/dist/index.js CHANGED
@@ -27,10 +27,10 @@ import {attachQuoteTags,quoteManifest,importQuoteGroups,quotePhotoName} from './
27
27
  import {attachFurnitureTags, furnitureManifest, importFurniture, manifestPartIndex, staticDateFallback} from './furniture-provenance.js';
28
28
  import {restoreRunColors} from './run-colors.js';
29
29
  import {joinWrappedText} from './content-topology.js';
30
- import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, recordContentTopology, restoreDocumentProvenance, joinAuthors, splitAuthors, slideListBreaks, DEFAULT_AUTHOR} from './document-provenance.js';
30
+ import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, recordContentTopology, restoreDocumentProvenance, joinAuthors, splitAuthors, slideListBreaks, storedMetadata, DEFAULT_AUTHOR, DEFAULT_TITLE} from './document-provenance.js';
31
31
  import {INVALID_XML_CHARACTER, nativeSections, writeSectionList} from './sections.js';
32
32
  import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
33
- import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, writeNativeMasters} from './native-furniture.js';
33
+ import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, nativePlaceholderForPart, writeNativeMasters} from './native-furniture.js';
34
34
  import {importImageOrientation} from './image-import.js';
35
35
  import {extractSignals, normalizeSignalOptions, themeFactsFor} from './import-signals.js';
36
36
  import {placeImages, importImages, imageName, imageOverlayName, backgroundImageName, backgroundOverlayName, IMAGE_TREATMENT_KEYS, BACKGROUND_IMAGE_KEYS} from './image-provenance.js';
@@ -59,6 +59,7 @@ import {
59
59
  isTemplate,
60
60
  OPFCatalogsOptionError,
61
61
  resolveSlideContext as resolveCoreSlideContext,
62
+ resolveSlideVariables,
62
63
  resolveVariables,
63
64
  validate
64
65
  } from "@openpresentation/opf";
@@ -236,7 +237,9 @@ export async function toPptx(input, options = {}) {
236
237
  // resolves nowhere instead of composing automatically or drawing with the engine default.
237
238
  checkCatalogs('toPptx', options.catalogs);
238
239
  options = {...options, strictReferences: options.strictReferences ?? options.strictAssets === true};
239
- const presentation = resolveTemplateInput(parseInput(input), options);
240
+ // The document as given, before the deck-wide variable pass: the furniture manifest records the authored header and footer text.
241
+ const authored = parseInput(input);
242
+ const presentation = resolveTemplateInput(authored, options);
240
243
  assertValidBoundary(presentation);
241
244
  // `options.fonts` is the fonts handle (the renderer's loadFonts()); the exporter reads its measurement, wrapped so the package names the
242
245
  // chosen family (FF-31). Internally `options.textMeasurement` is that wrapped measurement; it is not a public option.
@@ -280,6 +283,7 @@ export async function toPptx(input, options = {}) {
280
283
  context.furnitureManifests = new Map();
281
284
  context.nativeFurniture = new Map();
282
285
  context.hostDate = options.date;
286
+ context.authored = authored;
283
287
  context.codeTags = new Map();
284
288
  context.captionTags = new Map();
285
289
  context.footnoteTags = new Map();
@@ -415,10 +419,23 @@ export async function fromPptx(input, options = {}) {
415
419
  readPicture: picture => importPicture(entries, picture, slidePath, relationships,
416
420
  diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${index}.design`}))};
417
421
  });
418
- const furniture = importFurniture(furnitureContexts, entries, options.onDiagnostic);
422
+ // Native sections (PowerPoint's own section list, `Default Section` = none) name each slide's `{{slide.section}}` for the footer text check
423
+ // and, after the slides, are reconciled with the stored value in restoreDocumentProvenance.
424
+ const slideSections = nativeSections(presentationRoot, slideIdsInOrder(presentationRoot, presentationRels, entries, slidePaths));
425
+ const stored = storedMetadata(entries, presentationRoot, presentationRels);
426
+ // A stored zone text with built-in variables is drawn from the document's own metadata, as toPptx resolved it: the stored record
427
+ // (organization, speaker ...) and the observed name, description and author (a default the exporter writes is no value).
428
+ const importedMetadata = {
429
+ ...(imported.name && imported.name !== DEFAULT_TITLE ? {name: imported.name} : {}),
430
+ ...(imported.description ? {description: imported.description} : {}),
431
+ ...(imported.author && joinAuthors(imported.author) !== DEFAULT_AUTHOR ? {author: imported.author} : {}),
432
+ ...(stored.organization !== undefined ? {organization: stored.organization} : {}),
433
+ ...(stored.speaker !== undefined ? {speaker: stored.speaker} : {}),
434
+ };
435
+ const furniture = importFurniture(furnitureContexts, entries, options.onDiagnostic, {sections: slideSections ?? [], organizations: asArray(stored.organization).filter(item => isPlainObject(item) && typeof item.id === 'string' && typeof item.name === 'string').map(item => ({id: item.id, name: item.name})),
436
+ draw: (template, {index, count, section}) => drawFurnitureText(template, {index, count, section}, importedMetadata)});
419
437
  if (Object.keys(furniture.design).length) imported.design = {...imported.design, ...furniture.design};
420
438
  if (furniture.organization) imported.organization = furniture.organization;
421
- if (furniture.speaker) imported.speaker = furniture.speaker;
422
439
 
423
440
  // RR-54: the datasets recorded at export (OPF_DATASETS_V1). Chart and table frames restore their dataset references against
424
441
  // them, so the document carries them before document provenance validates the restored document.
@@ -429,9 +446,6 @@ export async function fromPptx(input, options = {}) {
429
446
  imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, {...options, rtlDeck: observedRtl(observedLanguage), deckLang: observedLanguage.lang, codeFamily: Object.keys(entries).some(path => /^ppt\/tags\/opf/i.test(path)) ? importedCodeFamily(imported, options.catalogs) : undefined, dataProvenance: {datasets}}, furniture.slides[index], furnitureContexts[index]));
430
447
  }
431
448
  if (datasets) imported.datasets = datasets;
432
- // Native sections (PowerPoint's own section list, `Default Section` = none)
433
- // are reconciled with the footer text and the stored value in restoreDocumentProvenance.
434
- const slideSections = nativeSections(presentationRoot, slideIdsInOrder(presentationRoot, presentationRels, entries, slidePaths));
435
449
  // A watermark carried identically by every slide is the deck's design.watermark.
436
450
  const carried = imported.slides.map(slide => slide.design?.watermark);
437
451
  if (carried.length && carried[0] && carried.every(value => JSON.stringify(value) === JSON.stringify(carried[0]))) {
@@ -456,7 +470,7 @@ export async function fromPptx(input, options = {}) {
456
470
  let slideProvenance = slidePaths.map(() => ({structure: "untagged"}));
457
471
  let restoredGroups = [];
458
472
  try {
459
- const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true, speakerConflict: furniture.speakerConflict === true,
473
+ const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels,
460
474
  // Stored layout ids also resolve in the host's registered catalogs (FA-23).
461
475
  catalogs: options.catalogs,
462
476
  nativeSections: slideSections,
@@ -614,6 +628,17 @@ function resolveRelationshipTarget(sourcePartPath, target) {
614
628
  return parts.join("/");
615
629
  }
616
630
 
631
+ // A header or footer text as the exporter drew it for one slide: deck-wide built-ins resolved from `metadata` (core
632
+ // resolveVariables), then the slide-scoped tokens for the slide's position, count and section. undefined when core refuses.
633
+ function drawFurnitureText(template, {index, count, section}, metadata) {
634
+ try {
635
+ const slide = resolveVariables({...metadata, slides: [{...(section !== undefined ? {section} : {}), text: template}]}).presentation.slides[0];
636
+ return resolveSlideVariables(slide, {slideNumber: index + 1, slideCount: count}).text;
637
+ } catch {
638
+ return undefined;
639
+ }
640
+ }
641
+
617
642
  function resolveSlidePaths(entries, presentationRoot, relationships) {
618
643
  const slideIds = asArray(presentationRoot["p:sldIdLst"]?.["p:sldId"]);
619
644
  const paths = [];
@@ -709,7 +734,6 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
709
734
  }
710
735
  if (imageBackground) slide.design = {background: imageBackground};
711
736
  if (Object.keys(furniture.design).length) slide.design = {...slide.design, ...furniture.design};
712
- if (furniture.section !== undefined) slide.section = furniture.section;
713
737
  nativeContext.imageBlocks = images.blocks;
714
738
  nativeContext.imagePictures = images.consumed;
715
739
  // An overlay of a background that was not recovered stays an ordinary shape.
@@ -1903,10 +1927,15 @@ function configurePresentation(pptx, presentation, context) {
1903
1927
  };
1904
1928
  }
1905
1929
 
1906
- async function addSlide(pptx, presentation, opfSlide, slideIndex, context, options) {
1930
+ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context, options) {
1907
1931
  options = {...options, textMeasurement: slideTextMeasurement(presentation, slideIndex, options)};
1908
1932
  const slide = pptx.addSlide();
1909
1933
  const slideContext = exportSlideContext(presentation, slideIndex, context, options);
1934
+ // FA-31: the slide is composed and drawn with its slide-scoped variables substituted ({{slide.number}}, {{slide.section}},
1935
+ // {{deck.slideCount}} for this position in the exported deck, in body text as fixed text). The authored slide still holds the
1936
+ // tokens: header and footer text is substituted by core's furniture layout (a slide number stays a native field), and the
1937
+ // furniture manifest records the authored text.
1938
+ const opfSlide = slideContext.core.slide;
1910
1939
  const { widthInches, heightInches } = slideContext.dimensions;
1911
1940
  // A slide with no layout, or one whose layout resolves nowhere, is composed with no layout record (automatic composition),
1912
1941
  // as in the preview; core reports the reference as unresolved-reference.
@@ -2009,7 +2038,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
2009
2038
  // Core composes furniture above all content and opf-render paints it last, so
2010
2039
  // spTree order (PowerPoint's z-order) matches: header/footer parts come after
2011
2040
  // every content item, and an overlapping footer stays visible over content.
2012
- await addFurniture(slide,presentation,opfSlide,geometry.furniture,slideContext,options,slideIndex);
2041
+ await addFurniture(slide,presentation,authoredSlide,geometry.furniture,slideContext,options,slideIndex);
2013
2042
 
2014
2043
  if (opfSlide.notes) {
2015
2044
  const notes = String(opfSlide.notes);
@@ -2772,7 +2801,7 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
2772
2801
  // The default footer band core composes for a deck with no footer of its own: where a footer added natively lands.
2773
2802
  function defaultFooterParts(composeOptions) {
2774
2803
  try {
2775
- const slide = {design: {footer: {left: {date: '2026-01-01'}, center: {text: 'Footer'}, right: {slideNumber: true}}}};
2804
+ const slide = {design: {footer: {left: {date: '2026-01-01'}, center: {text: 'Footer'}, right: {text: '{{slide.number}}'}}}};
2776
2805
  // Only the zone boxes and line height are used, and they do not depend on text widths: estimated measurement keeps the host's measurer (and its call count) out of this.
2777
2806
  return (composeSlide(slide, {...composeOptions, textMeasurement: undefined}).furniture?.parts ?? []).filter(part => part.kind === 'footer' && part.type === 'text');
2778
2807
  } catch {
@@ -2780,10 +2809,13 @@ function defaultFooterParts(composeOptions) {
2780
2809
  }
2781
2810
  }
2782
2811
 
2812
+ // The slide and deck as given to toPptx (variables not yet resolved), for the furniture manifest's authored header and footer text.
2813
+ const authoredFurniture = (context, slideIndex) => ({presentation: context.authored, slide: context.authored?.slides?.[slideIndex]});
2814
+
2783
2815
  async function addFurniture(slide,presentation,source,layout,context,options,slideIndex) {
2784
2816
  if(!layout){
2785
2817
  if(['header','footer'].some(kind=>(source.design?.[kind]??presentation.design?.[kind])))throw new OPFPptxError('missing-furniture-layout','Header/footer export requires coordinated core furniture geometry.',{path:`slides.${slideIndex}.design`});
2786
- const manifest = furnitureManifest(presentation, source, {parts: []}, slideIndex);
2818
+ const manifest = furnitureManifest(presentation, source, {parts: []}, slideIndex, undefined, undefined, authoredFurniture(context, slideIndex));
2787
2819
  if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
2788
2820
  return;
2789
2821
  }
@@ -2799,8 +2831,8 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
2799
2831
  else if (objectName) context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:manifestPartIndex(layout.parts, index)});
2800
2832
  }else{
2801
2833
  if(!part.fit?.sourceLines)throw new OPFPptxError('missing-furniture-layout','Repeated text requires accepted source lines from core.',{path:part.path});
2802
- // Slide numbers and current dates become native PowerPoint fields; {total}
2803
- // and fixed dates stay fixed text. A current date whose pattern has no
2834
+ // Slide numbers ({{slide.number}}) and current dates become native PowerPoint fields; {{deck.slideCount}},
2835
+ // {{slide.section}} and fixed dates stay fixed text. A current date whose pattern has no
2804
2836
  // en-US field type is written as its laid-out text.
2805
2837
  const fields=[];
2806
2838
  for(const field of furniturePartFields(part)){
@@ -2820,7 +2852,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
2820
2852
  if(natives.has(index))context.nativeFurniture.set(`OPF furniture ${slideIndex} part ${index} line 0`,natives.get(index));
2821
2853
  }
2822
2854
  }
2823
- const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates, natives);
2855
+ const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates, natives, authoredFurniture(context, slideIndex));
2824
2856
  if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
2825
2857
  }
2826
2858
 
@@ -3730,7 +3762,7 @@ function normalizeHex(value, fallback = "000000") {
3730
3762
  function writeNativeFurnitureMasters(output, context) {
3731
3763
  const size = /<p:sldSz\b[^>]*\bcx="(\d+)"[^>]*\bcy="(\d+)"/.exec(decodeText(output['ppt/presentation.xml'][0]));
3732
3764
  const defaults = new Map((context.defaultFooterOptions ? defaultFooterParts(context.defaultFooterOptions) ?? [] : []).flatMap(part => {
3733
- const ph = {date: 'dt', text: 'ftr', slideNumber: 'sldNum'}[part.field];
3765
+ const ph = nativePlaceholderForPart(part);
3734
3766
  const geometry = ph && defaultPlaceholderGeometry(part);
3735
3767
  return geometry ? [[ph, geometry]] : [];
3736
3768
  }));
@@ -16,7 +16,7 @@
16
16
  // or where core would draw a default footer when no slide has one.
17
17
 
18
18
  import {XMLParser} from 'fast-xml-parser';
19
- import {DEFAULT_DATE_FORMAT, NATIVE_DATE_FIELDS, formatDate} from './furniture-fields.js';
19
+ import {DEFAULT_DATE_FORMAT, NATIVE_DATE_FIELDS, formatDate, hasSlideNumberField, slideNumberTemplate} from './furniture-fields.js';
20
20
 
21
21
  const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal: true});
22
22
  const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, trimValues: false});
@@ -28,25 +28,33 @@ const EMU = 9525;
28
28
  export const NATIVE_PLACEHOLDERS = Object.freeze({
29
29
  dt: {field: 'date', name: 'Date Placeholder', sz: 'half', idx: 10, masterIdx: 2, zone: 'left', align: 'l'},
30
30
  ftr: {field: 'text', name: 'Footer Placeholder', sz: 'quarter', idx: 11, masterIdx: 3, zone: 'center', align: 'ctr'},
31
- sldNum: {field: 'slideNumber', name: 'Slide Number Placeholder', sz: 'quarter', idx: 12, masterIdx: 4, zone: 'right', align: 'r'},
31
+ sldNum: {field: 'text', name: 'Slide Number Placeholder', sz: 'quarter', idx: 12, masterIdx: 4, zone: 'right', align: 'r'},
32
32
  });
33
33
  const ORDER = ['dt', 'ftr', 'sldNum'];
34
- const phByField = {date: 'dt', text: 'ftr', slideNumber: 'sldNum'};
35
34
  export const isNativePlaceholderType = value => Object.hasOwn(NATIVE_PLACEHOLDERS, value);
36
- export const nativePlaceholderForField = field => phByField[field];
35
+ /**
36
+ * The placeholder type a core footer part maps to: a date is `dt`; a zone `text` with a slide-number field
37
+ * (`{{slide.number}}`) is `sldNum`, its words around the field fixed runs; any other zone `text` is `ftr`.
38
+ */
39
+ export function nativePlaceholderForPart(part) {
40
+ if (part.type !== 'text') return undefined;
41
+ if (part.field === 'date') return 'dt';
42
+ if (part.field !== 'text') return undefined;
43
+ return hasSlideNumberField(part) ? 'sldNum' : 'ftr';
44
+ }
37
45
  const ZONES = ['left', 'center', 'right'];
38
46
  const ALIGN = {left: 'l', center: 'ctr', right: 'r'};
39
47
 
40
48
  /**
41
49
  * Which accepted core parts of one slide become native placeholders: the first
42
- * footer text, the first footer date and the first footer slide number whose
43
- * text fits one accepted line. Returns Map<partIndex, 'dt'|'ftr'|'sldNum'>.
50
+ * footer text, the first footer date and the first footer text with a slide-number
51
+ * field whose text fits one accepted line. Returns Map<partIndex, 'dt'|'ftr'|'sldNum'>.
44
52
  */
45
53
  export function nativeFurnitureParts(layout) {
46
54
  const chosen = new Map(), taken = new Set();
47
55
  for (const [index, part] of (layout?.parts ?? []).entries()) {
48
- const ph = phByField[part.field];
49
- if (part.kind !== 'footer' || part.type !== 'text' || !ph || taken.has(ph)) continue;
56
+ const ph = nativePlaceholderForPart(part);
57
+ if (part.kind !== 'footer' || !ph || taken.has(ph)) continue;
50
58
  // One native shape holds one line; a part that wraps or breaks stays tagged shapes.
51
59
  if (part.fit?.sourceLines?.length !== 1 || part.fit.lines?.length !== 1 || !part.text || part.links?.length) continue;
52
60
  taken.add(ph);
@@ -252,8 +260,9 @@ function zoneOf(bounds, slideWidth, fallback) {
252
260
  /**
253
261
  * The footer one slide's native placeholders describe. `claimed` is the set of
254
262
  * shape indexes OPF provenance already consumed or reserved. Returns
255
- * {fields: [{zone, field, value, settings, index}], skipped: [index]} where `value`
256
- * is the OPF field value and `settings` the zone's `dateFormat`/`slideNumberFormat`.
263
+ * [{zone, field, value, settings, index, ph}] where `value` is the OPF field value (a zone
264
+ * `text` carries `{{slide.number}}` where the placeholder holds a native slide-number field)
265
+ * and `settings` the zone's `dateFormat`.
257
266
  */
258
267
  export function readNativePlaceholders(context, claimed, relationships, readPart) {
259
268
  const fields = [];
@@ -262,36 +271,44 @@ export function readNativePlaceholders(context, claimed, relationships, readPart
262
271
  if (!isNativePlaceholderType(ph) || claimed.has(index)) continue;
263
272
  const paragraphs = context.paragraphs[index] ?? [];
264
273
  const text = paragraphs.map(paragraph => paragraph.text).join('\n');
265
- const nativeFields = paragraphs.flatMap(paragraph => paragraph.fields ?? []);
274
+ // The native fields with their offsets into `text` (the paragraphs are joined by a newline).
275
+ const nativeFields = [];
276
+ let base = 0;
277
+ for (const paragraph of paragraphs) {
278
+ for (const field of paragraph.fields ?? []) nativeFields.push({...field, start: base + (field.start ?? text.indexOf(field.text, base) - base)});
279
+ base += paragraph.text.length + 1;
280
+ }
266
281
  const zone = zoneOf(inheritedBounds(shape, ph, context.path, relationships, readPart), context.slideWidth, NATIVE_PLACEHOLDERS[ph].zone);
267
282
  const properties = NATIVE_PLACEHOLDERS[ph];
268
283
  if (ph === 'sldNum') {
269
- const live = nativeFields.filter(field => field.type === 'slidenum');
270
- if (live.length !== 1) continue;
271
- const at = text.indexOf(live[0].text);
272
- const format = at < 0 ? undefined : text.slice(0, at) + '{current}' + text.slice(at + live[0].text.length);
273
- fields.push({index, zone, field: properties.field, value: true, settings: format && format !== '{current}' ? {slideNumberFormat: format} : {}});
284
+ // The words around a native slide-number field are the zone text and the field is `{{slide.number}}`.
285
+ if (!nativeFields.some(field => field.type === 'slidenum')) continue;
286
+ fields.push({index, zone, ph, field: properties.field, value: slideNumberTemplate(text, nativeFields), settings: {}});
274
287
  } else if (ph === 'dt') {
275
288
  if (!text.trim()) continue;
276
289
  const pattern = nativeFields.length === 1 && nativeFields[0].text === text ? dateFormatForField[nativeFields[0].type] : undefined;
277
- if (pattern) fields.push({index, zone, field: properties.field, value: true, settings: pattern === DEFAULT_DATE_FORMAT ? {} : {dateFormat: pattern}});
290
+ if (pattern) fields.push({index, zone, ph, field: properties.field, value: true, settings: pattern === DEFAULT_DATE_FORMAT ? {} : {dateFormat: pattern}});
278
291
  // A fixed date, or a field with no OPF pattern (a time, the master's datetimeFigureOut), keeps its current words.
279
- else fields.push({index, zone, field: properties.field, value: text, settings: {}});
292
+ else fields.push({index, zone, ph, field: properties.field, value: text, settings: {}});
280
293
  } else {
281
294
  if (!text.trim()) continue;
282
- fields.push({index, zone, field: properties.field, value: text, settings: {}});
295
+ fields.push({index, zone, ph, field: properties.field, value: slideNumberTemplate(text, nativeFields), settings: {}});
283
296
  }
284
297
  }
285
298
  return fields;
286
299
  }
287
300
 
288
- /** The OPF footer value for native placeholder fields: zones keyed left/center/right. */
301
+ /**
302
+ * The OPF footer value for native placeholder fields: zones keyed left/center/right. A footer text and a slide
303
+ * number in one zone are one `text`: the footer words, then the number's line (the zone stack's order).
304
+ */
289
305
  export function footerValue(fields) {
290
306
  const value = {};
291
- for (const item of fields) {
307
+ for (const item of [...fields].sort((a, b) => ORDER.indexOf(a.ph) - ORDER.indexOf(b.ph))) {
292
308
  value[item.zone] ??= {};
293
- value[item.zone][item.field] = item.value;
294
- Object.assign(value[item.zone], item.settings);
309
+ const zone = value[item.zone];
310
+ zone[item.field] = item.field === 'text' && typeof zone.text === 'string' ? `${zone.text}\n${item.value}` : item.value;
311
+ Object.assign(zone, item.settings);
295
312
  }
296
313
  return Object.fromEntries(ZONES.filter(zone => value[zone]).map(zone => [zone, value[zone]]));
297
314
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-pptx",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -54,7 +54,7 @@
54
54
  "test:metric": "node test/shared-metric.mjs && node test/metric-provenance.mjs",
55
55
  "test:font-variants": "node test/font-variants.mjs",
56
56
  "test:timeline": "node test/shared-timeline.mjs",
57
- "test:furniture": "node test/shared-furniture.mjs && node test/furniture-provenance.mjs && node test/furniture-fields.mjs && node test/furniture-wrapped-date.mjs && node test/socials-furniture.mjs && node test/native-furniture.mjs",
57
+ "test:furniture": "node test/shared-furniture.mjs && node test/furniture-provenance.mjs && node test/slide-variables.mjs && node test/furniture-fields.mjs && node test/furniture-wrapped-date.mjs && node test/socials-furniture.mjs && node test/native-furniture.mjs",
58
58
  "test:native-picture-edit-controls": "node test/compare-native-picture-edit-controls.mjs",
59
59
  "test:native-font-embed-controls": "node test/native-font-embed-controls.mjs",
60
60
  "test:native-mixed-edit-controls": "node test/native-mixed-edit-controls.mjs",
@@ -65,7 +65,7 @@
65
65
  },
66
66
  "dependencies": {
67
67
  "@node-projects/jszip": "4.3.0",
68
- "@openpresentation/opf": "^0.16.0",
68
+ "@openpresentation/opf": "^0.17.0",
69
69
  "@rgrove/parse-xml": "4.2.3",
70
70
  "fast-xml-parser": "^5.8.0",
71
71
  "fflate": "^0.8.3",
@@ -73,7 +73,7 @@
73
73
  "sharp": "0.35.5"
74
74
  },
75
75
  "peerDependencies": {
76
- "@openpresentation/opf-render": "^0.16.0"
76
+ "@openpresentation/opf-render": "^0.17.0"
77
77
  },
78
78
  "peerDependenciesMeta": {
79
79
  "@openpresentation/opf-render": {
@@ -92,7 +92,7 @@
92
92
  "@expo-google-fonts/roboto": "0.4.3",
93
93
  "@expo-google-fonts/roboto-mono": "0.4.2",
94
94
  "@expo-google-fonts/tinos": "0.4.2",
95
- "@openpresentation/opf-render": "0.16.0",
95
+ "@openpresentation/opf-render": "0.17.0",
96
96
  "@resvg/resvg-js": "2.6.2",
97
97
  "esbuild": "0.28.2",
98
98
  "playwright": "1.63.0"