@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 +5 -5
- package/dist/code-provenance.js +1 -1
- package/dist/document-provenance.js +30 -35
- package/dist/furniture-fields.js +41 -9
- package/dist/furniture-provenance.js +73 -76
- package/dist/index.d.ts +1 -1
- package/dist/index.js +50 -18
- package/dist/native-furniture.js +40 -23
- package/package.json +5 -5
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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).
|
package/dist/code-provenance.js
CHANGED
|
@@ -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,
|
|
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:
|
|
1022
|
-
// list
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
// the
|
|
1026
|
-
|
|
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
|
|
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
|
-
|
|
1040
|
-
if (
|
|
1041
|
-
|
|
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 (
|
|
1193
|
-
//
|
|
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
|
|
package/dist/furniture-fields.js
CHANGED
|
@@ -107,19 +107,51 @@ export function parseDate(text, pattern) {
|
|
|
107
107
|
return formatDate(iso, pattern) === text ? iso : null;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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
|
|
121
|
-
if (
|
|
122
|
-
|
|
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,
|
|
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', '
|
|
19
|
-
const settings = ['
|
|
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
|
|
76
|
-
//
|
|
77
|
-
// text, even after edits; a recorded format is kept only while the current text
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
...(
|
|
113
|
-
...(layout.parts.some(part => part.field === '
|
|
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 === '
|
|
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
|
-
|
|
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: [],
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
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: [],
|
|
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)
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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: {
|
|
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; {
|
|
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 =
|
|
3765
|
+
const ph = nativePlaceholderForPart(part);
|
|
3734
3766
|
const geometry = ph && defaultPlaceholderGeometry(part);
|
|
3735
3767
|
return geometry ? [[ph, geometry]] : [];
|
|
3736
3768
|
}));
|
package/dist/native-furniture.js
CHANGED
|
@@ -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: '
|
|
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
|
-
|
|
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
|
|
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 =
|
|
49
|
-
if (part.kind !== 'footer' ||
|
|
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
|
-
*
|
|
256
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
if (
|
|
271
|
-
|
|
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
|
-
/**
|
|
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]
|
|
294
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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"
|