@openpresentation/opf-pptx 0.17.0 → 0.18.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 +9 -7
- package/dist/document-provenance.js +7 -5
- package/dist/furniture-provenance.js +179 -69
- package/dist/image-fallback-node.js +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +78 -27
- 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/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -24,13 +24,14 @@ import {headingValue,joinRichLines} from './rich-heading.js';
|
|
|
24
24
|
import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
|
|
25
25
|
import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
|
|
26
26
|
import {attachQuoteTags,quoteManifest,importQuoteGroups,quotePhotoName} from './quote-provenance.js';
|
|
27
|
-
import {attachFurnitureTags, furnitureManifest, importFurniture,
|
|
27
|
+
import {attachFurnitureTags, furnitureManifest, importFurniture, logoShape, staticDateFallback, stampFurnitureImageKeys} from './furniture-provenance.js';
|
|
28
28
|
import {restoreRunColors} from './run-colors.js';
|
|
29
29
|
import {joinWrappedText} from './content-topology.js';
|
|
30
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
33
|
import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, nativePlaceholderForPart, writeNativeMasters} from './native-furniture.js';
|
|
34
|
+
import {liftMasterFurniture} from './master-furniture.js';
|
|
34
35
|
import {importImageOrientation} from './image-import.js';
|
|
35
36
|
import {extractSignals, normalizeSignalOptions, themeFactsFor} from './import-signals.js';
|
|
36
37
|
import {placeImages, importImages, imageName, imageOverlayName, backgroundImageName, backgroundOverlayName, IMAGE_TREATMENT_KEYS, BACKGROUND_IMAGE_KEYS} from './image-provenance.js';
|
|
@@ -278,10 +279,13 @@ export async function toPptx(input, options = {}) {
|
|
|
278
279
|
context.quoteTags = new Map();
|
|
279
280
|
context.quotePhotos = new Map();
|
|
280
281
|
context.furnitureTags = new Map();
|
|
281
|
-
context.
|
|
282
|
+
context.furnitureImages = new Map();
|
|
282
283
|
context.furnitureFields = new Map();
|
|
283
284
|
context.furnitureManifests = new Map();
|
|
284
285
|
context.nativeFurniture = new Map();
|
|
286
|
+
context.nativeFurnitureBands = new Map();
|
|
287
|
+
// RR-72: each slide's furniture parts and the object names of their shapes, for the slide master and layouts.
|
|
288
|
+
context.furnitureParts = [];
|
|
285
289
|
context.hostDate = options.date;
|
|
286
290
|
context.authored = authored;
|
|
287
291
|
context.codeTags = new Map();
|
|
@@ -408,6 +412,19 @@ export async function fromPptx(input, options = {}) {
|
|
|
408
412
|
// Layouts and the master are read once however many slides inherit a placeholder from them (RR-11).
|
|
409
413
|
const cached = read => { const memo = new Map(); return path => { if (!memo.has(path)) memo.set(path, read(path)); return memo.get(path); }; };
|
|
410
414
|
const cachedRelationships = cached(path => parseRelationships(entries, path)), cachedPart = cached(path => parseOptionalXml(entries, path));
|
|
415
|
+
// RR-72: the furniture a slide shows from its layout and slide master (src/master-furniture.js), read once per part.
|
|
416
|
+
const inheritedParts = new Map();
|
|
417
|
+
const inheritedPart = path => {
|
|
418
|
+
if (!path) return undefined;
|
|
419
|
+
if (!inheritedParts.has(path)) {
|
|
420
|
+
const element = /slideMaster/.test(path) ? 'p:sldMaster' : 'p:sldLayout', root = cachedPart(path)?.[element];
|
|
421
|
+
const tree = root?.['p:cSld']?.['p:spTree'];
|
|
422
|
+
inheritedParts.set(path, root ? {path, root, relationships: cachedRelationships(path), shapes: nativeTextShapes(tree), pictures: nativePictures(tree),
|
|
423
|
+
paragraphs: nativeShapeParagraphs(decodeText(entries[path]), element)} : undefined);
|
|
424
|
+
}
|
|
425
|
+
return inheritedParts.get(path);
|
|
426
|
+
};
|
|
427
|
+
const relatedPath = (path, type) => path ? [...cachedRelationships(path).values()].find(rel => rel.type === `http://schemas.openxmlformats.org/officeDocument/2006/relationships/${type}` && rel.targetMode !== 'External' && entries[rel.path])?.path : undefined;
|
|
411
428
|
const furnitureContexts = slidePaths.map((slidePath, index) => {
|
|
412
429
|
const root = parseRequiredXml(entries, slidePath)['p:sld'];
|
|
413
430
|
if (!root) throw new OPFPptxError('invalid-pptx', `PPTX slide is not a PresentationML slide: ${slidePath}.`, {path: slidePath});
|
|
@@ -417,7 +434,12 @@ export async function fromPptx(input, options = {}) {
|
|
|
417
434
|
path: slidePath, slideWidth: Number(presentationRoot['p:sldSz']?.cx), relationshipsOf: cachedRelationships, readPart: cachedPart,
|
|
418
435
|
paragraphs: nativeShapeParagraphs(decodeText(entries[slidePath])),
|
|
419
436
|
readPicture: picture => importPicture(entries, picture, slidePath, relationships,
|
|
420
|
-
diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${index}.design`}))
|
|
437
|
+
diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${index}.design`})),
|
|
438
|
+
inherited: () => {
|
|
439
|
+
const layout = relatedPath(slidePath, 'slideLayout'), master = relatedPath(layout, 'slideMaster');
|
|
440
|
+
return Object.fromEntries([['layout', inheritedPart(layout)], ['master', inheritedPart(master)]].filter(([, part]) => part).map(([on, part]) => [on, {...part,
|
|
441
|
+
readPicture: picture => importPicture(entries, picture, part.path, part.relationships, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${index}.design`}))}]));
|
|
442
|
+
}};
|
|
421
443
|
});
|
|
422
444
|
// Native sections (PowerPoint's own section list, `Default Section` = none) name each slide's `{{slide.section}}` for the footer text check
|
|
423
445
|
// and, after the slides, are reconciled with the stored value in restoreDocumentProvenance.
|
|
@@ -432,7 +454,7 @@ export async function fromPptx(input, options = {}) {
|
|
|
432
454
|
...(stored.organization !== undefined ? {organization: stored.organization} : {}),
|
|
433
455
|
...(stored.speaker !== undefined ? {speaker: stored.speaker} : {}),
|
|
434
456
|
};
|
|
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})),
|
|
457
|
+
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, ...(typeof item.role === 'string' ? {role: item.role} : {}), ...(logoShape(item.logo) !== undefined ? {logo: logoShape(item.logo)} : {})})),
|
|
436
458
|
draw: (template, {index, count, section}) => drawFurnitureText(template, {index, count, section}, importedMetadata)});
|
|
437
459
|
if (Object.keys(furniture.design).length) imported.design = {...imported.design, ...furniture.design};
|
|
438
460
|
if (furniture.organization) imported.organization = furniture.organization;
|
|
@@ -530,21 +552,19 @@ function applyRunColors(imported, slideProvenance, restoredGroups, report, optio
|
|
|
530
552
|
restoreRunColors(imported.slides, info, resolve, report);
|
|
531
553
|
}
|
|
532
554
|
|
|
533
|
-
// A consumed logo picture (OPF_LOGO_V1) never becomes content. Its own image restores the logo only when
|
|
534
|
-
//
|
|
535
|
-
//
|
|
555
|
+
// A consumed logo picture (OPF_LOGO_V1) never becomes content. Its own image restores the organization's logo only when the
|
|
556
|
+
// stored document did not (RR-71: logos live on the organization), so a package exported without provenance still keeps its
|
|
557
|
+
// logo: on the primary organization, else on a new one named after the deck. The picture is one drawn asset, so it becomes the
|
|
558
|
+
// organization's single logo for every shape.
|
|
536
559
|
function restoreLogoFallback(imported, contexts) {
|
|
537
560
|
const organizations = asArray(imported.organization);
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
if (fallback.path.startsWith('slides.')) { if (slide.design?.logo === undefined) slide.design = {...slide.design, logo: image}; }
|
|
546
|
-
else if (imported.design?.logo === undefined && !organizations.some(item => item?.logo !== undefined)) imported.design = {...imported.design, logo: image};
|
|
547
|
-
});
|
|
561
|
+
if (organizations.some(item => item?.logo !== undefined)) return;
|
|
562
|
+
const image = contexts.map(context => context.logoFallback?.image).find(Boolean);
|
|
563
|
+
if (!image) return;
|
|
564
|
+
const primary = organizations.find(item => item?.role === 'primary') ?? organizations[0];
|
|
565
|
+
if (!primary) { imported.organization = {id: 'brand', name: imported.name ?? 'Brand', logo: image}; return; }
|
|
566
|
+
const index = organizations.indexOf(primary), withLogo = {...primary, logo: image};
|
|
567
|
+
imported.organization = Array.isArray(imported.organization) ? imported.organization.map((item, at) => at === index ? withLogo : item) : withLogo;
|
|
548
568
|
}
|
|
549
569
|
|
|
550
570
|
function readPptxZip(input) {
|
|
@@ -752,7 +772,7 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
752
772
|
diagnostic => options.onDiagnostic?.({...diagnostic, path: watermarkPath}));
|
|
753
773
|
if (watermark.design) slide.design = {...slide.design, ...watermark.design};
|
|
754
774
|
nativeContext.watermarkPictures = watermark.consumed;
|
|
755
|
-
// The generated
|
|
775
|
+
// The generated cover logo picture is not content; the logo returns from the stored organization (or, without one, from the picture).
|
|
756
776
|
const logoPath = `slides.${slideIndex}.design.logo`;
|
|
757
777
|
const logo = importLogo(nativeContext.pictures, relationships, entries, slideIndex,
|
|
758
778
|
picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
|
|
@@ -1948,6 +1968,7 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
|
|
|
1948
1968
|
const geometry = composeSlide(opfSlide, composeOptions);
|
|
1949
1969
|
// The default footer band (date left, text center, number right): where a footer added natively lands (RR-11).
|
|
1950
1970
|
if (slideIndex === 0) context.defaultFooterOptions = composeOptions;
|
|
1971
|
+
slideContext.zoneBand = zoneBands(composeOptions);
|
|
1951
1972
|
slide.background = { color: slideContext.colors.background };
|
|
1952
1973
|
// An image background is core's geometry.backgroundImage (slide, deck, then theme background); every other kind is the
|
|
1953
1974
|
// effective design or theme background definition.
|
|
@@ -2809,10 +2830,19 @@ function defaultFooterParts(composeOptions) {
|
|
|
2809
2830
|
}
|
|
2810
2831
|
}
|
|
2811
2832
|
|
|
2833
|
+
// The band core gives a lone text part in a zone (x and width in pixels): where a footer added natively sits.
|
|
2834
|
+
function zoneBands(composeOptions) {
|
|
2835
|
+
let bands;
|
|
2836
|
+
return zone => (bands ??= new Map((defaultFooterParts(composeOptions) ?? []).map(part => [part.zone, {x: part.box.x, width: part.box.width}]))).get(zone);
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2812
2839
|
// The slide and deck as given to toPptx (variables not yet resolved), for the furniture manifest's authored header and footer text.
|
|
2813
2840
|
const authoredFurniture = (context, slideIndex) => ({presentation: context.authored, slide: context.authored?.slides?.[slideIndex]});
|
|
2814
2841
|
|
|
2815
2842
|
async function addFurniture(slide,presentation,source,layout,context,options,slideIndex) {
|
|
2843
|
+
// RR-72: every slide is listed, a slide without furniture too (it may need a layout that hides the master's).
|
|
2844
|
+
const drawn = [];
|
|
2845
|
+
context.furnitureParts[slideIndex] = {path: `ppt/slides/slide${slideIndex + 1}.xml`, parts: drawn};
|
|
2816
2846
|
if(!layout){
|
|
2817
2847
|
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`});
|
|
2818
2848
|
const manifest = furnitureManifest(presentation, source, {parts: []}, slideIndex, undefined, undefined, authoredFurniture(context, slideIndex));
|
|
@@ -2823,12 +2853,18 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
|
|
|
2823
2853
|
// RR-11: the first footer text, date and slide number that fit one line are native placeholders (src/native-furniture.js).
|
|
2824
2854
|
const natives = nativeFurnitureParts(layout);
|
|
2825
2855
|
for(const [index,part]of layout.parts.entries()){
|
|
2856
|
+
const entry = {index, kind: part.kind, zone: part.zone, field: part.field, type: part.type, path: part.path, native: natives.has(index), names: []};
|
|
2857
|
+
drawn.push(entry);
|
|
2826
2858
|
if(part.type==='image'){
|
|
2827
2859
|
const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
if (objectName
|
|
2831
|
-
|
|
2860
|
+
// A logo reference draws the organization's asset: a source that cannot be drawn is reported where the asset is written.
|
|
2861
|
+
const objectName = await addImagePayload(slide,presentation,part.image,region,part.reference!==undefined?part.sourcePath:part.path,context,options);
|
|
2862
|
+
if (objectName) {
|
|
2863
|
+
entry.names.push(objectName);
|
|
2864
|
+
context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:index});
|
|
2865
|
+
// RR-71: a part drawn from a logo reference (var:organization.logo.icon) records its picture, so import can tell it is unchanged.
|
|
2866
|
+
if (part.reference !== undefined) context.furnitureImages.set(`ppt/slides/slide${slideIndex + 1}.xml|${part.kind}.${part.zone}`, objectName);
|
|
2867
|
+
}
|
|
2832
2868
|
}else{
|
|
2833
2869
|
if(!part.fit?.sourceLines)throw new OPFPptxError('missing-furniture-layout','Repeated text requires accepted source lines from core.',{path:part.path});
|
|
2834
2870
|
// Slide numbers ({{slide.number}}) and current dates become native PowerPoint fields; {{deck.slideCount}},
|
|
@@ -2848,8 +2884,15 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
|
|
|
2848
2884
|
if(marker) staticDates.set(index,marker);
|
|
2849
2885
|
}
|
|
2850
2886
|
}
|
|
2851
|
-
addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.mutedColor,keepEmpty:true,objectName:`OPF furniture ${slideIndex} part ${index}`,furniture:{group:String(slideIndex),part:
|
|
2852
|
-
|
|
2887
|
+
addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.mutedColor,keepEmpty:true,objectName:`OPF furniture ${slideIndex} part ${index}`,furniture:{group:String(slideIndex),part:index},liveFields,links:part.links});
|
|
2888
|
+
entry.names.push(...part.fit.lines.map((line, number) => `OPF furniture ${slideIndex} part ${index} line ${number}`));
|
|
2889
|
+
if(natives.has(index)){
|
|
2890
|
+
const name=`OPF furniture ${slideIndex} part ${index} line 0`;
|
|
2891
|
+
context.nativeFurniture.set(name,natives.get(index));
|
|
2892
|
+
// RR-71: a part in a row with other parts is as wide as its text; the master's placeholder spans the zone.
|
|
2893
|
+
const band=context.zoneBand?.(part.zone);
|
|
2894
|
+
if(band&&part.box.width<band.width-.5)context.nativeFurnitureBands.set(name,band);
|
|
2895
|
+
}
|
|
2853
2896
|
}
|
|
2854
2897
|
}
|
|
2855
2898
|
const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates, natives, authoredFurniture(context, slideIndex));
|
|
@@ -3555,7 +3598,7 @@ async function resolveImage(asset, presentation, options, path, outcome = {}) {
|
|
|
3555
3598
|
if (raster) return dataUriMediaType(resolved.data) === raster.mediaType ? resolved : {...resolved, data: `data:${raster.mediaType};base64,${bytesToBase64(bytes)}`};
|
|
3556
3599
|
const svg = svgDataUriBytes(resolved.data);
|
|
3557
3600
|
if (svg) return resolveSvgImage(svg, options, path, outcome);
|
|
3558
|
-
const message = `The image is not a readable PNG, JPEG, GIF or WebP (${dataUriMediaType(resolved.data) ?? "unknown type"}); supply a raster through imageResolver (for example opf-render
|
|
3601
|
+
const message = `The image is not a readable PNG, JPEG, GIF or WebP (${dataUriMediaType(resolved.data) ?? "unknown type"}); supply a raster through imageResolver (for example opf-render toPng).`;
|
|
3559
3602
|
if (options.strictAssets) throw new OPFPptxError("unsupported-image-dimensions", `Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. ${message}`, { path });
|
|
3560
3603
|
options.onDiagnostic?.({ code: "unresolved-asset", path, reason: "unsupported-format", message });
|
|
3561
3604
|
outcome.reported = true;
|
|
@@ -3799,9 +3842,9 @@ async function normalizePptxZip(raw, context) {
|
|
|
3799
3842
|
attachQuoteTags(entries,context.quoteTags);
|
|
3800
3843
|
attachAnnotationTags(entries,context);
|
|
3801
3844
|
attachFurnitureFields(entries,context.furnitureFields);
|
|
3802
|
-
attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests
|
|
3845
|
+
attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests);
|
|
3803
3846
|
// RR-11: the recorded footer shapes become PowerPoint's date, footer and slide-number placeholders.
|
|
3804
|
-
context.nativePlaceholders = attachNativePlaceholders(entries,context.nativeFurniture);
|
|
3847
|
+
context.nativePlaceholders = attachNativePlaceholders(entries,context.nativeFurniture,context.nativeFurnitureBands);
|
|
3805
3848
|
// The "Image unavailable" panel of an unresolved logo: identity only, so import does not read it as content.
|
|
3806
3849
|
attachTextTags(entries,context.logoPlaceholderTags,LOGO_TAG,'opfLogoPlaceholder','logo placeholder');
|
|
3807
3850
|
for(const [part,bytes]of Object.entries(entries)){
|
|
@@ -3905,6 +3948,8 @@ async function normalizePptxZip(raw, context) {
|
|
|
3905
3948
|
});
|
|
3906
3949
|
// A picture repeated across slides embeds once (after fitting, which needs each slide's own relationship).
|
|
3907
3950
|
dedupeMedia(entries, imageMetadata, resolveRelationshipTarget);
|
|
3951
|
+
// The media keys of the logo-reference pictures, now that the package holds its final media (src/furniture-provenance.js).
|
|
3952
|
+
stampFurnitureImageKeys(entries, context.furnitureManifests, context.furnitureImages, parseRelationships);
|
|
3908
3953
|
// Charts and notes follow the language and fonts of the slide they belong to.
|
|
3909
3954
|
context.partSlides = new Map();
|
|
3910
3955
|
for (const part of Object.keys(entries)) {
|
|
@@ -3951,6 +3996,12 @@ async function normalizePptxZip(raw, context) {
|
|
|
3951
3996
|
}
|
|
3952
3997
|
reportPerSlideNotesScriptFonts(context.scriptFonts, slideThemes);
|
|
3953
3998
|
}
|
|
3999
|
+
// RR-72: furniture drawn the same on several slides moves to the slide master or a layout (src/master-furniture.js).
|
|
4000
|
+
try {
|
|
4001
|
+
liftMasterFurniture(output, context.furnitureParts, diagnostic => context.reportDiagnostic?.(diagnostic));
|
|
4002
|
+
} catch (error) {
|
|
4003
|
+
throw new OPFPptxError('packaging-failed', 'Header and footer furniture could not be written to the slide master and layouts.', {cause: errorMessage(error)});
|
|
4004
|
+
}
|
|
3954
4005
|
finalizeFontsUsed(output);
|
|
3955
4006
|
// Document references record evidence from the final normalized parts.
|
|
3956
4007
|
if (context.documentProvenance) {
|
package/dist/logo-provenance.js
CHANGED
|
@@ -3,13 +3,13 @@ import {normalizeCrop, withoutSvgBlip} from './svg-image.js';
|
|
|
3
3
|
import {decodeTextTag, encodeTextTag} from './code-provenance.js';
|
|
4
4
|
import {pictureTransform} from './image-geometry.js';
|
|
5
5
|
|
|
6
|
-
// The
|
|
7
|
-
//
|
|
6
|
+
// The cover and section logo (core's `geometry.logo`: the primary organization's full logo, or the
|
|
7
|
+
// organization a `design.logo` reference names, RR-71) exports as one native
|
|
8
8
|
// picture per slide named `OPF logo`, after the watermark and before content. It
|
|
9
9
|
// is fitted into core's box without cropping, anchored left and vertically
|
|
10
10
|
// centered (the preview's `xMinYMid meet`). The native tag records the exact
|
|
11
11
|
// picture, so an unchanged picture is consumed on import (the logo itself returns
|
|
12
|
-
// from the document
|
|
12
|
+
// from the document record's organization) while an edited one stays an
|
|
13
13
|
// ordinary picture and reports `invalid-logo-provenance`.
|
|
14
14
|
const TAG = 'OPF_LOGO_V1', REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
15
15
|
const EMUS_PER_INCH = 914400;
|
|
@@ -19,8 +19,8 @@ const array = value => value === undefined ? [] : Array.isArray(value) ? value :
|
|
|
19
19
|
// Whitespace-only text between elements is not identity: the vendored writer indents, PowerPoint drops it on save.
|
|
20
20
|
const canonical = value => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
|
|
21
21
|
? Object.fromEntries(Object.keys(item).sort().filter(key => !(key === '#text' && typeof item[key] === 'string' && !item[key].trim())).map(key => [key, item[key]])) : item);
|
|
22
|
-
// The OPF paths core reports for a logo source.
|
|
23
|
-
const LOGO_PATH = /^
|
|
22
|
+
// The OPF paths core reports for a logo source: the organization's `logo`, then a shape and a tone below it.
|
|
23
|
+
const LOGO_PATH = /^organization(?:\.\d{1,9})?\.logo(?:\.[A-Za-z]{1,16}){0,2}$/;
|
|
24
24
|
|
|
25
25
|
export const LOGO_TAG = TAG;
|
|
26
26
|
export const logoName = () => 'OPF logo';
|
|
@@ -138,10 +138,10 @@ export function importLogoPlaceholders(shapes, relationships, entries, slideInde
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
/**
|
|
141
|
-
* Consume an unchanged tagged logo picture: it is
|
|
142
|
-
*
|
|
141
|
+
* Consume an unchanged tagged logo picture: it is drawn from the organization's
|
|
142
|
+
* logo, never content. Returns the consumed picture indexes
|
|
143
143
|
* and `fallback`, the picture's own `{path, image}`: the caller uses it for the
|
|
144
|
-
* logo only when the stored document
|
|
144
|
+
* organization's logo only when the stored document did not restore one (an
|
|
145
145
|
* export without provenance, or one that omitted the field), so the logo is never
|
|
146
146
|
* lost. An edited, ambiguous or damaged tagged picture stays an ordinary picture
|
|
147
147
|
* and is reported. readPicture(picture) returns the ordinary imported payload.
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// RR-72: header and footer furniture on the slide master and layouts (docs/native-header-footer.md, "Master furniture").
|
|
2
|
+
//
|
|
3
|
+
// Furniture parts PowerPoint has no placeholder for (a logo or other zone image, socials, header text, footer text
|
|
4
|
+
// other than the one native `ftr`) are drawn by core on every slide. When a part is drawn the same on two or more slides
|
|
5
|
+
// (the same XML: content, box, style, picture bytes), it is written once instead:
|
|
6
|
+
//
|
|
7
|
+
// - on the slide master, when it is the same on every slide that shows liftable furniture (two or more slides);
|
|
8
|
+
// - on a layout (a copy of the master's layout, with RR-11's placeholders), when it is shared by two or more slides
|
|
9
|
+
// but not by all of them: one layout per distinct set, so a logo with onLight/onDark variants gets one layout per
|
|
10
|
+
// background tone, and a section's header text one layout per section;
|
|
11
|
+
// - a slide that shows none of the master's parts (its footer hidden, `design.footer: false`) uses a layout whose
|
|
12
|
+
// `showMasterSp="0"` hides the master's furniture.
|
|
13
|
+
//
|
|
14
|
+
// A part stays an ordinary shape on its slide when it is drawn on that slide only (nothing repeats, a one-slide deck
|
|
15
|
+
// included), when it holds a live field (a header slide number or current date: its cached value is per slide) or when
|
|
16
|
+
// slide content overlaps its box: PowerPoint draws master and layout shapes beneath every slide shape, while core paints
|
|
17
|
+
// furniture above content, so such a part stays above the content on its slide (`furniture-on-slide` diagnostic).
|
|
18
|
+
//
|
|
19
|
+
// Provenance. A lifted shape keeps its OPF_FURNITURE_V1 tag, with the slot (`footer.left.image`) in place of the slide
|
|
20
|
+
// group and part. Each slide's manifest moves the part (its topology entry, the definition's flag, and its stored text
|
|
21
|
+
// template or logo reference) into `shared`, keyed by slot, with `on: 'master' | 'layout'`; importers before 0.18,
|
|
22
|
+
// which read only slide shapes, ignore `shared` and read the rest of the slide's furniture as before.
|
|
23
|
+
|
|
24
|
+
import {decodeTextTag, encodeTextTag} from './code-provenance.js';
|
|
25
|
+
|
|
26
|
+
const dec = new TextDecoder('utf-8', {fatal: true}), enc = new TextEncoder();
|
|
27
|
+
const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
|
28
|
+
const TAGS_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.tags+xml';
|
|
29
|
+
const LAYOUT_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml';
|
|
30
|
+
const NS = 'http://schemas.openxmlformats.org/presentationml/2006/main';
|
|
31
|
+
const TAG = 'OPF_FURNITURE_V1';
|
|
32
|
+
|
|
33
|
+
const relsOf = path => path.replace(/([^/]+)$/, '_rels/$1.rels');
|
|
34
|
+
const attribute = (xml, name) => new RegExp(`\\s${name}="([^"]*)"`).exec(xml)?.[1];
|
|
35
|
+
const escapeAttribute = value => value.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
|
36
|
+
const unescapeAttribute = value => value.replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
37
|
+
const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
38
|
+
|
|
39
|
+
function resolvePart(source, target) {
|
|
40
|
+
if (target.startsWith('/')) return target.slice(1);
|
|
41
|
+
const parts = source.split('/').slice(0, -1);
|
|
42
|
+
for (const piece of target.split('/')) {
|
|
43
|
+
if (piece === '..') parts.pop();
|
|
44
|
+
else if (piece !== '.' && piece) parts.push(piece);
|
|
45
|
+
}
|
|
46
|
+
return parts.join('/');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function relativeTarget(source, path) {
|
|
50
|
+
const from = source.split('/').slice(0, -1), to = path.split('/');
|
|
51
|
+
let common = 0;
|
|
52
|
+
while (common < from.length && common < to.length - 1 && from[common] === to[common]) common++;
|
|
53
|
+
return [...Array(from.length - common).fill('..'), ...to.slice(common)].join('/');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The relationships of one part: [{node, id, type, target, external, path}].
|
|
57
|
+
function relationships(xml) {
|
|
58
|
+
return [...xml.matchAll(/<Relationship\b[^>]*\/>/g)].map(([node]) => ({node, id: attribute(node, 'Id'), type: attribute(node, 'Type') ?? '',
|
|
59
|
+
target: unescapeAttribute(attribute(node, 'Target') ?? ''), external: attribute(node, 'TargetMode') === 'External'}));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Top-level text shapes and pictures by object name (generated furniture is never grouped).
|
|
63
|
+
const shapeElements = xml => [...xml.matchAll(/<p:(sp|pic)>[\s\S]*?<\/p:\1>/g)].map(match => ({xml: match[0], kind: match[1], name: unescapeAttribute(match[0].match(/<p:cNvPr\b[^>]*\bname="([^"]*)"/)?.[1] ?? '')}));
|
|
64
|
+
const RELATIONSHIP_ATTRIBUTE = /\br:(embed|link|id|pict)="([^"]*)"/g;
|
|
65
|
+
|
|
66
|
+
function bounds(xml) {
|
|
67
|
+
const boxes = [...xml.matchAll(/<a:off x="(-?\d+)" y="(-?\d+)"\/>\s*<a:ext cx="(\d+)" cy="(\d+)"\/>/g)].map(match => match.slice(1).map(Number));
|
|
68
|
+
if (!boxes.length) return undefined;
|
|
69
|
+
const left = Math.min(...boxes.map(box => box[0])), top = Math.min(...boxes.map(box => box[1]));
|
|
70
|
+
return {left, top, right: Math.max(...boxes.map(box => box[0] + box[2])), bottom: Math.max(...boxes.map(box => box[1] + box[3]))};
|
|
71
|
+
}
|
|
72
|
+
const overlaps = (a, b) => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 0 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 0;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Write the furniture that repeats across slides once, on the slide master or a layout. `output` maps a package path to
|
|
76
|
+
* [bytes, zipOptions] (the finished, normalized package; per-script masters already exist). `slides[i]` describes slide i:
|
|
77
|
+
* `{path, parts: [{index, kind, zone, field, type, names, native, path}]}`, the core furniture parts in manifest order with the
|
|
78
|
+
* object names of their shapes (one per text line). `report` receives export diagnostics.
|
|
79
|
+
*/
|
|
80
|
+
export function liftMasterFurniture(output, slides, report = () => {}) {
|
|
81
|
+
const has = path => Object.hasOwn(output, path);
|
|
82
|
+
const read = path => dec.decode(output[path][0]);
|
|
83
|
+
const options = output['ppt/presentation.xml'][1];
|
|
84
|
+
const write = (path, xml) => { output[path] = [enc.encode(xml), output[path]?.[1] ?? options]; };
|
|
85
|
+
let types = read('[Content_Types].xml');
|
|
86
|
+
const relsCache = new Map();
|
|
87
|
+
const rels = path => {
|
|
88
|
+
if (!relsCache.has(path)) relsCache.set(path, has(relsOf(path)) ? relationships(read(relsOf(path))) : []);
|
|
89
|
+
return relsCache.get(path);
|
|
90
|
+
};
|
|
91
|
+
const related = (path, type) => {
|
|
92
|
+
const rel = rels(path).find(item => !item.external && item.type === `${REL}/${type}`);
|
|
93
|
+
return rel ? resolvePart(path, rel.target) : undefined;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// 1. Each slide's candidates: slot -> {signature, box, shapes, part}; and the parts content overlaps.
|
|
97
|
+
const plans = [];
|
|
98
|
+
for (const [slideIndex, slide] of slides.entries()) {
|
|
99
|
+
if (!slide || !has(slide.path)) continue;
|
|
100
|
+
const xml = read(slide.path), layout = related(slide.path, 'slideLayout'), master = layout && related(layout, 'slideMaster');
|
|
101
|
+
if (!layout || !master) continue;
|
|
102
|
+
const elements = new Map(shapeElements(xml).map(element => [element.name, element]));
|
|
103
|
+
const furnitureNames = new Set(slide.parts.flatMap(part => part.names));
|
|
104
|
+
// Every other shape's frame: furniture under any of them keeps its paint order on the slide.
|
|
105
|
+
let others = xml;
|
|
106
|
+
for (const element of shapeElements(xml)) if (furnitureNames.has(element.name)) others = others.replace(element.xml, '');
|
|
107
|
+
const occupied = [...others.matchAll(/<a:off x="(-?\d+)" y="(-?\d+)"\/>\s*<a:ext cx="(\d+)" cy="(\d+)"\/>/g)]
|
|
108
|
+
.map(match => match.slice(1).map(Number)).filter(([, , cx, cy]) => cx > 0 || cy > 0)
|
|
109
|
+
// A line has no area: one unit across, so a rule through the band still counts.
|
|
110
|
+
.map(([x, y, cx, cy]) => ({left: x, top: y, right: x + Math.max(cx, 1), bottom: y + Math.max(cy, 1)}));
|
|
111
|
+
const relationOf = id => {
|
|
112
|
+
const rel = rels(slide.path).find(item => item.id === id);
|
|
113
|
+
return rel ? `${rel.type}|${rel.external ? `external:${rel.target}` : resolvePart(slide.path, rel.target)}` : `missing:${id}`;
|
|
114
|
+
};
|
|
115
|
+
const candidates = new Map(), pinned = new Set();
|
|
116
|
+
for (const part of slide.parts) {
|
|
117
|
+
if (part.native || !part.names.length) continue;
|
|
118
|
+
const shapes = part.names.map(name => elements.get(name));
|
|
119
|
+
if (shapes.some(shape => !shape || shape.kind !== (part.type === 'image' ? 'pic' : 'sp'))) continue;
|
|
120
|
+
if (shapes.some(shape => /<a:fld\b/.test(shape.xml))) continue;
|
|
121
|
+
const box = bounds(shapes.map(shape => shape.xml).join(''));
|
|
122
|
+
if (!box) continue;
|
|
123
|
+
// Drawn content and box: the shape XML without its object id, name and tags, relationship ids read as their targets.
|
|
124
|
+
const signature = shapes.map(shape => shape.xml.replace(/<p:custDataLst>[\s\S]*?<\/p:custDataLst>/g, '')
|
|
125
|
+
.replace(/(<p:cNvPr\b[^>]*?)\s(?:id|name)="[^"]*"/g, '$1').replace(/(<p:cNvPr\b[^>]*?)\s(?:id|name)="[^"]*"/g, '$1')
|
|
126
|
+
.replace(RELATIONSHIP_ATTRIBUTE, (match, kind, id) => `r:${kind}="${relationOf(id)}"`)).join('\n');
|
|
127
|
+
const slot = `${part.kind}.${part.zone}.${part.field}`;
|
|
128
|
+
candidates.set(slot, {slot, signature, part, shapes, box});
|
|
129
|
+
if (occupied.some(frame => overlaps(frame, box))) pinned.add(slot);
|
|
130
|
+
}
|
|
131
|
+
plans.push({slideIndex, slide, xml, layout, master, candidates, pinned});
|
|
132
|
+
}
|
|
133
|
+
if (!plans.length) return;
|
|
134
|
+
// One report per part (its authored path), naming the slides where content covers it.
|
|
135
|
+
const covered = new Map();
|
|
136
|
+
for (const plan of plans) for (const slot of plan.pinned) {
|
|
137
|
+
const part = plan.candidates.get(slot).part;
|
|
138
|
+
covered.set(part.path, {part, slides: [...(covered.get(part.path)?.slides ?? []), plan.slideIndex + 1]});
|
|
139
|
+
}
|
|
140
|
+
for (const [path, {part, slides: numbers}] of covered) report({code: 'furniture-on-slide', path, message: `Slide content overlaps the ${part.kind} ${part.zone} ${part.field} on slide${numbers.length > 1 ? 's' : ''} ${numbers.join(', ')}. PowerPoint draws slide master and layout shapes beneath slide content, so there this part stays a shape on the slide, drawn above the content as in the preview.`});
|
|
141
|
+
|
|
142
|
+
// 2. Per slide master: the master's parts, each slide's layout key, and the layouts to create.
|
|
143
|
+
const value = (plan, slot) => plan.candidates.get(slot)?.signature;
|
|
144
|
+
const free = (plan, slot) => plan.candidates.has(slot) && !plan.pinned.has(slot);
|
|
145
|
+
const id = (slot, signature) => `${slot}\u0000${signature}`;
|
|
146
|
+
const assignments = [];
|
|
147
|
+
for (const master of [...new Set(plans.map(plan => plan.master))]) {
|
|
148
|
+
const group = plans.filter(plan => plan.master === master);
|
|
149
|
+
const showing = group.filter(plan => plan.candidates.size);
|
|
150
|
+
// The master holds the parts drawn the same on every slide that shows liftable furniture.
|
|
151
|
+
let common = showing.length >= 2 ? [...showing[0].candidates.keys()].filter(slot => showing.every(plan => value(plan, slot) === value(showing[0], slot))) : [];
|
|
152
|
+
if (!showing.some(plan => common.every(slot => free(plan, slot)))) common = [];
|
|
153
|
+
const counts = new Map();
|
|
154
|
+
for (const plan of group) for (const [slot, candidate] of plan.candidates) if (free(plan, slot)) counts.set(id(slot, candidate.signature), (counts.get(id(slot, candidate.signature)) ?? 0) + 1);
|
|
155
|
+
const layouts = new Map();
|
|
156
|
+
for (const plan of group) {
|
|
157
|
+
const showsMaster = plan.candidates.size ? common.every(slot => free(plan, slot)) : !common.length;
|
|
158
|
+
const extras = [...plan.candidates.keys()].filter(slot => free(plan, slot) && (showsMaster ? !common.includes(slot) && counts.get(id(slot, value(plan, slot))) >= 2
|
|
159
|
+
: common.includes(slot) || counts.get(id(slot, value(plan, slot))) >= 2)).sort();
|
|
160
|
+
const key = `${showsMaster ? 1 : 0}\u0001${extras.map(slot => id(slot, value(plan, slot))).join('\u0001')}`;
|
|
161
|
+
plan.extras = extras;
|
|
162
|
+
plan.lifted = new Map([...(showsMaster ? common.map(slot => [slot, 'master']) : []), ...extras.map(slot => [slot, 'layout'])]);
|
|
163
|
+
plan.layoutKey = showsMaster && !extras.length ? undefined : key;
|
|
164
|
+
if (plan.layoutKey !== undefined && !layouts.has(key)) layouts.set(key, {showsMaster, source: plan, slides: []});
|
|
165
|
+
if (plan.layoutKey !== undefined) layouts.get(key).slides.push(plan);
|
|
166
|
+
}
|
|
167
|
+
assignments.push({master, group, common, layouts});
|
|
168
|
+
}
|
|
169
|
+
if (!assignments.some(item => item.common.length || item.layouts.size)) return;
|
|
170
|
+
|
|
171
|
+
// 3. Write: the master's shapes, the new layouts, and each slide without its lifted shapes.
|
|
172
|
+
const tagPart = n => `ppt/tags/opfFurnitureShared${n}.xml`;
|
|
173
|
+
let tagNumber = 0, nextLayout = Math.max(0, ...Object.keys(output).map(path => Number(/^ppt\/slideLayouts\/slideLayout(\d+)\.xml$/.exec(path)?.[1] ?? 0))) + 1;
|
|
174
|
+
const presentation = read('ppt/presentation.xml');
|
|
175
|
+
const usedIds = [...presentation.matchAll(/<p:sldMasterId\b[^>]*\bid="(\d+)"/g), ...Object.keys(output).filter(path => /^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(path))
|
|
176
|
+
.flatMap(path => [...read(path).matchAll(/<p:sldLayoutId\b[^>]*\bid="(\d+)"/g)])].map(match => Number(match[1]));
|
|
177
|
+
let nextLayoutId = Math.max(2147483648, ...usedIds) + 1;
|
|
178
|
+
const pending = new Map(); // part path -> {xml, rels}
|
|
179
|
+
const partXml = path => pending.get(path)?.xml ?? read(path);
|
|
180
|
+
const partRels = path => pending.get(path)?.rels ?? (has(relsOf(path)) ? read(relsOf(path)) : `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`);
|
|
181
|
+
const stage = (path, xml, relsXml) => pending.set(path, {xml, rels: relsXml});
|
|
182
|
+
const relationshipId = (relsXml, type, target, external) => {
|
|
183
|
+
const existing = relationships(relsXml).find(rel => rel.type === type && rel.target === target && rel.external === external);
|
|
184
|
+
if (existing) return {relsXml, rid: existing.id};
|
|
185
|
+
const taken = new Set(relationships(relsXml).map(rel => rel.id));
|
|
186
|
+
let n = 1, rid;
|
|
187
|
+
do rid = `rId${n++}`; while (taken.has(rid));
|
|
188
|
+
return {rid, relsXml: relsXml.replace('</Relationships>', `<Relationship Id="${rid}" Type="${type}" Target="${escapeAttribute(target)}"${external ? ' TargetMode="External"' : ''}/></Relationships>`)};
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Copy one slide's shapes of a slot into a master or layout, with its relationships and a slot tag.
|
|
192
|
+
const transplant = (target, plan, slot) => {
|
|
193
|
+
const candidate = plan.candidates.get(slot);
|
|
194
|
+
let xml = partXml(target), relsXml = partRels(target);
|
|
195
|
+
let nextId = Math.max(0, ...[...xml.matchAll(/<p:cNvPr\b[^>]*\bid="(\d+)"/g)].map(match => Number(match[1]))) + 1;
|
|
196
|
+
const copies = candidate.shapes.map((shape, line) => {
|
|
197
|
+
let copy = shape.xml;
|
|
198
|
+
// The tag: the slide record with the slot in place of the slide's group and part.
|
|
199
|
+
const tagId = copy.match(/<p:custDataLst>\s*<p:tags r:id="([^"]+)"\/>\s*<\/p:custDataLst>/)?.[1];
|
|
200
|
+
copy = copy.replace(/<p:custDataLst>[\s\S]*?<\/p:custDataLst>/g, '');
|
|
201
|
+
copy = copy.replace(RELATIONSHIP_ATTRIBUTE, (match, kind, rid) => {
|
|
202
|
+
if (!rid) return match;
|
|
203
|
+
const rel = rels(plan.slide.path).find(item => item.id === rid);
|
|
204
|
+
if (!rel) throw new Error(`Furniture shape relationship ${rid} is missing.`);
|
|
205
|
+
const result = relationshipId(relsXml, rel.type, rel.external ? rel.target : relativeTarget(target, resolvePart(plan.slide.path, rel.target)), rel.external);
|
|
206
|
+
relsXml = result.relsXml;
|
|
207
|
+
return `r:${kind}="${result.rid}"`;
|
|
208
|
+
});
|
|
209
|
+
const tagRel = tagId && rels(plan.slide.path).find(item => item.id === tagId);
|
|
210
|
+
if (tagRel) {
|
|
211
|
+
const source = resolvePart(plan.slide.path, tagRel.target), record = decodeTextTag(read(source).match(/\bval="([^"]*)"/)[1]);
|
|
212
|
+
const {group, part, ...rest} = record;
|
|
213
|
+
const path = tagPart(++tagNumber);
|
|
214
|
+
output[path] = [enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${TAG}" val="${encodeTextTag({...rest, slot})}"/></p:tagLst>`), options];
|
|
215
|
+
types = types.replace('</Types>', `<Override PartName="/${path}" ContentType="${TAGS_TYPE}"/></Types>`);
|
|
216
|
+
const result = relationshipId(relsXml, `${REL}/tags`, relativeTarget(target, path), false);
|
|
217
|
+
relsXml = result.relsXml;
|
|
218
|
+
copy = copy.replace(/<p:nvPr\s*\/>/, '<p:nvPr></p:nvPr>').replace('</p:nvPr>', `<p:custDataLst><p:tags r:id="${result.rid}"/></p:custDataLst></p:nvPr>`);
|
|
219
|
+
}
|
|
220
|
+
const name = `OPF furniture ${slot.replace(/\./g, ' ')}${candidate.part.type === 'text' ? ` line ${line}` : ''}`;
|
|
221
|
+
return copy.replace(/(<p:cNvPr\b[^>]*?\bid=")\d+(")/, `$1${nextId++}$2`).replace(/(<p:cNvPr\b[^>]*?\bname=")[^"]*(")/, `$1${escapeAttribute(name)}$2`);
|
|
222
|
+
});
|
|
223
|
+
const end = xml.lastIndexOf('</p:spTree>');
|
|
224
|
+
if (end < 0) throw new Error(`${target} has no shape tree.`);
|
|
225
|
+
stage(target, xml.slice(0, end) + copies.join('') + xml.slice(end), relsXml);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
for (const {master, group, common, layouts} of assignments) {
|
|
229
|
+
const showing = group.find(plan => common.every(slot => free(plan, slot)) && plan.candidates.size);
|
|
230
|
+
for (const slot of common) transplant(master, showing, slot);
|
|
231
|
+
// The layout the slides use now (each master has one), copied once per layout key.
|
|
232
|
+
const base = group[0].layout;
|
|
233
|
+
let masterXml = partXml(master), masterRels = partRels(master);
|
|
234
|
+
let number = 0;
|
|
235
|
+
for (const layout of layouts.values()) {
|
|
236
|
+
const path = `ppt/slideLayouts/slideLayout${nextLayout++}.xml`;
|
|
237
|
+
const name = layout.source.extras.length ? `OPF furniture ${++number}` : 'OPF no furniture';
|
|
238
|
+
let xml = read(base).replace(/(<p:cSld\b[^>]*?\bname=")[^"]*(")/, `$1${escapeAttribute(name)}$2`);
|
|
239
|
+
if (!layout.showsMaster) xml = xml.replace(/<p:sldLayout\b[^>]*>/, open => `${open.slice(0, -1).replace(/\s+showMasterSp="[^"]*"/, '')} showMasterSp="0">`);
|
|
240
|
+
const baseRels = has(relsOf(base)) ? read(relsOf(base)) : '';
|
|
241
|
+
// A copied layout keeps only its master relationship; the furniture brings its own.
|
|
242
|
+
const masterRel = relationships(baseRels).find(rel => rel.type === `${REL}/slideMaster`);
|
|
243
|
+
if (!masterRel) throw new Error(`${base} has no slide master relationship.`);
|
|
244
|
+
stage(path, xml, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${masterRel.node}</Relationships>`);
|
|
245
|
+
types = types.replace('</Types>', `<Override PartName="/${path}" ContentType="${LAYOUT_TYPE}"/></Types>`);
|
|
246
|
+
for (const slot of layout.source.extras) transplant(path, layout.source, slot);
|
|
247
|
+
const result = relationshipId(masterRels, `${REL}/slideLayout`, relativeTarget(master, path), false);
|
|
248
|
+
masterRels = result.relsXml;
|
|
249
|
+
if (!masterXml.includes('</p:sldLayoutIdLst>')) throw new Error(`${master} has no layout list.`);
|
|
250
|
+
masterXml = masterXml.replace('</p:sldLayoutIdLst>', `<p:sldLayoutId id="${nextLayoutId++}" r:id="${result.rid}"/></p:sldLayoutIdLst>`);
|
|
251
|
+
layout.path = path;
|
|
252
|
+
}
|
|
253
|
+
stage(master, masterXml, masterRels);
|
|
254
|
+
|
|
255
|
+
// Each slide: drop its lifted shapes (and what only they referenced), point it at its layout, and move the manifest entries.
|
|
256
|
+
for (const plan of group) {
|
|
257
|
+
if (!plan.lifted.size && plan.layoutKey === undefined) continue;
|
|
258
|
+
let xml = plan.xml, relsXml = read(relsOf(plan.slide.path));
|
|
259
|
+
// The relationships only the removed shapes used (their tags, pictures and links) go with them.
|
|
260
|
+
const released = new Set();
|
|
261
|
+
for (const slot of plan.lifted.keys()) for (const shape of plan.candidates.get(slot).shapes) {
|
|
262
|
+
for (const match of shape.xml.matchAll(RELATIONSHIP_ATTRIBUTE)) released.add(match[2]);
|
|
263
|
+
xml = xml.replace(shape.xml, '');
|
|
264
|
+
}
|
|
265
|
+
for (const rel of relationships(relsXml)) {
|
|
266
|
+
if (!released.has(rel.id) || new RegExp(`"${escapeRegExp(rel.id)}"`).test(xml)) continue;
|
|
267
|
+
relsXml = relsXml.replace(rel.node, '');
|
|
268
|
+
if (rel.type === `${REL}/tags`) {
|
|
269
|
+
const path = resolvePart(plan.slide.path, rel.target);
|
|
270
|
+
delete output[path];
|
|
271
|
+
types = types.replace(`<Override PartName="/${path}" ContentType="${TAGS_TYPE}"/>`, '');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (plan.layoutKey !== undefined) {
|
|
275
|
+
const target = relativeTarget(plan.slide.path, layoutsFor(assignments, plan).path);
|
|
276
|
+
relsXml = relsXml.replace(/<Relationship\b[^>]*\/>/g, node => attribute(node, 'Type') === `${REL}/slideLayout` ? node.replace(/\sTarget="[^"]*"/, ` Target="${target}"`) : node);
|
|
277
|
+
}
|
|
278
|
+
moveManifestEntries(output, plan, read, write);
|
|
279
|
+
write(plan.slide.path, xml);
|
|
280
|
+
write(relsOf(plan.slide.path), relsXml);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
for (const [path, {xml, rels: relsXml}] of pending) { write(path, xml); write(relsOf(path), relsXml); }
|
|
284
|
+
write('[Content_Types].xml', types);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function layoutsFor(assignments, plan) {
|
|
288
|
+
return assignments.find(item => item.group.includes(plan)).layouts.get(plan.layoutKey);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// The slide's manifest: each lifted part leaves `parts`, `definitions` (its flag), `templates` and `images` for `shared`, and the
|
|
292
|
+
// remaining parts are renumbered in the slide's own shape tags, so an importer that does not know `shared` reads the slide as it is.
|
|
293
|
+
function moveManifestEntries(output, plan, read, write) {
|
|
294
|
+
const path = `ppt/tags/opfFurnitureSlide${plan.slideIndex}.xml`;
|
|
295
|
+
if (!Object.hasOwn(output, path) || !plan.lifted.size) return;
|
|
296
|
+
const manifest = decodeTextTag(read(path).match(/\bval="([^"]*)"/)[1]);
|
|
297
|
+
const shared = {}, keep = [], renumber = new Map();
|
|
298
|
+
for (const [index, part] of manifest.parts.entries()) {
|
|
299
|
+
const slot = `${part.kind}.${part.zone}.${part.field}`, on = plan.lifted.get(slot);
|
|
300
|
+
if (!on) { renumber.set(index, keep.length); keep.push(part); continue; }
|
|
301
|
+
const zone = manifest.definitions[part.kind].value[part.zone];
|
|
302
|
+
const entry = {...part, on, flag: zone[part.field]};
|
|
303
|
+
delete zone[part.field];
|
|
304
|
+
const key = `${part.kind}.${part.zone}`;
|
|
305
|
+
if (part.field === 'text' && manifest.templates?.[key] !== undefined) { entry.template = manifest.templates[key]; delete manifest.templates[key]; }
|
|
306
|
+
if (part.field === 'image' && manifest.images?.[key] !== undefined) { entry.image = manifest.images[key]; delete manifest.images[key]; }
|
|
307
|
+
shared[slot] = entry;
|
|
308
|
+
}
|
|
309
|
+
for (const name of ['templates', 'images']) if (manifest[name] && !Object.keys(manifest[name]).length) delete manifest[name];
|
|
310
|
+
manifest.parts = keep;
|
|
311
|
+
manifest.shared = shared;
|
|
312
|
+
write(path, read(path).replace(/\bval="[^"]*"/, () => `val="${encodeTextTag(manifest)}"`));
|
|
313
|
+
// Renumber the slide's remaining furniture shape tags.
|
|
314
|
+
const slideRels = relationships(read(relsOf(plan.slide.path)));
|
|
315
|
+
const remaining = new Set(plan.slide.parts.filter(part => !plan.lifted.has(`${part.kind}.${part.zone}.${part.field}`)).flatMap(part => part.names));
|
|
316
|
+
for (const element of shapeElements(plan.xml)) {
|
|
317
|
+
if (!remaining.has(element.name)) continue;
|
|
318
|
+
const tagId = element.xml.match(/<p:custDataLst>\s*<p:tags r:id="([^"]+)"\/>/)?.[1];
|
|
319
|
+
const rel = tagId && slideRels.find(item => item.id === tagId);
|
|
320
|
+
if (!rel) continue;
|
|
321
|
+
const tag = resolvePart(plan.slide.path, rel.target);
|
|
322
|
+
const record = decodeTextTag(read(tag).match(/\bval="([^"]*)"/)[1]);
|
|
323
|
+
if (record.part === undefined || !renumber.has(record.part) || renumber.get(record.part) === record.part) continue;
|
|
324
|
+
write(tag, read(tag).replace(/\bval="[^"]*"/, () => `val="${encodeTextTag({...record, part: renumber.get(record.part)})}"`));
|
|
325
|
+
}
|
|
326
|
+
}
|
package/dist/media-dedupe.js
CHANGED
|
@@ -13,6 +13,8 @@ const hashBytes = bytes => {
|
|
|
13
13
|
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
14
14
|
return (h2 >>> 0).toString(16) + (h1 >>> 0).toString(16);
|
|
15
15
|
};
|
|
16
|
+
/** The content key of a media part's bytes: its length and hash (a bucket key; equal keys are confirmed byte for byte where it matters). */
|
|
17
|
+
export const mediaKey = bytes => `${bytes.byteLength}:${hashBytes(bytes)}`;
|
|
16
18
|
const sameBytes = (a, b) => a.byteLength === b.byteLength && a.every((value, index) => value === b[index]);
|
|
17
19
|
const decoder = new TextDecoder(), encoder = new TextEncoder();
|
|
18
20
|
|