@openpresentation/opf-pptx 0.13.2 → 0.15.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 +57 -37
- package/dist/annotation-export.js +1 -1
- package/dist/annotation-provenance.js +4 -0
- package/dist/background-import.js +33 -17
- package/dist/background.js +30 -28
- package/dist/body-text-import.js +16 -4
- package/dist/chart-alt.js +54 -0
- package/dist/chart-data.js +29 -54
- package/dist/chart-highlight.js +113 -0
- package/dist/chart-options.js +46 -24
- package/dist/chart-types.js +61 -75
- package/dist/code-provenance.js +20 -4
- package/dist/color-ref.js +12 -6
- package/dist/data-provenance.js +18 -14
- package/dist/document-provenance.js +208 -129
- package/dist/format-check.js +10 -0
- package/dist/furniture-provenance.js +30 -3
- package/dist/heading-provenance.js +10 -3
- package/dist/image-fallback-node.js +4 -1
- package/dist/image-provenance.js +273 -0
- package/dist/index.d.ts +57 -35
- package/dist/index.js +724 -399
- package/dist/media-provenance.js +3 -3
- package/dist/metric-provenance.js +3 -3
- package/dist/native-text-style.js +2 -0
- package/dist/package-fonts.js +2 -2
- package/dist/quote-provenance.js +70 -26
- package/dist/rich-heading.js +119 -0
- package/dist/run-colors.js +3 -0
- package/dist/script-fonts.js +102 -101
- package/dist/theme-colors.js +16 -11
- package/dist/timeline-provenance.js +17 -6
- package/dist/typeface-inventory.js +8 -8
- package/dist/watermark-provenance.js +65 -3
- package/package.json +4 -4
- package/dist/slide-image-provenance.js +0 -177
|
@@ -15,7 +15,7 @@ const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal: true});
|
|
|
15
15
|
const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, trimValues: false});
|
|
16
16
|
const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
17
17
|
const kinds = ['header', 'footer'], zones = ['left', 'center', 'right'];
|
|
18
|
-
const fields = ['text', 'image', 'organization', 'socials', 'section', 'slideNumber', 'date'];
|
|
18
|
+
const fields = ['text', 'image', 'organization', 'speaker', 'socials', 'section', 'slideNumber', 'date'];
|
|
19
19
|
const settings = ['slideNumberFormat', '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
|
|
@@ -97,6 +97,7 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
|
|
|
97
97
|
if (!Object.keys(definitions).length) return null;
|
|
98
98
|
const organizations = array(presentation.organization);
|
|
99
99
|
const organization = organizations.find(item => item.role === 'primary') ?? organizations[0];
|
|
100
|
+
const speaker = array(presentation.speaker)[0];
|
|
100
101
|
// Generated deck logos (logo: true) are listed beside the topology, never inside it: the part list and the field
|
|
101
102
|
// definitions are validated strictly by every released importer (0.11.6 and earlier reject an unknown field there),
|
|
102
103
|
// so they keep describing only what those importers know. `drawn` is false when no logo resolved at export.
|
|
@@ -109,9 +110,12 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
|
|
|
109
110
|
}
|
|
110
111
|
return {v: 1, role: 'slide', group: String(slideIndex), definitions, ...(Object.keys(formats).length ? {formats} : {}),
|
|
111
112
|
...(layout.parts.some(part => part.field === 'organization' || part.field === 'socials') ? {organizationId: organization?.id} : {}),
|
|
113
|
+
...(layout.parts.some(part => part.field === 'speaker') ? {speakerId: speaker?.id} : {}),
|
|
112
114
|
parts: layout.parts.flatMap((part, index) => part.field === 'logo' ? [] : [{kind: part.kind, zone: part.zone, field: part.field,
|
|
113
115
|
type: part.type, count: part.type === 'image' ? 1 : part.fit.lines.length,
|
|
114
116
|
...(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} : {}),
|
|
115
119
|
...(staticDates.has(index) ? {staticDate: staticDates.get(index)} : {}),
|
|
116
120
|
// RR-11: this part is a native PowerPoint placeholder (dt, ftr or sldNum); deleting it in PowerPoint's dialog is then intent.
|
|
117
121
|
...(natives.has(index) ? {ph: natives.get(index)} : {})}]),
|
|
@@ -209,8 +213,12 @@ function validateManifest(manifest) {
|
|
|
209
213
|
&& part.socials.every(line => object(line) && platformId.test(line.platform) && schemes.includes(line.scheme))
|
|
210
214
|
&& new Set(part.socials.map(line => line.platform)).size === part.socials.length, 'Invalid social profile lines.');
|
|
211
215
|
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.');
|
|
212
218
|
expected.delete(key(part));
|
|
213
219
|
}
|
|
220
|
+
if (manifest.parts.some(part => part.field === 'speaker')) check(typeof manifest.speakerId === 'string' && /^[a-zA-Z0-9_-]+$/.test(manifest.speakerId), 'Invalid speaker identity.');
|
|
221
|
+
else check(manifest.speakerId === undefined, 'Unexpected speaker identity.');
|
|
214
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.');
|
|
215
223
|
}
|
|
216
224
|
|
|
@@ -269,7 +277,7 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
|
|
|
269
277
|
if (value !== false) for (const zone of zones) if (definition.value[zone] !== undefined) {
|
|
270
278
|
value[zone] = Object.fromEntries(Object.entries(definition.value[zone]).filter(([, flag]) => flag === false));
|
|
271
279
|
}
|
|
272
|
-
const candidate = {scope: definition.scope, value, text: [], pictures: [], organizations: [], socials: [], sections: []};
|
|
280
|
+
const candidate = {scope: definition.scope, value, text: [], pictures: [], organizations: [], speakers: [], socials: [], sections: []};
|
|
273
281
|
// logo: true returns as its flag; the generated picture is consumed, never content or an image field.
|
|
274
282
|
for (const logo of (manifest.logos ?? []).filter(item => item.kind === kind)) {
|
|
275
283
|
const found = logoPictures.filter(item => item.data.v === 1 && item.data.group === manifest.group && item.data.furniture === kind && item.data.zone === logo.zone);
|
|
@@ -305,6 +313,7 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
|
|
|
305
313
|
}
|
|
306
314
|
if (part.field === 'section') candidate.sections.push(text);
|
|
307
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));
|
|
308
317
|
if (part.field === 'socials') {
|
|
309
318
|
const lines = text.split('\n');
|
|
310
319
|
check(lines.length === part.socials.length && lines.every(line => line.trim()), 'Current social profile lines no longer match their platforms.');
|
|
@@ -330,6 +339,14 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
|
|
|
330
339
|
return candidates;
|
|
331
340
|
}
|
|
332
341
|
|
|
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
|
+
|
|
333
350
|
// A current native date field wins. Full-mode static-wrap evidence can recover
|
|
334
351
|
// generated OPF intent without turning the existing PPTX text into a live field. A fixed
|
|
335
352
|
// date keeps its ISO value and pattern only when the current text round-trips.
|
|
@@ -368,6 +385,13 @@ export function importFurniture(contexts, entries, onDiagnostic) {
|
|
|
368
385
|
delete slide[kind]; report(index, `${kind}: Current organization metadata disagrees across repeated fields.`);
|
|
369
386
|
}
|
|
370
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
|
+
}
|
|
371
395
|
for (const [index, slide] of candidates.entries()) {
|
|
372
396
|
const sections = Object.values(slide).flatMap(candidate => candidate.sections);
|
|
373
397
|
if (sections.some(section => section !== sections[0])) for (const kind of kinds) if (slide[kind]?.sections.length) {
|
|
@@ -398,7 +422,7 @@ export function importFurniture(contexts, entries, onDiagnostic) {
|
|
|
398
422
|
}
|
|
399
423
|
if (!accepted.length) return;
|
|
400
424
|
const merged = footerValue(accepted), indexes = accepted.map(item => item.index);
|
|
401
|
-
if (!existing) slide.footer = {scope: 'native', value: merged, text: indexes, pictures: [], organizations: [], socials: [], sections: []};
|
|
425
|
+
if (!existing) slide.footer = {scope: 'native', value: merged, text: indexes, pictures: [], organizations: [], speakers: [], socials: [], sections: []};
|
|
402
426
|
else {
|
|
403
427
|
if (existing.value === false) existing.value = {};
|
|
404
428
|
for (const zone of Object.keys(merged)) existing.value[zone] = {...existing.value[zone], ...merged[zone]};
|
|
@@ -417,6 +441,9 @@ export function importFurniture(contexts, entries, onDiagnostic) {
|
|
|
417
441
|
if (result.organization && validSocials.length) result.organization = {...result.organization, socials: validSocials[0].socials};
|
|
418
442
|
// Stored document metadata (FF-32) must not override disagreeing visible names.
|
|
419
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;
|
|
420
447
|
for (const kind of kinds) {
|
|
421
448
|
const inherited = candidates.map(slide => slide[kind]).filter(candidate => candidate?.scope === 'global');
|
|
422
449
|
if (inherited.length && candidates.every(slide => slide[kind]) && inherited.every(candidate => same(candidate.value, inherited[0].value))) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {XMLParser} from 'fast-xml-parser';
|
|
2
2
|
import {attachTextTags,decodeTextTag} from './code-provenance.js';
|
|
3
3
|
import {sourceLineParagraphs} from './text-provenance.js';
|
|
4
|
+
import {cleanBase,headingValue,joinRichLines} from './rich-heading.js';
|
|
4
5
|
const TAG='OPF_HEADING_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
5
6
|
const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
|
|
6
7
|
const decoder=new TextDecoder('utf-8',{fatal:true}),array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
|
|
@@ -8,7 +9,8 @@ export const attachHeadingTags=(entries,records)=>attachTextTags(entries,records
|
|
|
8
9
|
|
|
9
10
|
// Tags carry only roles and line ordering. Current native text always wins;
|
|
10
11
|
// neither original source text nor old geometry can be restored from these tags.
|
|
11
|
-
|
|
12
|
+
// `readBody` (optional) is the native body reader: with it a group also returns `richText`, the heading as string | TextRun[] (FA-10).
|
|
13
|
+
export function importHeadingGroups(shapes,paragraphs,relationships,entries,report,readBody) {
|
|
12
14
|
const groups=new Map(),consumed=new Set(),items=[];
|
|
13
15
|
for(const [index,shape] of shapes.entries()) {
|
|
14
16
|
const tags=[];let unreadable=false;
|
|
@@ -37,8 +39,13 @@ export function importHeadingGroups(shapes,paragraphs,relationships,entries,repo
|
|
|
37
39
|
}
|
|
38
40
|
const restored=sourceLineParagraphs(ordered,paragraphs,{legacy:true});
|
|
39
41
|
for(const item of ordered)consumed.add(item.shape);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
// The same boundaries as the plain text: a new tag's separator, or a native line break between legacy-tagged lines.
|
|
43
|
+
const source=ordered.some(item=>item.data.boundary!==undefined||item.data.separator!==undefined);
|
|
44
|
+
// Only a rich export records the heading's own look (tag.base); a heading exported as a plain string stays one.
|
|
45
|
+
const base=cleanBase(ordered[0].data.base);
|
|
46
|
+
const parts=base?joinRichLines(ordered,readBody,(item,last)=>source?item.data.separator:last?'':'\n'):undefined;
|
|
47
|
+
items.push({field:first.field,shapes:ordered.map(item=>item.shape),paragraphs:restored,...(parts?{richText:headingValue(parts,base)}:{})});
|
|
48
|
+
report({code:'heading-import-reflow',message:'Complete tagged heading lines retain their roles, order and current native text. New boundary tags also retain authored whitespace and line endings; legacy tags retain native line breaks. Run formatting that differs between words is read back as TextRun[]; geometry is not reconstructed.'});
|
|
42
49
|
}catch(error){report({code:'invalid-heading-provenance',message:`${error.message} Ordinary import retains visible native text.`});}
|
|
43
50
|
return {consumed,items};
|
|
44
51
|
}
|
|
@@ -11,6 +11,9 @@ export async function webpToPng(bytes) {
|
|
|
11
11
|
// is deterministic (no system fonts, no network or file access for the SVG's own references). `text` selects the
|
|
12
12
|
// bundled fonts; an SVG with no text needs none. Rejects with code `svg-rasterizer-unavailable` when opf-render is
|
|
13
13
|
// not installed.
|
|
14
|
+
//
|
|
15
|
+
// This is the one place the exporter calls the renderer. opf-render's svgToPng(svg, {fonts, scale, background}) takes the
|
|
16
|
+
// fonts handle for the faces it may draw with: here only the bundled ones, never the system's.
|
|
14
17
|
export async function svgToPng(svg, {scale, text}) {
|
|
15
18
|
let render;
|
|
16
19
|
try {
|
|
@@ -20,7 +23,7 @@ export async function svgToPng(svg, {scale, text}) {
|
|
|
20
23
|
unavailable.code = 'svg-rasterizer-unavailable';
|
|
21
24
|
throw unavailable;
|
|
22
25
|
}
|
|
23
|
-
return new Uint8Array(await render(svg, {scale, background: 'rgba(0, 0, 0, 0)'
|
|
26
|
+
return new Uint8Array(await render(svg, {fonts: {useBundledFonts: text === true, loadSystemFonts: false}, scale, background: 'rgba(0, 0, 0, 0)'}));
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
// A local SVG file (a path source), read as bytes; PptxGenJS reads raster paths itself.
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import {XMLParser} from 'fast-xml-parser';
|
|
2
|
+
import {normalizeCrop, withoutSvgBlip} from './svg-image.js';
|
|
3
|
+
import {attachTextTags, decodeTextTag} from './code-provenance.js';
|
|
4
|
+
|
|
5
|
+
// FA-23 (OPF 0.15): image blocks and image backgrounds as native pictures.
|
|
6
|
+
//
|
|
7
|
+
// Every image block (core ComposedItem.image) exports as one native picture at core's frame. Its fit and focus are an
|
|
8
|
+
// a:srcRect; its treatments keep the DrawingML mapping 0.14's slide image used: the mask is a:prstGeom with core's guide
|
|
9
|
+
// values, the border an a:ln centered on the outline with a miter join, and the recolor and opacity a:grayscl or
|
|
10
|
+
// a:duotone then a:alphaModFix on the blip. An overlay is one native shape directly above its picture.
|
|
11
|
+
//
|
|
12
|
+
// An image background (core SlideComposition.backgroundImage) with alt text is a full-slide picture at the back of the
|
|
13
|
+
// slide (screen readers read its descr); without alt it is the native slide background fill (src/background.js), and its
|
|
14
|
+
// overlay is the first shape of the slide.
|
|
15
|
+
//
|
|
16
|
+
// OPF_IMAGE_V1 binds a picture to the image block's (or background's) own authored fields and the exact native geometry it
|
|
17
|
+
// was written with, so an unchanged picture imports back with its treatments and placement while an edited one stays an
|
|
18
|
+
// ordinary picture. OPF_IMAGE_OVERLAY_V1 does the same for an overlay shape: an edited overlay drops only the overlay.
|
|
19
|
+
export const IMAGE_TAG = 'OPF_IMAGE_V1', IMAGE_OVERLAY_TAG = 'OPF_IMAGE_OVERLAY_V1';
|
|
20
|
+
const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
21
|
+
const EMUS_PER_INCH = 914400;
|
|
22
|
+
const parser = new XMLParser({ignoreAttributes:false, attributeNamePrefix:'', parseTagValue:false, trimValues:false});
|
|
23
|
+
const decoder = new TextDecoder('utf-8', {fatal:true}), encoder = new TextEncoder();
|
|
24
|
+
const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
25
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
26
|
+
// Whitespace-only text between elements is not identity: the vendored writer indents, PowerPoint drops it on save.
|
|
27
|
+
const canonical = value => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
|
|
28
|
+
? Object.fromEntries(Object.keys(item).sort().filter(key => !(key === '#text' && typeof item[key] === 'string' && !item[key].trim())).map(key => [key, item[key]])) : item);
|
|
29
|
+
|
|
30
|
+
/** An image block's own fields (everything but its type, asset, id, caption and extensions). */
|
|
31
|
+
export const IMAGE_TREATMENT_KEYS = Object.freeze(['fit', 'focus', 'shape', 'cornerRadius', 'border', 'opacity', 'recolor', 'overlay', 'aspectRatio', 'placement']);
|
|
32
|
+
/** An image background's own fields besides its source. */
|
|
33
|
+
export const BACKGROUND_IMAGE_KEYS = Object.freeze(['alt', 'fit', 'focus', 'opacity', 'recolor', 'overlay']);
|
|
34
|
+
|
|
35
|
+
export const imageName = number => `OPF image ${number}`;
|
|
36
|
+
export const imageOverlayName = pictureName => `${pictureName} overlay`;
|
|
37
|
+
export const backgroundImageName = slide => `OPF background ${slide}`;
|
|
38
|
+
export const backgroundOverlayName = slide => `OPF background overlay ${slide}`;
|
|
39
|
+
|
|
40
|
+
// DrawingML for the shared treatment: preset geometry (core guide values), a centered solid line, and blip
|
|
41
|
+
// recolor/alpha effects applied in that order.
|
|
42
|
+
const presetXml = shape => `<a:prstGeom prst="${shape?.preset ?? 'rect'}"><a:avLst>${Object.entries(shape?.adjust ?? {}).map(([name, value]) => `<a:gd name="${name}" fmla="val ${value}"/>`).join('')}</a:avLst></a:prstGeom>`;
|
|
43
|
+
const colorXml = ({hex, alpha}, extraAlpha = 1) => {
|
|
44
|
+
const value = Math.round(alpha * extraAlpha * 100000);
|
|
45
|
+
return `<a:srgbClr val="${hex}">${value < 100000 ? `<a:alpha val="${value}"/>` : ''}</a:srgbClr>`;
|
|
46
|
+
};
|
|
47
|
+
const lineXml = border => border ? `<a:ln w="${Math.round(border.width / 96 * EMUS_PER_INCH)}" algn="ctr"><a:solidFill>${colorXml(border)}</a:solidFill><a:miter lim="800000"/></a:ln>` : '';
|
|
48
|
+
function blipEffects(effects) {
|
|
49
|
+
let xml = '';
|
|
50
|
+
if (effects?.recolor?.type === 'grayscale') xml += '<a:grayscl/>';
|
|
51
|
+
if (effects?.recolor?.type === 'duotone') xml += `<a:duotone>${colorXml({...effects.recolor.dark, alpha: 1})}${colorXml({...effects.recolor.light, alpha: 1})}</a:duotone>`;
|
|
52
|
+
if (typeof effects?.opacity === 'number') xml += `<a:alphaModFix amt="${Math.round(effects.opacity * 100000)}"/>`;
|
|
53
|
+
return xml;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Native blip fill without its package-local relationship id.
|
|
57
|
+
function blipFillIdentity(blipFill) {
|
|
58
|
+
const {['a:blip']: blip, ...rest} = normalizeCrop(blipFill) ?? {};
|
|
59
|
+
const {['r:embed']: _embed, ...blipRest} = withoutSvgBlip(blip) ?? {};
|
|
60
|
+
return {...rest, 'a:blip': blipRest};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The focus point in the stored picture's own axes. DrawingML crops (a:srcRect) the picture before it flips and turns it
|
|
64
|
+
// (the EXIF orientation is written as the frame's rot/flipH/flipV), so a point the author placed on the displayed
|
|
65
|
+
// picture maps back through that orientation.
|
|
66
|
+
function storedFocus({x, y}, orientation) {
|
|
67
|
+
switch (orientation) {
|
|
68
|
+
case 2: return {x: 1 - x, y};
|
|
69
|
+
case 3: return {x: 1 - x, y: 1 - y};
|
|
70
|
+
case 4: return {x, y: 1 - y};
|
|
71
|
+
case 5: return {x: y, y: x};
|
|
72
|
+
case 6: return {x: y, y: 1 - x};
|
|
73
|
+
case 7: return {x: 1 - y, y: 1 - x};
|
|
74
|
+
case 8: return {x: 1 - y, y: x};
|
|
75
|
+
default: return {x, y};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const srcRectValue = value => Math.round(value * 100000) || 0;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The native frame and crop of a picture: `box` (inches) is core's picture frame, `fit` cover | contain | stretch,
|
|
83
|
+
* `focus` core's focus point, `metadata` the embedded raster (width, height, orientation). `fitImage` is core's shared
|
|
84
|
+
* fit math. A plain contain picture (`shrink`: a rectangle with no line or overlay) is framed by the picture itself, as
|
|
85
|
+
* 0.14 content images were; every other picture keeps the frame and pads with negative a:srcRect insets, so the mask,
|
|
86
|
+
* line and overlay apply to core's frame.
|
|
87
|
+
*/
|
|
88
|
+
export function pictureFrame(metadata, {box, fit, focus = {x: .5, y: .5}, shrink = false}, fitImage) {
|
|
89
|
+
const orientation = metadata.orientation ?? 1;
|
|
90
|
+
const target = orientation >= 5 ? {x: box.x + (box.w - box.h) / 2, y: box.y + (box.h - box.w) / 2, w: box.h, h: box.w} : box;
|
|
91
|
+
const rotation = [0, 0, 0, 180, 0, 90, 90, 90, 270][orientation];
|
|
92
|
+
const transform = {rotation, flipH: orientation === 2 || orientation === 7, flipV: orientation === 4 || orientation === 5};
|
|
93
|
+
// A tiled picture fills its frame with the picture at its intrinsic size; nothing is cropped.
|
|
94
|
+
if (fit === 'tile') return {...target, crop: null, ...transform};
|
|
95
|
+
const placed = fitImage({x: target.x, y: target.y, width: target.w, height: target.h}, fit, metadata.width / metadata.height, storedFocus(focus, orientation));
|
|
96
|
+
if (shrink && fit === 'contain') return {x: placed.image.x, y: placed.image.y, w: placed.image.width, h: placed.image.height, crop: null, ...transform};
|
|
97
|
+
const crop = {l: srcRectValue(placed.crop.left), t: srcRectValue(placed.crop.top), r: srcRectValue(placed.crop.right), b: srcRectValue(placed.crop.bottom)};
|
|
98
|
+
return {...target, crop: Object.values(crop).some(Boolean) ? crop : null, ...transform};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Place each generated image picture at its frame, write its treatment, place its overlay shape, then bind the tagged
|
|
103
|
+
* ones to their manifests. Runs after PptxGenJS embedded the bytes (the frame needs the raster's own size).
|
|
104
|
+
* images: Map<objectName, {slide, role: 'block' | 'background', path, box (inches), fit, focus, shrink, effects, treatment}>;
|
|
105
|
+
* `treatment` (the authored fields) is stored in OPF_IMAGE_V1 when present.
|
|
106
|
+
* overlays: Map<objectName, {slide, owner, box (reference px), shape, hex, alpha, opacity, overlay}>; `owner` is the
|
|
107
|
+
* picture's objectName, or 'background' for the overlay of a native slide background fill; `overlay` (the authored value)
|
|
108
|
+
* is stored in OPF_IMAGE_OVERLAY_V1.
|
|
109
|
+
*/
|
|
110
|
+
export function placeImages(entries, images, overlays, {metadataFor, fail, fitImage}) {
|
|
111
|
+
if (!images.size && !overlays.size) return;
|
|
112
|
+
const manifests = new Map(), overlayManifests = new Map(), placed = new Set(), shaped = new Set();
|
|
113
|
+
for (const part of Object.keys(entries).filter(path => /^ppt\/slides\/slide\d+\.xml$/.test(path))) {
|
|
114
|
+
const xml = decoder.decode(entries[part]);
|
|
115
|
+
let next = xml.replace(/<p:pic>[\s\S]*?<\/p:pic>/g, picture => {
|
|
116
|
+
const name = picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
117
|
+
const image = images.get(name);
|
|
118
|
+
if (!image) return picture;
|
|
119
|
+
const embed = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
120
|
+
const metadata = metadataFor(part, embed);
|
|
121
|
+
if (!metadata) fail(image.path);
|
|
122
|
+
const frame = pictureFrame(metadata, image, fitImage);
|
|
123
|
+
const emu = value => Math.round(value * EMUS_PER_INCH);
|
|
124
|
+
const attributes = `${frame.rotation ? ` rot="${frame.rotation * 60000}"` : ''}${frame.flipH ? ' flipH="1"' : ''}${frame.flipV ? ' flipV="1"' : ''}`;
|
|
125
|
+
picture = picture.replace(/<a:xfrm\b[^>]*>[\s\S]*?<\/a:xfrm>/, `<a:xfrm${attributes}><a:off x="${emu(frame.x)}" y="${emu(frame.y)}"/><a:ext cx="${emu(frame.w)}" cy="${emu(frame.h)}"/></a:xfrm>`);
|
|
126
|
+
picture = picture.replace(/<a:srcRect\b[^>]*\/>/, '');
|
|
127
|
+
if (frame.crop) picture = picture.replace('<a:stretch>', `<a:srcRect l="${frame.crop.l}" t="${frame.crop.t}" r="${frame.crop.r}" b="${frame.crop.b}"/><a:stretch>`);
|
|
128
|
+
if (image.fit === 'tile') picture = picture.replace(/<a:stretch>[\s\S]*?<\/a:stretch>|<a:stretch\/>/, `<a:tile tx="0" ty="0" sx="${srcRectValue((metadata.dpiX ?? 96) / 96)}" sy="${srcRectValue((metadata.dpiY ?? 96) / 96)}" flip="none" algn="tl"/>`);
|
|
129
|
+
const effects = image.effects;
|
|
130
|
+
if (effects) {
|
|
131
|
+
picture = picture.replace(/<a:prstGeom\b[\s\S]*?<\/a:prstGeom>/, presetXml(effects.shape) + lineXml(effects.border));
|
|
132
|
+
const blip = blipEffects(effects);
|
|
133
|
+
if (blip) picture = picture.replace(/<a:blip\b([^>]*?)(?:\/>|>([\s\S]*?)<\/a:blip>)/, (_, attributes, inner = '') => `<a:blip${attributes}>${blip}${inner}</a:blip>`);
|
|
134
|
+
}
|
|
135
|
+
placed.add(name);
|
|
136
|
+
if (image.treatment !== undefined) {
|
|
137
|
+
const node = parser.parse(picture)['p:pic'];
|
|
138
|
+
manifests.set(name, {v: 1, role: image.role, slide: image.slide, path: image.path, treatment: image.treatment, properties: node['p:spPr'], blipFill: blipFillIdentity(node['p:blipFill'])});
|
|
139
|
+
}
|
|
140
|
+
return picture;
|
|
141
|
+
});
|
|
142
|
+
next = next.replace(/<p:sp>[\s\S]*?<\/p:sp>/g, shape => {
|
|
143
|
+
const name = shape.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
144
|
+
const overlay = overlays.get(name);
|
|
145
|
+
if (!overlay) return shape;
|
|
146
|
+
const emu = value => Math.round(value / 96 * EMUS_PER_INCH);
|
|
147
|
+
shape = shape.replace(/<p:spPr>[\s\S]*?<\/p:spPr>/, `<p:spPr><a:xfrm><a:off x="${emu(overlay.box.x)}" y="${emu(overlay.box.y)}"/><a:ext cx="${emu(overlay.box.width)}" cy="${emu(overlay.box.height)}"/></a:xfrm>${presetXml(overlay.shape)}<a:solidFill>${colorXml(overlay, overlay.opacity)}</a:solidFill><a:ln><a:noFill/></a:ln></p:spPr>`);
|
|
148
|
+
const node = parser.parse(shape)['p:sp'];
|
|
149
|
+
overlayManifests.set(name, {v: 1, slide: overlay.slide, owner: overlay.owner, overlay: overlay.overlay, properties: node['p:spPr']});
|
|
150
|
+
shaped.add(name);
|
|
151
|
+
return shape;
|
|
152
|
+
});
|
|
153
|
+
if (next !== xml) entries[part] = encoder.encode(next);
|
|
154
|
+
}
|
|
155
|
+
if (placed.size !== images.size) throw new Error('Missing generated image picture.');
|
|
156
|
+
if (shaped.size !== overlays.size) throw new Error('Missing generated image overlay.');
|
|
157
|
+
attachTextTags(entries, manifests, IMAGE_TAG, 'opfImage', 'image', {pictures: true});
|
|
158
|
+
attachTextTags(entries, overlayManifests, IMAGE_OVERLAY_TAG, 'opfImageOverlay', 'image overlay');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Import
|
|
163
|
+
|
|
164
|
+
function readTags(container, relationships, entries) {
|
|
165
|
+
const tags = [];
|
|
166
|
+
let unreadable = false;
|
|
167
|
+
for (const link of array(container?.['p:tags'])) {
|
|
168
|
+
const rel = relationships.get(link['r:id']);
|
|
169
|
+
if (rel?.type !== REL || rel.targetMode === 'External' || !entries[rel.path]) { unreadable = true; continue; }
|
|
170
|
+
try { tags.push(...array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag'])); } catch { unreadable = true; }
|
|
171
|
+
}
|
|
172
|
+
return {tags, unreadable};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The one tag of `name` on a shape, decoded, or null (absent); throws when it is ambiguous or sits beside another OPF tag.
|
|
176
|
+
function ownTag(container, relationships, entries, name) {
|
|
177
|
+
const {tags, unreadable} = readTags(container, relationships, entries);
|
|
178
|
+
const own = tags.filter(tag => tag.name?.toUpperCase() === name);
|
|
179
|
+
if (!own.length) return null;
|
|
180
|
+
if (unreadable || own.length !== 1 || tags.some(tag => /^OPF_/i.test(tag.name) && tag.name.toUpperCase() !== name)) throw Error('Ambiguous image identity.');
|
|
181
|
+
return decodeTextTag(own[0].val);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const pick = (value, keys) => Object.fromEntries(keys.filter(key => Object.hasOwn(value, key)).map(key => [key, value[key]]));
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Recover image blocks and an image background from unchanged tagged pictures and overlays of one slide.
|
|
188
|
+
* - `blocks`: Map<picture index, fields> to merge into the ordinary image block read from that picture;
|
|
189
|
+
* - `background`: the slide's image background from its tagged back picture, with the picture's index in `consumed`;
|
|
190
|
+
* - `backgroundOverlay`: the authored overlay of a native slide background fill;
|
|
191
|
+
* - `consumedShapes`: the overlay shapes recovered (they are no content).
|
|
192
|
+
* readPicture(picture) returns the ordinary imported image payload; `valid(kind, value)` checks a recovered image block
|
|
193
|
+
* (`kind` 'block') or background (`kind` 'background') against the OPF format.
|
|
194
|
+
*/
|
|
195
|
+
export function importImages({pictures, shapes, relationships, entries, slideIndex, readPicture, valid, report}) {
|
|
196
|
+
const slide = `slides.${slideIndex}`;
|
|
197
|
+
const blocks = new Map(), consumed = new Set(), consumedShapes = new Set();
|
|
198
|
+
let background, backgroundOverlay;
|
|
199
|
+
const invalid = (path, message) => report({code: 'invalid-image-provenance', path, message});
|
|
200
|
+
// Overlay shapes first: each names the picture (or the slide background) it belongs to.
|
|
201
|
+
const overlays = new Map();
|
|
202
|
+
for (const [index, shape] of shapes.entries()) {
|
|
203
|
+
let manifest;
|
|
204
|
+
try { manifest = ownTag(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries, IMAGE_OVERLAY_TAG); }
|
|
205
|
+
catch { invalid(slide, 'A tagged OPF image overlay is ambiguous; it was imported as an ordinary shape.'); continue; }
|
|
206
|
+
if (manifest === null) continue;
|
|
207
|
+
const name = shape['p:nvSpPr']?.['p:cNvPr']?.name;
|
|
208
|
+
const expected = manifest?.owner === 'background' ? backgroundOverlayName(slide) : typeof manifest?.owner === 'string' ? imageOverlayName(manifest.owner) : undefined;
|
|
209
|
+
if (manifest?.v !== 1 || manifest.slide !== slide || name !== expected || !object(manifest.overlay) || canonical(shape['p:spPr']) !== canonical(manifest.properties)) {
|
|
210
|
+
overlays.set(manifest?.owner, {index, edited: true});
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (overlays.has(manifest.owner)) { overlays.set(manifest.owner, {index, edited: true}); continue; }
|
|
214
|
+
overlays.set(manifest.owner, {index, overlay: manifest.overlay});
|
|
215
|
+
}
|
|
216
|
+
const takeOverlay = (owner, path) => {
|
|
217
|
+
const found = overlays.get(owner);
|
|
218
|
+
if (!found) return undefined;
|
|
219
|
+
if (found.edited) {
|
|
220
|
+
invalid(path, 'The tagged OPF image overlay is missing, edited or ambiguous, so the image is recovered without its overlay; the remaining native shape is imported as ordinary content.');
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
consumedShapes.add(found.index);
|
|
224
|
+
return found.overlay;
|
|
225
|
+
};
|
|
226
|
+
const edges = new Set();
|
|
227
|
+
for (const [index, picture] of pictures.entries()) {
|
|
228
|
+
let manifest;
|
|
229
|
+
try { manifest = ownTag(picture['p:nvPicPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries, IMAGE_TAG); }
|
|
230
|
+
catch { invalid(slide, 'A tagged OPF image is ambiguous; it was imported as an ordinary picture.'); continue; }
|
|
231
|
+
if (manifest === null) continue;
|
|
232
|
+
const name = picture['p:nvPicPr']?.['p:cNvPr']?.name;
|
|
233
|
+
const role = manifest?.role;
|
|
234
|
+
const path = role === 'background' ? `${slide}.design.background` : slide;
|
|
235
|
+
try {
|
|
236
|
+
if (manifest?.v !== 1 || manifest.slide !== slide || !['block', 'background'].includes(role) || !object(manifest.treatment)) throw Error('Invalid image manifest.');
|
|
237
|
+
if (role === 'block' ? !/^OPF image \d+$/.test(name ?? '') : name !== backgroundImageName(slide)) throw Error('Image identity changed.');
|
|
238
|
+
if (canonical(picture['p:spPr']) !== canonical(manifest.properties) || canonical(blipFillIdentity(picture['p:blipFill'])) !== canonical(normalizeCrop(manifest.blipFill))) throw Error('Image geometry or effects changed.');
|
|
239
|
+
} catch {
|
|
240
|
+
invalid(path, `An edited, ambiguous or invalid tagged OPF ${role === 'background' ? 'background picture' : 'image'} was imported as an ordinary picture; its OPF ${role === 'background' ? 'background' : 'treatment and placement'} are not reconstructed.`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (role === 'background') {
|
|
244
|
+
const item = readPicture(picture);
|
|
245
|
+
const src = item?.payload?.image?.src;
|
|
246
|
+
const fields = pick(manifest.treatment, BACKGROUND_IMAGE_KEYS.filter(key => key !== 'overlay'));
|
|
247
|
+
const overlay = takeOverlay(name, `${path}.overlay`);
|
|
248
|
+
const value = {type: 'image', src, ...fields, ...(overlay ? {overlay} : {})};
|
|
249
|
+
if (typeof src !== 'string' || background || !valid('background', value)) { invalid(path, 'A tagged OPF background picture does not form a valid image background; it was imported as an ordinary picture.'); continue; }
|
|
250
|
+
background = value;
|
|
251
|
+
consumed.add(index);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const fields = pick(manifest.treatment, IMAGE_TREATMENT_KEYS.filter(key => key !== 'overlay'));
|
|
255
|
+
const overlay = takeOverlay(name, `${path}.overlay`);
|
|
256
|
+
if (overlay) fields.overlay = overlay;
|
|
257
|
+
// At most one placed block per edge: a copied tagged picture keeps its other fields and flows.
|
|
258
|
+
const edge = fields.placement?.edge;
|
|
259
|
+
if (edge !== undefined && edges.has(edge)) { delete fields.placement; invalid(path, `Two tagged OPF images are placed on the ${edge} edge; the second imports without its placement.`); }
|
|
260
|
+
else if (edge !== undefined) edges.add(edge);
|
|
261
|
+
const item = readPicture(picture);
|
|
262
|
+
const candidate = {...item?.payload, ...fields};
|
|
263
|
+
if (!item?.payload || !valid('block', candidate)) { invalid(path, 'A tagged OPF image does not form a valid image block; it was imported as an ordinary picture.'); if (edge !== undefined) edges.delete(edge); continue; }
|
|
264
|
+
blocks.set(index, fields);
|
|
265
|
+
}
|
|
266
|
+
// The overlay of a native slide background fill.
|
|
267
|
+
backgroundOverlay = takeOverlay('background', `${slide}.design.background.overlay`);
|
|
268
|
+
const backgroundOverlayShape = backgroundOverlay ? overlays.get('background').index : undefined;
|
|
269
|
+
for (const [owner, found] of overlays) if (!found.edited && !consumedShapes.has(found.index) && owner !== 'background') {
|
|
270
|
+
invalid(`${slide}`, 'A tagged OPF image overlay no longer has its picture; it was imported as an ordinary shape.');
|
|
271
|
+
}
|
|
272
|
+
return {blocks, background, backgroundOverlay, backgroundOverlayShape, consumed, consumedShapes};
|
|
273
|
+
}
|