@openpresentation/opf-pptx 0.17.0 → 0.18.1
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 +35 -10
- package/dist/chart-order.js +76 -0
- package/dist/content-topology.js +25 -0
- package/dist/data-provenance.js +71 -4
- package/dist/document-provenance.js +34 -16
- package/dist/furniture-provenance.js +179 -69
- package/dist/image-fallback-node.js +2 -2
- package/dist/image-provenance.js +54 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +204 -56
- package/dist/logo-provenance.js +8 -8
- package/dist/master-furniture.js +326 -0
- package/dist/media-dedupe.js +2 -0
- package/dist/native-furniture.js +6 -2
- package/dist/run-colors.js +28 -2
- package/package.json +9 -4
|
@@ -3,7 +3,8 @@ import {attachTextTags, decodeTextTag, encodeTextTag} from './code-provenance.js
|
|
|
3
3
|
import {sourceLineParagraphs} from './text-provenance.js';
|
|
4
4
|
import {DEFAULT_DATE_FORMAT, NATIVE_DATE_FIELDS, isBuiltinTemplate, mentionsSlideToken, parseDate, slideNumberTemplate} from './furniture-fields.js';
|
|
5
5
|
import {schemas} from '@openpresentation/opf';
|
|
6
|
-
import {
|
|
6
|
+
import {resolveLogo} from '@openpresentation/opf/composition';
|
|
7
|
+
import {mediaKey} from './media-dedupe.js';
|
|
7
8
|
import {NATIVE_PLACEHOLDERS, footerValue, isNativePlaceholderType, readNativePlaceholders} from './native-furniture.js';
|
|
8
9
|
|
|
9
10
|
const TAG = 'OPF_FURNITURE_V1';
|
|
@@ -26,6 +27,8 @@ const platformId = new RegExp(schemas.presentation.$defs.Socials.propertyNames.p
|
|
|
26
27
|
const socialLines = part => part.links.map(link => ({platform: link.platform,
|
|
27
28
|
scheme: link.href && !/^[a-z][a-z0-9+.-]*:/i.test(link.text) ? 'https://' : ''}));
|
|
28
29
|
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
30
|
+
const LOGO_REFERENCE = /^var:organization(?:\.[A-Za-z0-9_-]{1,64}){1,3}$/;
|
|
31
|
+
const isLogoReference = value => typeof value === 'string' && value.length <= 200 && LOGO_REFERENCE.test(value);
|
|
29
32
|
const key = part => `${part.kind}.${part.zone}.${part.field}`;
|
|
30
33
|
// Key order is not meaning: a value merged from native placeholders equals the same value read from a manifest.
|
|
31
34
|
const canonical = value => JSON.stringify(value, (key, item) => object(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : item);
|
|
@@ -72,11 +75,13 @@ function unchangedStaticDate(marker, format, ordered, paragraphs) {
|
|
|
72
75
|
}), 'Current static date text or boundaries differ from the exported lines.');
|
|
73
76
|
}
|
|
74
77
|
|
|
75
|
-
// This manifest contains topology, identities, inactive flags, format settings
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
+
// This manifest contains topology, identities, inactive flags, format settings, the authored text of a zone whose `text` uses
|
|
79
|
+
// built-in variables (`templates`, FA-31; `authored` is the document as given to toPptx, before the deck-wide pass) and the
|
|
80
|
+
// organization logo reference of a zone `image` (`images`, RR-71: `{reference, media?, drawn?}` per `kind.zone`). Current native
|
|
81
|
+
// shapes supply all words, image bytes and alt text, even after edits; a recorded format, template or logo reference is kept
|
|
82
|
+
// only while the current text or picture still matches it exactly.
|
|
78
83
|
export function furnitureManifest(presentation, slide, layout, slideIndex, staticDates = new Map(), natives = new Map(), authored) {
|
|
79
|
-
const definitions = {}, formats = {}, templates = {};
|
|
84
|
+
const definitions = {}, formats = {}, templates = {}, images = {};
|
|
80
85
|
for (const kind of kinds) {
|
|
81
86
|
const local = slide.design?.[kind] !== undefined;
|
|
82
87
|
const source = local ? slide.design[kind] : presentation.design?.[kind];
|
|
@@ -84,8 +89,14 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
|
|
|
84
89
|
const value = source === false ? false : {};
|
|
85
90
|
if (value !== false) for (const zone of zones) if (source[zone] !== undefined) {
|
|
86
91
|
value[zone] = {};
|
|
92
|
+
// An organization logo reference (`var:organization.logo.icon`): core lays out an image part with `reference`; the manifest keeps
|
|
93
|
+
// the authored string. With no part (the organization has no logo) the reference is all there is, and import restores it.
|
|
94
|
+
const reference = isLogoReference(source[zone].image) ? source[zone].image : undefined;
|
|
95
|
+
const imagePart = layout.parts.find(part => part.kind === kind && part.zone === zone && part.type === 'image');
|
|
96
|
+
if (reference !== undefined) images[`${kind}.${zone}`] = imagePart ? {reference} : {reference, drawn: false};
|
|
87
97
|
for (const field of fields) if (source[zone][field] !== undefined) {
|
|
88
98
|
const current = source[zone][field];
|
|
99
|
+
if (field === 'image' && reference !== undefined && !imagePart) continue;
|
|
89
100
|
value[zone][field] = field === 'image' ? 'native' : typeof current === 'string' ? 'literal' : current;
|
|
90
101
|
}
|
|
91
102
|
const format = Object.fromEntries(settings.filter(setting => typeof source[zone][setting] === 'string').map(setting => [setting, source[zone][setting]]));
|
|
@@ -103,35 +114,19 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
|
|
|
103
114
|
if (!Object.keys(definitions).length) return null;
|
|
104
115
|
const organizations = array(presentation.organization);
|
|
105
116
|
const organization = organizations.find(item => item.role === 'primary') ?? organizations[0];
|
|
106
|
-
// Generated deck logos (logo: true) are listed beside the topology, never inside it: the part list and the field
|
|
107
|
-
// definitions are validated strictly by every released importer (0.11.6 and earlier reject an unknown field there),
|
|
108
|
-
// so they keep describing only what those importers know. `drawn` is false when no logo resolved at export.
|
|
109
|
-
const logos = [];
|
|
110
|
-
for (const kind of kinds) {
|
|
111
|
-
const source = (slide.design?.[kind] !== undefined ? slide.design : presentation.design)?.[kind];
|
|
112
|
-
for (const zone of zones) if (definitions[kind]?.value?.[zone] && source?.[zone]?.logo === true) {
|
|
113
|
-
logos.push({kind, zone, drawn: layout.parts.some(part => part.field === 'logo' && part.kind === kind && part.zone === zone)});
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
117
|
return {v: 1, role: 'slide', group: String(slideIndex), definitions, ...(Object.keys(formats).length ? {formats} : {}),
|
|
117
|
-
...(Object.keys(templates).length ? {templates} : {}),
|
|
118
|
+
...(Object.keys(templates).length ? {templates} : {}), ...(Object.keys(images).length ? {images} : {}),
|
|
118
119
|
...(layout.parts.some(part => part.field === 'socials') ? {organizationId: organization?.id} : {}),
|
|
119
|
-
parts: layout.parts.
|
|
120
|
+
parts: layout.parts.map((part, index) => ({kind: part.kind, zone: part.zone, field: part.field,
|
|
120
121
|
type: part.type, count: part.type === 'image' ? 1 : part.fit.lines.length,
|
|
121
122
|
...(part.field === 'socials' ? {socials: socialLines(part)} : {}),
|
|
122
123
|
...(staticDates.has(index) ? {staticDate: staticDates.get(index)} : {}),
|
|
123
124
|
// RR-11: this part is a native PowerPoint placeholder (dt, ftr or sldNum); deleting it in PowerPoint's dialog is then intent.
|
|
124
|
-
...(natives.has(index) ? {ph: natives.get(index)} : {})}
|
|
125
|
-
...(logos.length ? {logos} : {})};
|
|
125
|
+
...(natives.has(index) ? {ph: natives.get(index)} : {})}))};
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
|
|
129
|
-
export const manifestPartIndex = (parts, index) => parts.slice(0, index).filter(part => part.field !== 'logo').length;
|
|
130
|
-
|
|
131
|
-
export function attachFurnitureTags(entries, records, manifests, logoRecords = new Map()) {
|
|
128
|
+
export function attachFurnitureTags(entries, records, manifests) {
|
|
132
129
|
attachTextTags(entries, records, TAG, 'opfFurniture', 'furniture', {pictures: true});
|
|
133
|
-
// A generated logo picture carries the logo tag (never the furniture tag): see logo-provenance.js.
|
|
134
|
-
attachTextTags(entries, logoRecords, LOGO_TAG, 'opfFurnitureLogo', 'furniture logo', {pictures: true});
|
|
135
130
|
const types = [];
|
|
136
131
|
for (const [path, manifest] of manifests) {
|
|
137
132
|
const part = `ppt/tags/opfFurnitureSlide${manifest.group}.xml`;
|
|
@@ -153,6 +148,49 @@ export function attachFurnitureTags(entries, records, manifests, logoRecords = n
|
|
|
153
148
|
if (types.length) entries['[Content_Types].xml'] = enc.encode(dec.decode(entries['[Content_Types].xml']).replace('</Types>', types.join('') + '</Types>'));
|
|
154
149
|
}
|
|
155
150
|
|
|
151
|
+
/**
|
|
152
|
+
* An organization `logo` value reduced to what logo resolution needs (RR-71): the shapes and tones that exist, each as a
|
|
153
|
+
* placeholder string (a stored picture, an asset reference and a path all count), or undefined when it has none.
|
|
154
|
+
*/
|
|
155
|
+
export function logoShape(value) {
|
|
156
|
+
if (typeof value === 'string') return value.trim() ? 'x' : undefined;
|
|
157
|
+
if (!object(value)) return undefined;
|
|
158
|
+
if (typeof value.src === 'string') return value.src.trim() ? 'x' : undefined;
|
|
159
|
+
if (typeof value.$opfMedia === 'string') return 'x';
|
|
160
|
+
const kept = Object.fromEntries(Object.entries(value).map(([name, item]) => [name, logoShape(item)]).filter(([, item]) => item !== undefined));
|
|
161
|
+
return Object.keys(kept).length ? kept : undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The content key of the embedded bytes of a picture: its main blip's media part.
|
|
165
|
+
function pictureKey(picture, relationships, entries) {
|
|
166
|
+
const target = relationships.get(picture?.['p:blipFill']?.['a:blip']?.['r:embed'])?.path;
|
|
167
|
+
return target && entries[target] ? mediaKey(entries[target]) : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Finish the `images` of each slide manifest once the package is final (SVG parts, format conversion and media de-duplication
|
|
172
|
+
* change what a picture embeds): the media key of every logo-reference picture, or `drawn: false` when the picture could not be
|
|
173
|
+
* embedded. Rewrites the slide's furniture tag part. `names` maps `<slide part>|<kind>.<zone>` to the picture's object name.
|
|
174
|
+
*/
|
|
175
|
+
export function stampFurnitureImageKeys(entries, manifests, names, relationshipsOf) {
|
|
176
|
+
for (const [path, manifest] of manifests) {
|
|
177
|
+
if (!manifest.images) continue;
|
|
178
|
+
const xml = dec.decode(entries[path]), relationships = relationshipsOf(entries, path);
|
|
179
|
+
const pictures = [...xml.matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)].map(match => match[0]);
|
|
180
|
+
for (const [slot, entry] of Object.entries(manifest.images)) {
|
|
181
|
+
if (entry.drawn === false) continue;
|
|
182
|
+
const name = names.get(`${path}|${slot}`);
|
|
183
|
+
const embed = name === undefined ? undefined : pictures.find(picture => picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1] === name)?.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
184
|
+
const target = embed === undefined ? undefined : relationships.get(embed)?.path;
|
|
185
|
+
if (target && entries[target]) entry.media = mediaKey(entries[target]);
|
|
186
|
+
else entry.drawn = false;
|
|
187
|
+
}
|
|
188
|
+
const part = `ppt/tags/opfFurnitureSlide${manifest.group}.xml`;
|
|
189
|
+
if (!entries[part]) throw Error('Missing furniture tag part.');
|
|
190
|
+
entries[part] = enc.encode(dec.decode(entries[part]).replace(/\bval="[^"]*"/, () => `val="${encodeTextTag(manifest)}"`));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
156
194
|
function readTags(container, relationships, entries) {
|
|
157
195
|
const tags = [];
|
|
158
196
|
let unreadable = false;
|
|
@@ -167,6 +205,29 @@ function readTags(container, relationships, entries) {
|
|
|
167
205
|
ambiguous: unreadable || tags.filter(tag => /^OPF_/i.test(tag.name) && tag.name.toUpperCase() !== SLIDE_TAG).length !== 1};
|
|
168
206
|
}
|
|
169
207
|
|
|
208
|
+
// RR-72: a part written once on the slide master or a layout is stored under `shared` (keyed by slot, with `on`, the definition's
|
|
209
|
+
// flag and its text template or logo reference), so importers that read only slide shapes ignore it. Put it back in place, as a
|
|
210
|
+
// part marked `on`, before the manifest is validated as one record.
|
|
211
|
+
function withSharedParts(manifest) {
|
|
212
|
+
if (manifest?.shared === undefined) return manifest;
|
|
213
|
+
check(object(manifest.shared) && object(manifest.definitions) && Array.isArray(manifest.parts), 'Invalid shared furniture.');
|
|
214
|
+
const result = {...manifest, definitions: structuredClone(manifest.definitions), parts: [...manifest.parts],
|
|
215
|
+
...(manifest.templates ? {templates: {...manifest.templates}} : {}), ...(manifest.images ? {images: {...manifest.images}} : {})};
|
|
216
|
+
delete result.shared;
|
|
217
|
+
for (const [slot, entry] of Object.entries(manifest.shared)) {
|
|
218
|
+
check(object(entry) && ['master', 'layout'].includes(entry.on) && slot === key(entry) && entry.ph === undefined, 'Invalid shared furniture part.');
|
|
219
|
+
const {on, flag, template, image, ...part} = entry;
|
|
220
|
+
const zone = result.definitions[entry.kind]?.value?.[entry.zone];
|
|
221
|
+
check(object(zone) && zone[entry.field] === undefined && flag !== undefined, 'Invalid shared furniture part.');
|
|
222
|
+
zone[entry.field] = flag;
|
|
223
|
+
const place = `${entry.kind}.${entry.zone}`;
|
|
224
|
+
if (template !== undefined) { check(entry.field === 'text' && result.templates?.[place] === undefined, 'Invalid shared furniture text.'); (result.templates ??= {})[place] = template; }
|
|
225
|
+
if (image !== undefined) { check(entry.field === 'image' && result.images?.[place] === undefined, 'Invalid shared furniture image.'); (result.images ??= {})[place] = image; }
|
|
226
|
+
result.parts.push({...part, on});
|
|
227
|
+
}
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
|
|
170
231
|
function validateManifest(manifest) {
|
|
171
232
|
check(manifest?.v === 1 && manifest.role === 'slide' && /^\d{1,10}$/.test(manifest.group), 'Invalid slide identity.');
|
|
172
233
|
check(object(manifest.definitions) && Object.keys(manifest.definitions).length > 0 && Object.keys(manifest.definitions).every(kind => kinds.includes(kind)), 'Invalid furniture definitions.');
|
|
@@ -187,14 +248,16 @@ function validateManifest(manifest) {
|
|
|
187
248
|
}
|
|
188
249
|
}
|
|
189
250
|
}
|
|
190
|
-
if (manifest.
|
|
191
|
-
check(
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
check(
|
|
196
|
-
|
|
197
|
-
|
|
251
|
+
if (manifest.images !== undefined) {
|
|
252
|
+
check(object(manifest.images), 'Invalid furniture images.');
|
|
253
|
+
for (const [slot, entry] of Object.entries(manifest.images)) {
|
|
254
|
+
const [kind, zone, extra] = slot.split('.');
|
|
255
|
+
const flag = manifest.definitions[kind]?.value?.[zone]?.image;
|
|
256
|
+
check(extra === undefined && object(manifest.definitions[kind]?.value?.[zone]) && object(entry) && Object.keys(entry).every(name => ['reference', 'media', 'drawn'].includes(name)) &&
|
|
257
|
+
isLogoReference(entry.reference) && (entry.media === undefined || (typeof entry.media === 'string' && /^\d{1,12}:[0-9a-f]{1,32}$/.test(entry.media))) &&
|
|
258
|
+
(entry.drawn === undefined || (entry.drawn === false && entry.media === undefined)) &&
|
|
259
|
+
// A reference with no part at all has no image flag; one with a part has the part's flag.
|
|
260
|
+
(flag === 'native' || (flag === undefined && entry.drawn === false)), 'Invalid furniture image.');
|
|
198
261
|
}
|
|
199
262
|
}
|
|
200
263
|
if (manifest.templates !== undefined) {
|
|
@@ -218,6 +281,8 @@ function validateManifest(manifest) {
|
|
|
218
281
|
check(object(part) && expected.get(key(part)) === part.type, 'Ambiguous furniture part.');
|
|
219
282
|
check(Number.isSafeInteger(part.count) && part.count >= 1 && part.count <= 10000 && (part.type !== 'image' || part.count === 1), 'Invalid furniture line count.');
|
|
220
283
|
// RR-11: a footer part that is a native PowerPoint placeholder (dt, ftr, sldNum), unique per slide.
|
|
284
|
+
// RR-72: a part drawn once on the slide master or a layout.
|
|
285
|
+
check(part.on === undefined || (['master', 'layout'].includes(part.on) && part.ph === undefined), 'Invalid shared furniture part.');
|
|
221
286
|
if (part.ph !== undefined) check(isNativePlaceholderType(part.ph) && part.kind === 'footer' && part.type === 'text' && part.count === 1 &&
|
|
222
287
|
NATIVE_PLACEHOLDERS[part.ph].field === part.field && manifest.parts.filter(other => other.ph === part.ph).length === 1, 'Invalid native placeholder part.');
|
|
223
288
|
if (part.field === 'socials') check(Array.isArray(part.socials) && part.socials.length >= 1 && part.socials.length <= 100
|
|
@@ -229,17 +294,6 @@ function validateManifest(manifest) {
|
|
|
229
294
|
if (manifest.parts.some(part => part.field === 'socials')) check(typeof manifest.organizationId === 'string' && /^[a-zA-Z0-9_-]+$/.test(manifest.organizationId), 'Invalid organization identity.');
|
|
230
295
|
}
|
|
231
296
|
|
|
232
|
-
// The logo tags of a picture: any other OPF tag next to one is not an identity conflict for the furniture reader.
|
|
233
|
-
function readLogoTags(container, relationships, entries) {
|
|
234
|
-
const found = [];
|
|
235
|
-
for (const link of array(container?.['p:tags'])) {
|
|
236
|
-
const rel = relationships.get(link['r:id']);
|
|
237
|
-
if (rel?.type !== REL || rel.targetMode === 'External' || !entries[rel.path]) continue;
|
|
238
|
-
found.push(...array(parser.parse(dec.decode(entries[rel.path]))['p:tagLst']?.['p:tag']).filter(tag => tag.name?.toUpperCase() === LOGO_TAG));
|
|
239
|
-
}
|
|
240
|
-
return found;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
297
|
// The native fields of the lines of one tagged text part, with offsets into the text sourceLineParagraphs joins from them.
|
|
244
298
|
function orderedFields(ordered, paragraphs) {
|
|
245
299
|
const fields = [];
|
|
@@ -270,8 +324,37 @@ function importedZoneText(text, nativeFields, template, position) {
|
|
|
270
324
|
return slideNumberTemplate(text, nativeFields);
|
|
271
325
|
}
|
|
272
326
|
|
|
273
|
-
|
|
274
|
-
|
|
327
|
+
// RR-72: the tagged furniture shapes of the slide's layout and master, by `on` and slot, and whether the slide shows them: a slide
|
|
328
|
+
// whose "Hide background graphics" is on (showMasterSp="0") shows neither; a layout with it hides the master's.
|
|
329
|
+
function inheritedRecords(context, entries) {
|
|
330
|
+
const inherited = context.inherited?.() ?? {};
|
|
331
|
+
const records = new Map(), damaged = {};
|
|
332
|
+
for (const on of ['layout', 'master']) {
|
|
333
|
+
const source = inherited[on];
|
|
334
|
+
if (!source) continue;
|
|
335
|
+
for (const [type, nodes, properties] of [['text', source.shapes, 'p:nvSpPr'], ['image', source.pictures, 'p:nvPicPr']]) {
|
|
336
|
+
for (const [index, node] of nodes.entries()) {
|
|
337
|
+
const container = node[properties]?.['p:nvPr']?.['p:custDataLst'];
|
|
338
|
+
const result = readTags(container, source.relationships, entries);
|
|
339
|
+
// A tag that cannot be read (a missing part, two identities) leaves the part's shape unknown: that is damage, not a removal.
|
|
340
|
+
if (array(container?.['p:tags']).length && result.tags.length !== 1 && result.ambiguous) damaged[on] = true;
|
|
341
|
+
for (const tag of result.tags) {
|
|
342
|
+
let data;
|
|
343
|
+
try { data = decodeTextTag(tag.val); } catch { damaged[on] = true; continue; }
|
|
344
|
+
if (result.ambiguous || data?.v !== 1 || typeof data.slot !== 'string' || data.role !== type) { damaged[on] = true; continue; }
|
|
345
|
+
const slot = `${on}|${data.slot}`;
|
|
346
|
+
records.set(slot, [...(records.get(slot) ?? []), {type, index, data}]);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const hiddenSlide = context.root?.showMasterSp === '0' || context.root?.showMasterSp === 'false';
|
|
352
|
+
const hiddenLayout = inherited.layout?.root?.showMasterSp === '0' || inherited.layout?.root?.showMasterSp === 'false';
|
|
353
|
+
return {sources: inherited, records, damaged, hidden: {layout: hiddenSlide, master: hiddenSlide || hiddenLayout}};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function readSlide(context, entries, slideIndex, slideCount, report, taggedText, section, draw, logoOwner) {
|
|
357
|
+
const {root, shapes, pictures, relationships} = context;
|
|
275
358
|
const candidates = {};
|
|
276
359
|
const records = [];
|
|
277
360
|
let invalidRecord = false;
|
|
@@ -285,26 +368,19 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText,
|
|
|
285
368
|
} catch { invalidRecord = true; }
|
|
286
369
|
}
|
|
287
370
|
}
|
|
288
|
-
// Generated logo pictures: identity only, the bytes come from design.logo.
|
|
289
|
-
const logoPictures = [];
|
|
290
|
-
for (const [index, node] of pictures.entries()) try {
|
|
291
|
-
for (const tag of readLogoTags(node['p:nvPicPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries)) {
|
|
292
|
-
const data = decodeTextTag(tag.val);
|
|
293
|
-
if (data?.role === 'furniture') logoPictures.push({index, data});
|
|
294
|
-
}
|
|
295
|
-
} catch { invalidRecord = true; }
|
|
296
371
|
const tags = readTags(root['p:cSld']?.['p:custDataLst'], relationships, entries);
|
|
297
|
-
if (!tags.tags.length && !records.length && !invalidRecord
|
|
372
|
+
if (!tags.tags.length && !records.length && !invalidRecord) return candidates;
|
|
298
373
|
let manifest;
|
|
299
374
|
try {
|
|
300
375
|
check(!tags.ambiguous && tags.tags.length === 1 && !invalidRecord, 'Missing or ambiguous furniture manifest/shape tags.');
|
|
301
|
-
manifest = decodeTextTag(tags.tags[0].val);
|
|
376
|
+
manifest = withSharedParts(decodeTextTag(tags.tags[0].val));
|
|
302
377
|
validateManifest(manifest);
|
|
303
378
|
for (const record of records) {
|
|
304
379
|
const data = record.data, part = manifest.parts[data?.part];
|
|
305
380
|
check(data?.v === 1 && data.group === manifest.group && Number.isSafeInteger(data.part) && part && data.role === record.type && part.type === record.type, 'Unmatched furniture identity.');
|
|
306
381
|
}
|
|
307
382
|
} catch (error) { report(slideIndex, error.message); return candidates; }
|
|
383
|
+
const inherited = manifest.parts.some(part => part.on) ? inheritedRecords(context, entries) : undefined;
|
|
308
384
|
for (const kind of kinds) {
|
|
309
385
|
const definition = manifest.definitions[kind];
|
|
310
386
|
if (!definition) continue;
|
|
@@ -315,24 +391,41 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText,
|
|
|
315
391
|
value[zone] = Object.fromEntries(Object.entries(definition.value[zone]).filter(([, flag]) => flag === false));
|
|
316
392
|
}
|
|
317
393
|
const candidate = {scope: definition.scope, value, text: [], pictures: [], socials: []};
|
|
318
|
-
// logo
|
|
319
|
-
for (const
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
if (found.length) { candidate.pictures.push(found[0].index); value[logo.zone].logo = true; }
|
|
323
|
-
else if (!logo.drawn) value[logo.zone].logo = true;
|
|
394
|
+
// A logo reference with no part (the organization had no logo to draw) returns as the reference.
|
|
395
|
+
for (const [slot, entry] of Object.entries(manifest.images ?? {})) {
|
|
396
|
+
const [slotKind, slotZone] = slot.split('.');
|
|
397
|
+
if (slotKind === kind && entry.drawn === false && !definition.value?.[slotZone]?.image && value !== false) value[slotZone].image = entry.reference;
|
|
324
398
|
}
|
|
325
399
|
for (const [partIndex, part] of manifest.parts.entries()) {
|
|
326
400
|
if (part.kind !== kind) continue;
|
|
327
|
-
|
|
401
|
+
// A part on the master or a layout is read there; the slide's own shapes otherwise.
|
|
402
|
+
const source = part.on ? inherited.sources[part.on] : context;
|
|
403
|
+
const group = part.on ? (inherited.hidden[part.on] || !source ? [] : inherited.records.get(`${part.on}|${key(part)}`) ?? []) : records.filter(record => record.data.part === partIndex);
|
|
404
|
+
const {paragraphs, relationships} = source ?? context;
|
|
405
|
+
const logo = part.type === 'image' ? manifest.images?.[`${kind}.${part.zone}`] : undefined;
|
|
406
|
+
// The picture of a logo reference could not be embedded at export: the reference is all there is.
|
|
407
|
+
if (logo?.drawn === false && group.length === 0) { value[part.zone].image = logo.reference; continue; }
|
|
328
408
|
// A native placeholder PowerPoint's Header & Footer dialog removed is a deliberate removal, not damage.
|
|
329
409
|
if (part.ph !== undefined && group.length === 0) { removedNative = true; continue; }
|
|
410
|
+
// So is a master or layout part the slide no longer shows (deleted there, another layout, or the slide's background graphics hidden).
|
|
411
|
+
if (part.on !== undefined && group.length === 0) {
|
|
412
|
+
check(inherited.hidden[part.on] || !inherited.damaged[part.on], 'Damaged furniture tags on the slide master or layout.');
|
|
413
|
+
removedNative = true;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
330
417
|
check(group.length === part.count, 'Incomplete or duplicated furniture part.');
|
|
331
418
|
if (part.type === 'image') {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
419
|
+
// An unchanged picture of an organization logo returns as its reference (the organization is stored with its logo, and the
|
|
420
|
+
// picture still embeds the bytes export drew); an edited, replaced or foreign one is an ordinary image.
|
|
421
|
+
const picture = source.pictures[group[0].index];
|
|
422
|
+
if (logo && logoOwner(logo.reference) && logo.media !== undefined && logo.media === pictureKey(picture, relationships, entries)) value[part.zone].image = logo.reference;
|
|
423
|
+
else {
|
|
424
|
+
const current = source.readPicture(picture);
|
|
425
|
+
check(current?.kind === 'image', 'Furniture image is no longer recoverable.');
|
|
426
|
+
value[part.zone].image = current.payload.image;
|
|
427
|
+
}
|
|
428
|
+
if (!part.on) candidate.pictures.push(group[0].index);
|
|
336
429
|
} else {
|
|
337
430
|
const ordered = Array(part.count);
|
|
338
431
|
for (const record of group) {
|
|
@@ -348,7 +441,20 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText,
|
|
|
348
441
|
candidate.socials.push({id: manifest.organizationId, socials: Object.fromEntries(part.socials.map((line, index) => [line.platform, line.scheme + lines[index]]))});
|
|
349
442
|
}
|
|
350
443
|
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;
|
|
351
|
-
candidate.text.push(...ordered.map(record => record.index));
|
|
444
|
+
if (!part.on) candidate.text.push(...ordered.map(record => record.index));
|
|
445
|
+
}
|
|
446
|
+
} catch (error) {
|
|
447
|
+
// A part on the slide master or a layout is never slide content: when its record no longer reads, its current words (or
|
|
448
|
+
// picture) stay the part's value, as literal text, and the damage is reported.
|
|
449
|
+
if (!part.on) throw error;
|
|
450
|
+
report(slideIndex, `${kind}.${part.zone}.${part.field}: ${error.message} The current ${part.type === 'image' ? 'picture' : 'words'} on the slide master or layout are kept.`);
|
|
451
|
+
if (part.type === 'image') {
|
|
452
|
+
const current = source.readPicture(source.pictures[group[0].index]);
|
|
453
|
+
if (current?.kind === 'image') value[part.zone].image = current.payload.image;
|
|
454
|
+
} else if (part.field !== 'socials') {
|
|
455
|
+
const lines = [...group].sort((a, b) => (Number.isSafeInteger(a.data.line) ? a.data.line : 0) - (Number.isSafeInteger(b.data.line) ? b.data.line : 0) || a.index - b.index);
|
|
456
|
+
value[part.zone][part.field] = lines.map(record => (paragraphs[record.index] ?? []).map(paragraph => paragraph.text).join('\n')).join('\n');
|
|
457
|
+
}
|
|
352
458
|
}
|
|
353
459
|
}
|
|
354
460
|
check(new Set(candidate.text).size === candidate.text.length && new Set(candidate.pictures).size === candidate.pictures.length, 'Repeated furniture shape identity.');
|
|
@@ -398,9 +504,13 @@ function importedDate(flag, format, text, nativeFields, zone, marker, ordered, p
|
|
|
398
504
|
// `options.organizations` the stored organizations (OPF_DOCUMENT_V1) a socials part can name by id, and `options.draw(template,
|
|
399
505
|
// {index, count, section})` draws a stored zone text for a slide (its built-in variables resolved from the document's metadata).
|
|
400
506
|
export function importFurniture(contexts, entries, onDiagnostic, {sections = [], organizations = [], draw = () => undefined} = {}) {
|
|
507
|
+
// A logo reference returns only while the stored organizations still give it a logo (RR-71): `organizations` carry `logo`.
|
|
508
|
+
const logoOwner = reference => {
|
|
509
|
+
try { return resolveLogo({organization: organizations.filter(item => item.logo !== undefined)}, {}, {reference}) !== null; } catch { return false; }
|
|
510
|
+
};
|
|
401
511
|
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.`});
|
|
402
512
|
const taggedText = contexts.map(() => new Set());
|
|
403
|
-
const candidates = contexts.map((context, index) => readSlide(context, entries, index, contexts.length, report, taggedText[index], sections[index], draw));
|
|
513
|
+
const candidates = contexts.map((context, index) => readSlide(context, entries, index, contexts.length, report, taggedText[index], sections[index], draw, logoOwner));
|
|
404
514
|
// Social profiles belong to the organization the manifest names by id, which only the stored document metadata can supply
|
|
405
515
|
// (no furniture part shows the organization's name any more); without it (or with disagreeing values) the lines stay
|
|
406
516
|
// ordinary current text.
|
|
@@ -13,12 +13,12 @@ export async function webpToPng(bytes) {
|
|
|
13
13
|
// not installed, and also when it is installed but its PNG converter (`@resvg/resvg-js`, an optional peer of opf-render
|
|
14
14
|
// 0.16) is not: opf-render's `converter-missing` error maps to the same code, with its install command on `install`.
|
|
15
15
|
//
|
|
16
|
-
// This is the one place the exporter calls the renderer. opf-render's
|
|
16
|
+
// This is the one place the exporter calls the renderer. opf-render's toPng(svg, {fonts, scale, background}) takes the
|
|
17
17
|
// fonts handle for the faces it may draw with: here only the bundled ones, never the system's.
|
|
18
18
|
export async function svgToPng(svg, {scale, text}) {
|
|
19
19
|
let render;
|
|
20
20
|
try {
|
|
21
|
-
({
|
|
21
|
+
({toPng: render} = await import('@openpresentation/opf-render'));
|
|
22
22
|
} catch (error) {
|
|
23
23
|
const unavailable = new Error('SVG pictures need the optional peer @openpresentation/opf-render (or an options.svgRasterizer).', {cause: error});
|
|
24
24
|
unavailable.code = 'svg-rasterizer-unavailable';
|
package/dist/image-provenance.js
CHANGED
|
@@ -271,3 +271,57 @@ export function importImages({pictures, shapes, relationships, entries, slideInd
|
|
|
271
271
|
}
|
|
272
272
|
return {blocks, background, backgroundOverlay, backgroundOverlayShape, consumed, consumedShapes};
|
|
273
273
|
}
|
|
274
|
+
|
|
275
|
+
// opf-pptx#221: the "Image unavailable" placeholder of an image block whose source could not be embedded (a remote URL is never
|
|
276
|
+
// fetched) is one native group, `OPF image placeholder N group`, that carries the panel's accessible name (descr) and holds the
|
|
277
|
+
// dashed panel `OPF image placeholder N` and its label lines (`... text line i`) or cross strokes (`... icon i`), each an
|
|
278
|
+
// editable shape. In `full` mode the panel carries OPF_IMAGE_PLACEHOLDER_V1: the slide, the block path, the authored image value
|
|
279
|
+
// and the block's own fields. Import turns an intact tagged panel back into that image block and consumes its label shapes;
|
|
280
|
+
// without a valid tag (provenance false or references-only, a damaged tag) the shapes stay ordinary content, as before.
|
|
281
|
+
export const IMAGE_PLACEHOLDER_TAG = 'OPF_IMAGE_PLACEHOLDER_V1';
|
|
282
|
+
export const imagePlaceholderGroupName = panel => `${panel} group`;
|
|
283
|
+
const PLACEHOLDER_PANEL = /^OPF image placeholder \d+$/;
|
|
284
|
+
const PLACEHOLDER_GROUP = /^OPF image placeholder \d+ group$/;
|
|
285
|
+
const BLOCK_PATH = /^slides\.\d+(?:\.[A-Za-z0-9_:+-]{1,64}){1,32}$/;
|
|
286
|
+
const placeholderPart = (panel, name) => typeof name === 'string' && name.startsWith(`${panel} `) && /^(?:text line|icon) \d+$/.test(name.slice(panel.length + 1));
|
|
287
|
+
|
|
288
|
+
/** Whether a native group is an exported image placeholder group with an identity transform (its children keep their boxes). */
|
|
289
|
+
export function isImagePlaceholderGroup(group) {
|
|
290
|
+
if (!PLACEHOLDER_GROUP.test(String(group?.['p:nvGrpSpPr']?.['p:cNvPr']?.name ?? ''))) return false;
|
|
291
|
+
const xfrm = group?.['p:grpSpPr']?.['a:xfrm'];
|
|
292
|
+
return ['x', 'y'].every(key => String(xfrm?.['a:off']?.[key]) === String(xfrm?.['a:chOff']?.[key]))
|
|
293
|
+
&& ['cx', 'cy'].every(key => String(xfrm?.['a:ext']?.[key]) === String(xfrm?.['a:chExt']?.[key]));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Recover the image blocks of tagged unavailable-image placeholders on one slide. `shapes` are the slide's native text shapes
|
|
298
|
+
* (nativeTextShapes order, group members included); `valid(payload)` checks a recovered image block against the OPF format.
|
|
299
|
+
* Returns `items`: [{panel (shape index), members (shape indexes, the panel first), payload}] and `consumed`, every member index.
|
|
300
|
+
*/
|
|
301
|
+
export function importImagePlaceholders({shapes, relationships, entries, slideIndex, valid, report}) {
|
|
302
|
+
const slide = `slides.${slideIndex}`;
|
|
303
|
+
const items = [], consumed = new Set();
|
|
304
|
+
const names = shapes.map(shape => shape?.['p:nvSpPr']?.['p:cNvPr']?.name);
|
|
305
|
+
const invalid = message => report({code: 'invalid-image-provenance', path: slide, message});
|
|
306
|
+
for (const [index, shape] of shapes.entries()) {
|
|
307
|
+
let manifest;
|
|
308
|
+
try { manifest = ownTag(shape?.['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries, IMAGE_PLACEHOLDER_TAG); }
|
|
309
|
+
catch { invalid('A tagged OPF image placeholder is ambiguous; its shapes were imported as ordinary content.'); continue; }
|
|
310
|
+
if (manifest === null) continue;
|
|
311
|
+
const name = names[index];
|
|
312
|
+
const image = manifest?.image;
|
|
313
|
+
const treatment = manifest?.treatment ?? {};
|
|
314
|
+
if (manifest?.v !== 1 || manifest.slide !== slide || typeof manifest.path !== 'string' || !BLOCK_PATH.test(manifest.path) || !manifest.path.startsWith(`${slide}.`)
|
|
315
|
+
|| !PLACEHOLDER_PANEL.test(name ?? '') || !(typeof image === 'string' || object(image)) || !object(treatment)
|
|
316
|
+
|| Object.keys(treatment).some(key => !IMAGE_TREATMENT_KEYS.includes(key))) {
|
|
317
|
+
invalid('An edited or invalid tagged OPF image placeholder was imported as ordinary content; its image block is not reconstructed.');
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const payload = {type: 'image', image: structuredClone(image), ...structuredClone(treatment)};
|
|
321
|
+
if (!valid(payload)) { invalid('A tagged OPF image placeholder does not form a valid image block; its shapes were imported as ordinary content.'); continue; }
|
|
322
|
+
const members = [index, ...names.flatMap((other, position) => position !== index && placeholderPart(name, other) ? [position] : [])];
|
|
323
|
+
for (const member of members) consumed.add(member);
|
|
324
|
+
items.push({panel: index, members, payload});
|
|
325
|
+
}
|
|
326
|
+
return {items, consumed};
|
|
327
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -97,7 +97,7 @@ export interface ToPptxOptions {
|
|
|
97
97
|
/**
|
|
98
98
|
* OPF_DOCUMENT_V1 / OPF_SLIDE_V1 customer-data tags that let fromPptx restore
|
|
99
99
|
* catalog references, layout ids, authoring metadata (`filename` and `extensions`
|
|
100
|
-
* included), `design.logo
|
|
100
|
+
* included), `design.logo` (a logo reference or `false`), the organizations with their logos, the asset registry, slide `section`/`extensions` and
|
|
101
101
|
* the slide content structure: nested groups, promoted regions, the root payload
|
|
102
102
|
* form, block ids and extensions, group composition (docs/document-roundtrip.md).
|
|
103
103
|
* Tags are not shown in PowerPoint's UI. Default "full"; "references-only"
|
|
@@ -131,7 +131,7 @@ export interface ToPptxOptions {
|
|
|
131
131
|
/**
|
|
132
132
|
* Draws the PNG fallback of an SVG picture (the raster older viewers show; PowerPoint 2016 and Microsoft 365 draw the SVG itself). It receives the
|
|
133
133
|
* sanitized SVG text and the pixel size to draw (the SVG's own aspect, about 192 dpi of the displayed size) and returns PNG bytes with a transparent
|
|
134
|
-
* background. It must be deterministic for the export to be. Default in Node: opf-render's `
|
|
134
|
+
* background. It must be deterministic for the export to be. Default in Node: opf-render's `toPng` (resvg with the bundled fonts), loaded only when an
|
|
135
135
|
* SVG is exported (optional peer `@openpresentation/opf-render`). Without it, and without opf-render, an SVG picture exports as the "Image unavailable"
|
|
136
136
|
* placeholder with an `unresolved-asset` diagnostic (`reason: "svg-rasterizer-unavailable"`); a browser build has no default.
|
|
137
137
|
*/
|