@openpresentation/opf-pptx 0.18.2 → 0.20.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/DEPENDENCY-NOTES.md +2 -0
- package/README.md +7 -3
- package/dist/content-topology.js +63 -6
- package/dist/data-provenance.js +7 -4
- package/dist/document-provenance.js +23 -9
- package/dist/index.d.ts +8 -5
- package/dist/index.js +199 -20
- package/dist/master-furniture.js +25 -7
- package/dist/source-run-styles.js +71 -0
- package/dist/template-layouts.js +514 -0
- package/package.json +5 -4
package/dist/index.js
CHANGED
|
@@ -32,6 +32,8 @@ import {INVALID_XML_CHARACTER, nativeSections, writeSectionList} from './section
|
|
|
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
34
|
import {liftMasterFurniture} from './master-furniture.js';
|
|
35
|
+
import {gallery} from '@openpresentation/gallery';
|
|
36
|
+
import {applyTemplateLayouts, deckTemplates, foreignLayoutReference, placeholderRegion, readLayoutTag, slideTemplate, writeTemplateLayouts} from './template-layouts.js';
|
|
35
37
|
import {importImageOrientation} from './image-import.js';
|
|
36
38
|
import {extractSignals, normalizeSignalOptions, themeFactsFor} from './import-signals.js';
|
|
37
39
|
import {placeImages, importImages, importImagePlaceholders, isImagePlaceholderGroup, imageName, imageOverlayName, imagePlaceholderGroupName, backgroundImageName, backgroundOverlayName, IMAGE_TREATMENT_KEYS, BACKGROUND_IMAGE_KEYS, IMAGE_PLACEHOLDER_TAG} from './image-provenance.js';
|
|
@@ -290,6 +292,10 @@ export async function toPptx(input, options = {}) {
|
|
|
290
292
|
context.nativeFurnitureBands = new Map();
|
|
291
293
|
// RR-72: each slide's furniture parts and the object names of their shapes, for the slide master and layouts.
|
|
292
294
|
context.furnitureParts = [];
|
|
295
|
+
// RR-81: the 0.19 template each slide uses (its native slide layout) and the objects bound to a region placeholder.
|
|
296
|
+
context.templateSlides = [];
|
|
297
|
+
context.automaticSlides = [];
|
|
298
|
+
context.placeholderBindings = new Map();
|
|
293
299
|
context.hostDate = options.date;
|
|
294
300
|
context.authored = authored;
|
|
295
301
|
context.codeTags = new Map();
|
|
@@ -345,6 +351,22 @@ export async function toPptx(input, options = {}) {
|
|
|
345
351
|
await addSlide(pptx, presentation, presentation.slides[index], index, context, options);
|
|
346
352
|
}
|
|
347
353
|
|
|
354
|
+
// RR-81: when a slide uses a template, every template the deck reaches gets its native layout (registered catalogs, then embedded).
|
|
355
|
+
// A template's placeholders are composed with the options of the first slide that uses it; a template no slide uses, with the
|
|
356
|
+
// options of a slide that would (the deck's header and footer, the template's own design) and no placed-image band.
|
|
357
|
+
const reachableTemplates = deckTemplates(presentation, options.catalogs, reference => resolveReference(presentation, 'layouts', reference, options.catalogs !== undefined ? {catalogs: options.catalogs} : {}));
|
|
358
|
+
if (context.templateSlides.some(Boolean) || reachableTemplates.length || context.automaticSlides.some(Boolean)) {
|
|
359
|
+
const plans = context.templateSlides.filter(Boolean), first = plans[0] ?? context.automaticSlides.find(Boolean) ?? {areaOptions: context.firstSlideOptions};
|
|
360
|
+
const reachable = reachableTemplates;
|
|
361
|
+
for (const plan of plans) if (!reachable.some(template => template.key === plan.template.key)) reachable.push(plan.template);
|
|
362
|
+
context.deckTemplates = reachable.map(template => {
|
|
363
|
+
const used = plans.find(plan => plan.template.key === template.key);
|
|
364
|
+
if (used) return {...template, areaOptions: used.areaOptions};
|
|
365
|
+
const core = coreContext({...presentation, slides: [{layout: template.reference}]}, 0, options).options;
|
|
366
|
+
return {...template, areaOptions: {...core, fontFamilies: first.areaOptions.fontFamilies, textRasterPadding: options.textRasterPadding, placements: []}};
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
348
370
|
// FF-08: pitchFamily per exported family, from each slide's resolved scheme, then the font schemes the document embeds
|
|
349
371
|
// and the registered catalogs its groups name (core catalogRecords).
|
|
350
372
|
context.fontPitch = fontPitchFamilies(presentation.slides.map((slide, index) => exportSlideContext(presentation, index, context, options).fonts),
|
|
@@ -429,11 +451,26 @@ export async function fromPptx(input, options = {}) {
|
|
|
429
451
|
return inheritedParts.get(path);
|
|
430
452
|
};
|
|
431
453
|
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;
|
|
454
|
+
// RR-81: the OPF_LAYOUT_V1 tag of each slide's layout (a 0.19 template layout), read once per layout part.
|
|
455
|
+
const layoutTags = new Map();
|
|
456
|
+
const layoutTagOf = (layoutPath, index) => {
|
|
457
|
+
if (!layoutPath) return undefined;
|
|
458
|
+
if (!layoutTags.has(layoutPath)) layoutTags.set(layoutPath, readLayoutTag(entries, layoutPath, message => options.onDiagnostic?.({code: 'invalid-layout-provenance', path: `slides.${index}.layout`, message})));
|
|
459
|
+
return layoutTags.get(layoutPath);
|
|
460
|
+
};
|
|
432
461
|
const furnitureContexts = slidePaths.map((slidePath, index) => {
|
|
433
462
|
const root = parseRequiredXml(entries, slidePath)['p:sld'];
|
|
434
463
|
if (!root) throw new OPFPptxError('invalid-pptx', `PPTX slide is not a PresentationML slide: ${slidePath}.`, {path: slidePath});
|
|
435
464
|
const tree = root['p:cSld']?.['p:spTree'], relationships = parseRelationships(entries, slidePath);
|
|
436
|
-
|
|
465
|
+
const layoutPath = relatedPath(slidePath, 'slideLayout'), layoutTag = layoutTagOf(layoutPath, index);
|
|
466
|
+
return {root, relationships, shapes: nativeTextShapes(tree), pictures: nativePictures(tree), layoutTag, foreignLayout: layoutTag ? undefined : foreignLayoutReference(cachedPart(layoutPath)?.['p:sldLayout'], options.catalogs),
|
|
467
|
+
// RR-81: a region placeholder typed into in PowerPoint has no xfrm of its own; it sits where the template layout puts it.
|
|
468
|
+
layoutBounds: layoutTag ? shape => {
|
|
469
|
+
const ph = shape?.['p:nvSpPr']?.['p:nvPr']?.['p:ph'];
|
|
470
|
+
if (ph?.idx === undefined) return undefined;
|
|
471
|
+
const match = asArray(cachedPart(layoutPath)?.['p:sldLayout']?.['p:cSld']?.['p:spTree']?.['p:sp']).find(candidate => candidate?.['p:nvSpPr']?.['p:nvPr']?.['p:ph']?.idx === ph.idx);
|
|
472
|
+
return match ? shapeBounds(match['p:spPr']?.['a:xfrm']) ?? undefined : undefined;
|
|
473
|
+
} : undefined,
|
|
437
474
|
// RR-11: native footer placeholders resolve their position through the layout and master.
|
|
438
475
|
path: slidePath, slideWidth: Number(presentationRoot['p:sldSz']?.cx), relationshipsOf: cachedRelationships, readPart: cachedPart,
|
|
439
476
|
paragraphs: nativeShapeParagraphs(decodeText(entries[slidePath])),
|
|
@@ -512,11 +549,34 @@ export async function fromPptx(input, options = {}) {
|
|
|
512
549
|
report({code: "invalid-document-provenance", path: "", message: `${errorMessage(error)} Ordinary import keeps the values observed in the PPTX.`});
|
|
513
550
|
}
|
|
514
551
|
if (slideProvenance.length !== imported.slides.length) throw new OPFPptxError("invalid-import-opf", "Slide provenance does not match the imported slides.");
|
|
552
|
+
// RR-81: a slide on a 0.19 template layout takes that layout's reference (a slide added in PowerPoint, Change Layout), and a
|
|
553
|
+
// block read from a region placeholder keeps its region with a pin where plain binding would move it (src/template-layouts.js).
|
|
554
|
+
// Every tagged layout of the package, used or not: an embedded template no slide uses comes back from its tag.
|
|
555
|
+
const packageLayoutTags = Object.keys(entries).filter(path => /^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(path)).sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]))
|
|
556
|
+
.map(path => layoutTagOf(path, 0)).filter(Boolean);
|
|
557
|
+
if (packageLayoutTags.length) {
|
|
558
|
+
const restoredContent = new Set(restoredGroups.filter(group => group.applied && group.contentPaths).map(group => group.contentPaths.slide));
|
|
559
|
+
applyTemplateLayouts(imported, furnitureContexts.map((context, index) => ({tag: context.layoutTag, blockRegions: context.blockRegions, restored: restoredContent.has(index), preservedAutomatic: slideProvenance[index]?.structure !== 'untagged', storedLayout: slideProvenance[index]?.layout})),
|
|
560
|
+
index => resolveCoreSlideContext(imported, index, options.catalogs !== undefined ? {catalogs: options.catalogs} : {}).resolved.layout, report, packageLayoutTags);
|
|
561
|
+
}
|
|
562
|
+
for (const [index, context] of furnitureContexts.entries()) {
|
|
563
|
+
if (!context.layoutTag && slideProvenance[index]?.structure === 'untagged' && context.foreignLayout !== undefined && imported.slides[index].layout === undefined)
|
|
564
|
+
{
|
|
565
|
+
if (context.foreignLayout.startsWith('default:')) {
|
|
566
|
+
const found = resolveReference({}, 'layouts', context.foreignLayout, {catalogs: options.catalogs ?? [gallery]});
|
|
567
|
+
const existing = imported.catalogs?.default;
|
|
568
|
+
if (existing === false || (existing?.source !== undefined && existing.source !== found?.source)) continue;
|
|
569
|
+
if (found?.source) imported.catalogs = {...imported.catalogs, default: {...imported.catalogs?.default, source: found.source}};
|
|
570
|
+
}
|
|
571
|
+
imported.slides[index].layout = context.foreignLayout;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
515
574
|
// RR-34: marker runs become cite/footnote on the runs before them, and the references list is rebuilt
|
|
516
575
|
// from the stored record and the footnote boxes (an edited note keeps its edited text).
|
|
517
576
|
restoreCitations(imported, furnitureContexts.map(context => context.annotations?.notes ?? []), report);
|
|
518
577
|
restoreLogoFallback(imported, furnitureContexts);
|
|
519
578
|
applyRunColors(imported, slideProvenance, restoredGroups, report, options);
|
|
579
|
+
applyNativeImageFits(imported, furnitureContexts, restoredGroups, options);
|
|
520
580
|
if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
|
|
521
581
|
languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
|
|
522
582
|
|
|
@@ -820,10 +880,22 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
820
880
|
}
|
|
821
881
|
}
|
|
822
882
|
const listRecord = slideListRecord(entries, slidePath, slideRoot, relationships);
|
|
823
|
-
const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items, listRecord.breaks, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`}), listRecord.runs))
|
|
824
|
-
.map((item) => ({payload: payloadFromSlideItem(item, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`})), bounds: item.visualBounds ?? item.bounds, sources: item.sources ?? []}))
|
|
883
|
+
const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items, listRecord.breaks, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`}), listRecord.runs, listRecord.roles))
|
|
884
|
+
.map((item) => ({payload: payloadFromSlideItem(item, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`})), bounds: item.visualBounds ?? item.bounds, sources: item.sources ?? [], inferredStretch: item.inferredStretch}))
|
|
825
885
|
.filter((entry) => entry.payload);
|
|
826
886
|
const blocks = content.map((entry) => entry.payload);
|
|
887
|
+
// RR-81: the region of each block read from one region placeholder of the slide's 0.19 template layout (its p:ph idx).
|
|
888
|
+
if (nativeContext.layoutTag) {
|
|
889
|
+
const frames = asArray(slideRoot['p:cSld']?.['p:spTree']?.['p:graphicFrame']);
|
|
890
|
+
const nodeOf = source => {
|
|
891
|
+
const [kind, position] = source.split(':');
|
|
892
|
+
return kind === 'sp' ? nativeContext.shapes[position] : kind === 'pic' ? nativeContext.pictures[position] : kind === 'frame' ? frames[position] : undefined;
|
|
893
|
+
};
|
|
894
|
+
nativeContext.blockRegions = content.map(entry => {
|
|
895
|
+
const regions = new Set(entry.sources.map(source => placeholderRegion(nodeOf(source), nativeContext.layoutTag)));
|
|
896
|
+
return regions.size === 1 ? [...regions][0] : undefined;
|
|
897
|
+
});
|
|
898
|
+
}
|
|
827
899
|
// RR-34: a tagged caption re-attaches to the block whose native media shape it names (never by position).
|
|
828
900
|
const annotations = nativeContext.annotations;
|
|
829
901
|
if (annotations?.captions.size) {
|
|
@@ -850,6 +922,7 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
850
922
|
// Which native shapes fed each field and block (import signals); never part of the document.
|
|
851
923
|
nativeContext.signalSources = {tag: tagItem?.sources ?? [], title: titleItem?.sources ?? [], subtitle: subtitleItem?.sources ?? [],
|
|
852
924
|
blocks: content.map(entry => ({sources: entry.sources, type: entry.payload.type})), roles: nativeContext.signalRoles ?? new Map()};
|
|
925
|
+
nativeContext.inferredImageFits = content.flatMap((entry,index)=>entry.inferredStretch?[index]:[]);
|
|
853
926
|
nativeContext.contentBounds = content.map((entry) => entry.bounds ? {x: entry.bounds.x * 96, y: entry.bounds.y * 96, width: entry.bounds.w * 96, height: entry.bounds.h * 96} : null);
|
|
854
927
|
|
|
855
928
|
const notes = readSlideNotes(entries, relationships);
|
|
@@ -932,9 +1005,13 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
932
1005
|
if (furniture.text.has(index) || nativeContext.imageShapes?.has(index) || nativeContext.watermarkShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index) || placeholders.consumed.has(index)) continue;
|
|
933
1006
|
if (annotations.consumed.has(shape)) { roles.set(shapeKeys.get(shape), 'annotation'); continue; }
|
|
934
1007
|
if (code.consumed.has(shape)||metric.consumed.has(shape)||cards.has(shape)||media.consumed.has(shape)||headings.consumed.has(shape)||plainText.consumed.has(shape)||timelines.consumed.has(shape)||quotes.consumed.has(shape)) continue;
|
|
1008
|
+
// RR-81: a placeholder with no text (no runs, or only empty paragraphs) is PowerPoint's prompt ("Click to add text" on a slide
|
|
1009
|
+
// added from a layout), not content. A picture, chart or table in a placeholder is a p:pic or p:graphicFrame, read below.
|
|
1010
|
+
if (shape['p:nvSpPr']?.['p:nvPr']?.['p:ph'] !== undefined && !(paragraphs[index] ?? []).some(paragraph => paragraph.text)) { roles.set(shapeKeys.get(shape), 'prompt'); continue; }
|
|
935
1011
|
const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
|
|
936
1012
|
const item = importShape(shape, dimensions, paragraphs[index], ordinaryBody || furniture.taggedText.has(index) || media.captionShapes.has(shape));
|
|
937
1013
|
if (item && ordinaryBody) item.readNativeBody = () => readBody(index);
|
|
1014
|
+
if (item && !item.bounds && nativeContext.layoutBounds) item.bounds = nativeContext.layoutBounds(shape) ?? item.bounds;
|
|
938
1015
|
// A damaged/edited furniture group falls back to current native text,
|
|
939
1016
|
// including cleared text boxes, without inventing a title or shape label.
|
|
940
1017
|
if (item && (furniture.taggedText.has(index) || media.captionShapes.has(shape))) item.sourceText = true;
|
|
@@ -972,6 +1049,8 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
972
1049
|
const report = diagnostic => { if (!(treatment && diagnostic.code === 'unsupported-image-crop')) options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`}); };
|
|
973
1050
|
const item = importPicture(entries, picture, slidePath, relationships, report);
|
|
974
1051
|
if (item?.payload && treatment) item.payload = {...item.payload, ...structuredClone(treatment)};
|
|
1052
|
+
else if (item?.nativeStretch) { item.payload.fit='stretch'; item.inferredStretch=true; }
|
|
1053
|
+
else if (item?.nativeFitUnsupported) report({code:'unsupported-image-fit',message:'Native picture tiling or an offset stretch fill is not represented by the imported image block; review the reflowed image.'});
|
|
975
1054
|
if (item) items.push({...item, sources: [`pic:${index}`]});
|
|
976
1055
|
}
|
|
977
1056
|
|
|
@@ -1127,6 +1206,8 @@ function importPicture(entries, picture, slidePath, relationships, report) {
|
|
|
1127
1206
|
|
|
1128
1207
|
return {
|
|
1129
1208
|
kind: "image",
|
|
1209
|
+
nativeStretch: nativePictureStretch(picture, bytes, bounds, Boolean(svg)),
|
|
1210
|
+
nativeFitUnsupported: picture['p:blipFill']?.['a:tile']!==undefined || ['l','r','t','b'].some(key=>Number(picture['p:blipFill']?.['a:stretch']?.['a:fillRect']?.[key]??0)!==0),
|
|
1130
1211
|
bounds,
|
|
1131
1212
|
visualBounds: visualBounds(picture["p:spPr"]?.["a:xfrm"]),
|
|
1132
1213
|
name,
|
|
@@ -1141,6 +1222,46 @@ function importPicture(entries, picture, slidePath, relationships, report) {
|
|
|
1141
1222
|
};
|
|
1142
1223
|
}
|
|
1143
1224
|
|
|
1225
|
+
// A full-source native stretch is distinguishable from cover only when the
|
|
1226
|
+
// intrinsic aspect differs from its frame. No crop, tile, offset fill or turn is
|
|
1227
|
+
// inferred; those require their existing diagnostics/separate fidelity review.
|
|
1228
|
+
function nativePictureStretch(picture,bytes,bounds,svg) {
|
|
1229
|
+
const fill=picture['p:blipFill'],crop=fill?.['a:srcRect'],stretch=fill?.['a:stretch'];
|
|
1230
|
+
const rect=stretch?.['a:fillRect'],transform=picture['p:spPr']?.['a:xfrm'];
|
|
1231
|
+
const metadata=svg?null:rasterMetadata(bytes);
|
|
1232
|
+
if(!bounds||!(bounds.w>0&&bounds.h>0)||!metadata||!(metadata.width>0&&metadata.height>0)||metadata.orientation>1) return false;
|
|
1233
|
+
if(stretch===undefined||fill?.['a:tile']!==undefined||Number(transform?.rot??0)!==0) return false;
|
|
1234
|
+
if([crop,rect].some(value=>value&&['l','r','t','b'].some(key=>Number(value[key]??0)!==0)))return false;
|
|
1235
|
+
// Compare dimensions in native EMUs, without a new geometry tolerance.
|
|
1236
|
+
const ext=transform?.['a:ext'];
|
|
1237
|
+
const cx=Number(ext?.cx),cy=Number(ext?.cy);
|
|
1238
|
+
if(!Number.isFinite(cx)||!Number.isFinite(cy))return false;
|
|
1239
|
+
// Equal-aspect dimensions can round by one EMU when serialized. A dimension
|
|
1240
|
+
// reproduced by rounding the intrinsic ratio is still the same aspect.
|
|
1241
|
+
return Math.round(cx*metadata.height/metadata.width)!==cy&&Math.round(cy*metadata.width/metadata.height)!==cx;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Provenance may rebuild flat blocks into groups or scalar fields. Remove only
|
|
1245
|
+
// our inferred fit when restored design already supplies stretch, preserving an
|
|
1246
|
+
// unchanged authored default rather than adding an unsolicited block override.
|
|
1247
|
+
function applyNativeImageFits(imported,contexts,groups,options) {
|
|
1248
|
+
for(const [slideIndex,context] of contexts.entries()) {
|
|
1249
|
+
if(!context.inferredImageFits?.length)continue;
|
|
1250
|
+
const slide=imported.slides[slideIndex];
|
|
1251
|
+
const resolved=resolveCoreSlideContext(imported,slideIndex,options.catalogs!==undefined?{catalogs:options.catalogs}:{}).resolved;
|
|
1252
|
+
const fit=slide.design?.imageFit??imported.design?.imageFit??resolved.theme?.design?.imageFit??resolved.layout?.design?.imageFit??'cover';
|
|
1253
|
+
const restored=groups.find(group=>group.applied&&group.contentPaths?.slide===slideIndex)?.contentPaths?.paths;
|
|
1254
|
+
for(const index of context.inferredImageFits) {
|
|
1255
|
+
const path=restored?.get(index)??`blocks.${index}`;
|
|
1256
|
+
const value=path.split('.').reduce((item,key)=>item?.[key],slide);
|
|
1257
|
+
if(value&&typeof value==='object'&&value.image!==undefined) {
|
|
1258
|
+
if(fit==='stretch')delete value.fit;
|
|
1259
|
+
else value.fit='stretch';
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1144
1265
|
function readParagraphs(txBody) {
|
|
1145
1266
|
return withDisplayedNumbers(asArray(txBody?.["a:p"])
|
|
1146
1267
|
.map((paragraph) => {
|
|
@@ -1257,7 +1378,7 @@ function joinRich(a, b) {
|
|
|
1257
1378
|
// `runs` (opf-pptx#212) holds the look export wrote on every run of a list ({text, description?}): a run value still equal to
|
|
1258
1379
|
// it is inherited formatting, not authored run style, and is removed (as for a tagged heading), so an untouched round trip
|
|
1259
1380
|
// adds no explicit sizes, families or colours; a value edited in PowerPoint differs and is kept. Without it runs are as read.
|
|
1260
|
-
function mergeOpfListShapes(items, breaks = null, report = () => {}, runs = null) {
|
|
1381
|
+
function mergeOpfListShapes(items, breaks = null, report = () => {}, runs = null, roles = null) {
|
|
1261
1382
|
const lists = new Map();
|
|
1262
1383
|
for (const item of items) {
|
|
1263
1384
|
const match = /^OPF list (.+) line (\d+)$/.exec(item.name ?? '');
|
|
@@ -1270,6 +1391,10 @@ function mergeOpfListShapes(items, breaks = null, report = () => {}, runs = null
|
|
|
1270
1391
|
for (const [path, entries] of lists) {
|
|
1271
1392
|
entries.sort((a, b) => a.line - b.line);
|
|
1272
1393
|
if (!entries[0].item.paragraphs[0].bullet) continue;
|
|
1394
|
+
const expectedRoles = roles?.get(path);
|
|
1395
|
+
const validRoles = expectedRoles && expectedRoles.length === entries.length && entries.every(({item, line}, index) => line === index && Boolean(item.paragraphs[0].bullet) === expectedRoles[index][2]);
|
|
1396
|
+
const lineRoles = validRoles ? expectedRoles : null;
|
|
1397
|
+
if (expectedRoles && !validRoles) report({code: 'list-line-role-changed', message: `The line topology of ${path} changed; list roles were not restored.`});
|
|
1273
1398
|
const look = runs?.get(path), textLook = cleanBase(look?.text), descriptionLook = cleanBase(look?.description);
|
|
1274
1399
|
// The line's runs without the inherited look, or the paragraph's own richText when there is no record for this kind of line.
|
|
1275
1400
|
// Each run is cleaned on its own and none merge, so the runs stay the slots the run colour record counts (src/run-colors.js);
|
|
@@ -1299,7 +1424,7 @@ function mergeOpfListShapes(items, breaks = null, report = () => {}, runs = null
|
|
|
1299
1424
|
const join = (a, b) => gap === undefined ? joinRich(a, b) : joinWrappedText([a, b], [gap]);
|
|
1300
1425
|
if (paragraph.bullet) { paragraphs.push({...paragraph, richText: own(paragraph, textLook)}); size = paragraph.maxFontSize; }
|
|
1301
1426
|
else if (paragraph.text === '') { /* a blank line of the entry: no text, no description */ }
|
|
1302
|
-
else if (paragraph.maxFontSize < size - 0.01) {
|
|
1427
|
+
else if (lineRoles ? lineRoles[line][1] === 'description' : paragraph.maxFontSize < size - 0.01) {
|
|
1303
1428
|
const text = own(paragraph, descriptionLook) ?? paragraph.text;
|
|
1304
1429
|
if (current.description === undefined) current.description = text;
|
|
1305
1430
|
else { current.description = join(current.description, text); applied.add(line); }
|
|
@@ -1810,7 +1935,7 @@ function resolvePresentationContext(presentation, options, core = coreContext({.
|
|
|
1810
1935
|
// The primary color, which the exporter draws accent fills (table headers, tag, media badge) in. The `accent` ColorRef role is roles.accent.
|
|
1811
1936
|
accent: normalizeHex(roles.primary),
|
|
1812
1937
|
surface: normalizeHex(roles.surface),
|
|
1813
|
-
border: normalizeHex(
|
|
1938
|
+
border: normalizeHex(roles.border)
|
|
1814
1939
|
},
|
|
1815
1940
|
variables
|
|
1816
1941
|
};
|
|
@@ -2002,6 +2127,17 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
|
|
|
2002
2127
|
// Core resolves every shared design key once (slide design, deck design, then the layout record's design): the text alignment of
|
|
2003
2128
|
// each item (item.alignment), and each image item's own fit and treatment (item.image).
|
|
2004
2129
|
const geometry = composeSlide(opfSlide, composeOptions);
|
|
2130
|
+
if (slideIndex === 0) context.firstSlideOptions = composeOptions;
|
|
2131
|
+
// RR-81: a list core broke into columns (listColumns) is drawn as one measured list whose lines sit in their columns: each line
|
|
2132
|
+
// is its own shape at core's geometry, as in every list, and line names, numbering and provenance run across the columns.
|
|
2133
|
+
// Core's item.text is the whole list (every entry at its column's position); the lines are taken from listColumns, which replace
|
|
2134
|
+
// the entries rather than add to them, so each entry is drawn once.
|
|
2135
|
+
for (const item of geometry.items) if (item.listColumns?.length > 1 && item.text?.listEntries) item.text = {...item.text, listEntries: item.listColumns.flatMap(column => column.text?.listEntries ?? [])};
|
|
2136
|
+
// RR-81: a slide on a 0.19 template uses that template's native slide layout (src/template-layouts.js), whose placeholders
|
|
2137
|
+
// core composes with this slide's options (canvas, the deck's header and footer, direction) and its placed-image bands.
|
|
2138
|
+
const template = slideTemplate(slideContext.core);
|
|
2139
|
+
if (template) context.templateSlides[slideIndex] = {template, areaOptions: {...composeOptions, placements: placedImageBands(opfSlide)}};
|
|
2140
|
+
else if (opfSlide.layout === undefined || opfSlide.layout === 'auto') context.automaticSlides[slideIndex] = {areaOptions: composeOptions, title: geometry.items.find(item => item.field === 'title')};
|
|
2005
2141
|
// The default footer band (date left, text center, number right): where a footer added natively lands (RR-11).
|
|
2006
2142
|
if (slideIndex === 0) context.defaultFooterOptions = composeOptions;
|
|
2007
2143
|
slideContext.zoneBand = zoneBands(composeOptions);
|
|
@@ -2035,6 +2171,13 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
|
|
|
2035
2171
|
// Cover and section slides: the deck logo core composed at the top-left of the free area, after the watermark and before content.
|
|
2036
2172
|
if (geometry.logo) await addLogo(slide, presentation, geometry.logo, slideIndex, slideContext, context, options);
|
|
2037
2173
|
for (const item of geometry.items) {
|
|
2174
|
+
// RR-81: the lone picture, chart or table of a `none` region binds to that region's placeholder on the slide layout. A captioned
|
|
2175
|
+
// object or one drawn on a card stays a free shape: Change Layout would move it away from its caption or card.
|
|
2176
|
+
const boundRegion = template && item.region !== undefined && ['image', 'chart', 'table'].includes(item.field) && !item.caption && !item.frameBox
|
|
2177
|
+
? geometry.regions?.find(region => region.name === item.region && region.flow === 'none' && region.content?.length === 1 && !region.overflow?.length) : undefined;
|
|
2178
|
+
if ((template || context.automaticSlides[slideIndex]) && item.field === 'title' && typeof item.value === 'string' && item.text?.lines?.length === 1 && !item.text.overflow && !item.text.richLines)
|
|
2179
|
+
context.placeholderBindings.set(`OPF heading ${item.path} line 0`, {slide: slideIndex, region: 'Title', object: 'title'});
|
|
2180
|
+
const drawnBefore = boundRegion && {images: context.images.size, placeholders: context.imagePlaceholders.size, charts: context.chartHeadings.size, tables: context.tableHeaders.size};
|
|
2038
2181
|
// Card text sits on a literal card fill, not the slide background: keep it literal.
|
|
2039
2182
|
const itemContext = item.frameBox ? {...slideContext, textColor: slideContext.colors.text, mutedColor: slideContext.colors.mutedText} : slideContext;
|
|
2040
2183
|
const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
|
|
@@ -2045,7 +2188,7 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
|
|
|
2045
2188
|
const surface=itemContext.colorScheme.surface??itemContext.colorScheme[itemContext.roles.dark?'dark2':'light2']??`#${itemContext.colors.surface}`;
|
|
2046
2189
|
slide.addShape('roundRect',{x:frame.x/96,y:frame.y/96,w:frame.width/96,h:frame.height/96,
|
|
2047
2190
|
rectRadius:8*Math.min(widthInches,heightInches)/720,
|
|
2048
|
-
fill:paint(surface),line:{...paint(
|
|
2191
|
+
fill:paint(surface),line:{...paint(`#${itemContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
|
|
2049
2192
|
}
|
|
2050
2193
|
if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
|
|
2051
2194
|
addMeasuredPayloadText(slide,item.value,item.box,itemContext,options,{path:item.path,fit:item.text,textStyle:item.textStyle,sourceText:item.field==='text'&&!!item.text.sourceLines,align:item.alignment,diagnosticsHandled:true,heading:['title','subtitle','tag'].includes(item.field)?item.field:undefined,color:item.field==='tag'?tagColor(itemContext):itemContext.textColor});
|
|
@@ -2085,12 +2228,18 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
|
|
|
2085
2228
|
} else {
|
|
2086
2229
|
// RR-34: a captioned item draws its media in item.box; the caption band follows as tagged text boxes linked to the media shape by name.
|
|
2087
2230
|
const mediaNames = item.caption ? new Set([...context.images.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()]) : undefined;
|
|
2088
|
-
await addPayload(slide, presentation, item.payload, region, item.path, { ...itemContext, composition: item.composition, contentAlignment: item.alignment ?? "left", direction: geometry.direction }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
|
|
2231
|
+
await addPayload(slide, presentation, item.payload, region, item.path, { ...itemContext, composition: item.composition, contentAlignment: item.alignment ?? "left", tableAlignment: geometry.design.contentAlignment, direction: geometry.direction }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
|
|
2089
2232
|
if (item.caption) {
|
|
2090
2233
|
const mediaName = item.field === 'video' ? `OPF media ${item.path} frame` : [...context.images.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()].find(name => !mediaNames.has(name));
|
|
2091
2234
|
addCaption(slide, item, mediaName, itemContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: item.caption.path}));
|
|
2092
2235
|
}
|
|
2093
2236
|
}
|
|
2237
|
+
if (drawnBefore) {
|
|
2238
|
+
const added = (map, size) => [...map.keys()].slice(size);
|
|
2239
|
+
const names = item.field === 'image' ? (context.imagePlaceholders.size === drawnBefore.placeholders ? added(context.images, drawnBefore.images) : [])
|
|
2240
|
+
: item.field === 'chart' ? added(context.chartHeadings, drawnBefore.charts).filter(name => !context.chartex.has(name)) : added(context.tableHeaders, drawnBefore.tables);
|
|
2241
|
+
if (names.length === 1) context.placeholderBindings.set(names[0], {slide: slideIndex, region: boundRegion.name, object: item.field === 'image' ? 'picture' : item.field});
|
|
2242
|
+
}
|
|
2094
2243
|
}
|
|
2095
2244
|
// RR-34: the footnote area core reserved above the footer band (rule plus one tagged text box per listed line).
|
|
2096
2245
|
addFootnotes(slide, geometry.footnotes, slideIndex, slideContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: `slides.${slideIndex}`}));
|
|
@@ -2106,6 +2255,13 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
|
|
|
2106
2255
|
}
|
|
2107
2256
|
}
|
|
2108
2257
|
|
|
2258
|
+
// RR-81: the placed-image bands of a slide (its image blocks with a `placement`), which a template layout reserves as the slide does.
|
|
2259
|
+
function placedImageBands(slide) {
|
|
2260
|
+
return (Array.isArray(slide?.blocks) ? slide.blocks : [])
|
|
2261
|
+
.filter(block => isPlainObject(block) && isPlainObject(block.placement) && typeof block.placement.edge === 'string' && (block.image !== undefined || block.type === 'image'))
|
|
2262
|
+
.map(({placement}) => ({edge: placement.edge, ...(placement.size !== undefined ? {size: placement.size} : {}), ...(placement.inset !== undefined ? {inset: placement.inset} : {})}));
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2109
2265
|
// One slide's export context: core's resolveSlideContext (`core`: composeSlide's options, the records and the diagnostics) and
|
|
2110
2266
|
// the exporter's colours and measurement-resolved families derived from it.
|
|
2111
2267
|
function exportSlideContext(presentation, slideIndex, baseContext, options) {
|
|
@@ -2536,7 +2692,7 @@ function applyChartTextSize(xml, size) {
|
|
|
2536
2692
|
}
|
|
2537
2693
|
|
|
2538
2694
|
function addChartPayload(slide, chart, region, context, options = {}, path = "chart", presentation) {
|
|
2539
|
-
const chartData = toPptxChartData(chart, context.chartexMode, presentation);
|
|
2695
|
+
const chartData = toPptxChartData(chart, context.chartexMode, presentation, path);
|
|
2540
2696
|
if (!chartData.series) {
|
|
2541
2697
|
// Never lose a chart silently: the placeholder frame stands in for it, and a diagnostic names the reason.
|
|
2542
2698
|
options.onDiagnostic?.({code: "chart-data-unplottable", path, message: chartData.message, reason: chartData.reason});
|
|
@@ -2569,7 +2725,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
2569
2725
|
labels: !chartOptions?.dataLabels?.content?.includes('percent'), scatter: chartData.type === 'scatter'};
|
|
2570
2726
|
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading,labelColor,spec:chartData.spec,textSize,
|
|
2571
2727
|
options:chartOptions,kind:chartTargetFor(chart.type)?.kind,pointCount:chartData.pointCount,numberFormats});
|
|
2572
|
-
|
|
2728
|
+
// Synthetic row-number categories are a native adaptation, not an authored
|
|
2729
|
+
// data column. Full provenance restores the source form only on unchanged caches.
|
|
2730
|
+
recordPayloadData(context, objectName, 'chart', presentation, path, undefined, {rowNumbers: chart.data?.columns?.length === 1 && chartData.adaptations?.some(entry => entry.adaptation === 'row-numbers') === true});
|
|
2573
2731
|
context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
|
|
2574
2732
|
// FA-09: the chart's text alternative (the frame's descr; the empty string is PowerPoint's decorative marker), applied before the chartex frame copies the head.
|
|
2575
2733
|
if (typeof chart.alt === 'string') context.frameAlts.set(objectName, chart.alt);
|
|
@@ -2622,6 +2780,11 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
2622
2780
|
dataLabelFontSize: textSize / 100,
|
|
2623
2781
|
showValue: false,
|
|
2624
2782
|
valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
|
|
2783
|
+
// Magnitude bars/columns/areas must not let Office auto-truncate a near-equal range.
|
|
2784
|
+
...(chartData.spec.family === "category" && ["bar", "area"].includes(chartData.type) && chartData.spec.grouping !== "percentStacked" ? {
|
|
2785
|
+
...(chartData.series.every(entry => entry.values.every(value => value == null || value >= 0)) ? {valAxisMinVal: 0} : {}),
|
|
2786
|
+
...(chartData.series.some(entry => entry.values.some(value => value < 0)) && chartData.series.every(entry => entry.values.every(value => value == null || value <= 0)) ? {valAxisMaxVal: 0} : {})
|
|
2787
|
+
} : {}),
|
|
2625
2788
|
barDir: chartData.barDir,
|
|
2626
2789
|
barGrouping: chartData.barGrouping,
|
|
2627
2790
|
// Right to left (RR-05): column, line and area charts reverse their categories (c:catAx orientation maxMin), which also moves the
|
|
@@ -2668,11 +2831,11 @@ function comboChart(chartData, options, {palette, chartOptions, labelColor, font
|
|
|
2668
2831
|
// RR-54: a chart or table that uses a dataset, DataColumn objects, a mapping, a data source or number formats records its
|
|
2669
2832
|
// authored data form on its native frame (src/data-provenance.js), in `full` provenance mode only. `path` is the composed
|
|
2670
2833
|
// item's path ("slides.0.blocks.1.chart"), which names the authored value (composition hands over the inline copy).
|
|
2671
|
-
function recordPayloadData(context, objectName, kind, presentation, path, layout) {
|
|
2834
|
+
function recordPayloadData(context, objectName, kind, presentation, path, layout, chartRecordOptions) {
|
|
2672
2835
|
if (context.provenanceMode !== 'full' || !context.dataRecords || !presentation) return;
|
|
2673
2836
|
let authored = presentation;
|
|
2674
2837
|
for (const key of String(path).split('.')) authored = authored !== null && typeof authored === 'object' ? authored[Array.isArray(authored) ? Number(key) : key] : undefined;
|
|
2675
|
-
const record = kind === 'chart' ? chartDataRecord(authored, presentation.datasets) : tableDataRecord(authored, layout);
|
|
2838
|
+
const record = kind === 'chart' ? chartDataRecord(authored, presentation.datasets, chartRecordOptions) : tableDataRecord(authored, layout);
|
|
2676
2839
|
if (record) {
|
|
2677
2840
|
context.dataRecords.set(objectName, record);
|
|
2678
2841
|
context.dataRecordPaths.set(objectName, String(path));
|
|
@@ -2694,7 +2857,7 @@ function addTablePayload(slide, authoredTable, region, context, options, path, p
|
|
|
2694
2857
|
}
|
|
2695
2858
|
|
|
2696
2859
|
const layout = layoutTable(table, {x:region.x*96,y:region.y*96,width:region.w*96,height:region.h*96}, {
|
|
2697
|
-
scale, minFontSize:context.composition?.minFontSize, fontFamily:context.fonts.body, textMeasurement:options.textMeasurement, path,
|
|
2860
|
+
scale, minFontSize:context.composition?.minFontSize, fontFamily:context.fonts.body, textMeasurement:options.textMeasurement, path, alignment:context.tableAlignment,
|
|
2698
2861
|
...(context.direction === 'rtl' ? {direction: 'rtl'} : {}), ...(presentation ? {presentation} : {})
|
|
2699
2862
|
});
|
|
2700
2863
|
const columnCount = layout.columnCount;
|
|
@@ -2744,7 +2907,7 @@ function addTablePayload(slide, authoredTable, region, context, options, path, p
|
|
|
2744
2907
|
...(rich ? {bold:false,italic:false} : {}),
|
|
2745
2908
|
lineSpacing: fit.lineHeight * 0.75,
|
|
2746
2909
|
paraSpaceAfter: 0,
|
|
2747
|
-
align: physicalAlignment(
|
|
2910
|
+
align: physicalAlignment(cell.alignment, cell.direction),
|
|
2748
2911
|
valign: cellStyle.verticalAlign ?? 'top',
|
|
2749
2912
|
...(cell.colSpan > 1 ? {colspan:cell.colSpan} : {}),
|
|
2750
2913
|
...(cell.rowSpan > 1 ? {rowspan:cell.rowSpan} : {}),
|
|
@@ -2761,7 +2924,7 @@ function addTablePayload(slide, authoredTable, region, context, options, path, p
|
|
|
2761
2924
|
recordPayloadData(context, objectName, 'table', presentation, path, layout);
|
|
2762
2925
|
// FA-27: the table's text alternative (the frame's descr; the empty string is PowerPoint's decorative marker).
|
|
2763
2926
|
if (typeof authoredTable?.alt === 'string') context.frameAlts.set(objectName, authoredTable.alt);
|
|
2764
|
-
if (layout.rows.some(row => row.cells.some(cell => Object.keys(cell.style ?? {}).length))) context.tableCells.set(objectName, {layout, scale, context, defaultBorder:{color
|
|
2927
|
+
if (layout.rows.some(row => row.cells.some(cell => Object.keys(cell.style ?? {}).length))) context.tableCells.set(objectName, {layout, scale, context, defaultBorder:{color:`#${context.colors.border}`,width:1/scale}});
|
|
2765
2928
|
slide.addTable(rows, {
|
|
2766
2929
|
objectName,
|
|
2767
2930
|
x: region.x,
|
|
@@ -2777,7 +2940,7 @@ function addTablePayload(slide, authoredTable, region, context, options, path, p
|
|
|
2777
2940
|
fontFace: context.fonts.body,
|
|
2778
2941
|
fontSize: 15 * scale * 0.75,
|
|
2779
2942
|
color: context.colors.text,
|
|
2780
|
-
border: { type: "solid", color:
|
|
2943
|
+
border: { type: "solid", color: context.colors.border, pt: 0.75 },
|
|
2781
2944
|
margin: [6 * scale, 7.5 * scale, 3 * scale, 7.5 * scale],
|
|
2782
2945
|
valign: "top"
|
|
2783
2946
|
});
|
|
@@ -3454,7 +3617,7 @@ function textRuns(value, context, fallbackFontSize) {
|
|
|
3454
3617
|
* goes through core's strict `chartNumber` (a string that is not a plain decimal is a gap, reported once per chart as
|
|
3455
3618
|
* `chart-value-not-numeric`). `formats` holds the Excel codes of the columns' number formats per exported series.
|
|
3456
3619
|
*/
|
|
3457
|
-
function toPptxChartData(chart, chartexMode = 'auto', presentation) {
|
|
3620
|
+
function toPptxChartData(chart, chartexMode = 'auto', presentation, path = 'chart') {
|
|
3458
3621
|
const unplottable = (reason, summary, message) => ({reason, summary, message: `${message} No native chart was exported; a placeholder frame stands in for it.`});
|
|
3459
3622
|
// A dataset reference is inlined from the document first (composition normally hands over the inline copy already).
|
|
3460
3623
|
const source = isDatasetRef(chart?.data) ? inlineChartData(chart, presentation) : chart;
|
|
@@ -3473,19 +3636,25 @@ function toPptxChartData(chart, chartexMode = 'auto', presentation) {
|
|
|
3473
3636
|
const columnCodes = (resolvedData.formats ?? []).map(excelCode);
|
|
3474
3637
|
const diagnostics = resolvedData.diagnostics.filter((entry) => entry.severity === 'warning' && entry.code !== 'chart-value-not-numeric');
|
|
3475
3638
|
const rejected = resolvedData.diagnostics.filter((entry) => entry.code === 'chart-value-not-numeric');
|
|
3476
|
-
const resolved = resolveChartType(chart.type)
|
|
3639
|
+
const {id: chartId, spec: resolved} = resolveChartType(chart.type);
|
|
3477
3640
|
// A chartex type (treemap, histogram, pareto, box & whisker, waterfall, funnel, map) has no classic construct. Natively
|
|
3478
3641
|
// it is written as its cx:chartSpace part and the clustered column chart of the same data is its mc:Fallback
|
|
3479
3642
|
// (src/chartex.js). 'auto' (the default) does that for the constructs the native PowerPoint check confirmed and keeps
|
|
3480
3643
|
// the unconfirmed map on the clustered column chart alone; 'fallback' keeps every chartex id there. The fallback is
|
|
3481
3644
|
// reported as chartex-fallback, never silent.
|
|
3482
|
-
const
|
|
3645
|
+
const multiSeriesPareto = chartId === 'pareto' && data.columns.length > 2;
|
|
3646
|
+
if (multiSeriesPareto && chartexMode === 'native') {
|
|
3647
|
+
throw new OPFPptxError('unsupported-chart-data', 'Native Pareto supports one value series. Use chartex auto or fallback to export every series as an editable clustered column chart.', {path, series: data.columns.slice(1)});
|
|
3648
|
+
}
|
|
3649
|
+
const native = chartexMode === 'native' || (chartexMode === 'auto' && resolved.family === 'chartex' && !resolved.unconfirmed && !multiSeriesPareto);
|
|
3483
3650
|
const chartex = resolved.family === 'chartex' && native ? resolved : null;
|
|
3484
3651
|
const spec = resolved.family === 'chartex' ? CHARTEX_FALLBACK : resolved;
|
|
3485
3652
|
const typeName = stringifyText(chart.type);
|
|
3486
3653
|
const adaptations = [];
|
|
3487
3654
|
if (resolved.family === 'chartex' && !native) {
|
|
3488
|
-
const reason = chartexMode === '
|
|
3655
|
+
const reason = multiSeriesPareto && chartexMode === 'auto'
|
|
3656
|
+
? 'that cannot represent multiple value series natively; all authored series are preserved in the fallback'
|
|
3657
|
+
: chartexMode === 'fallback'
|
|
3489
3658
|
? 'that this exporter does not write'
|
|
3490
3659
|
: 'that PowerPoint has not yet accepted natively from this exporter (pass chartex: \'native\' to write its chartex part)';
|
|
3491
3660
|
adaptations.push({adaptation: "chartex-fallback", message: `The '${typeName}' chart is a PowerPoint extension (chartex) chart ${reason}; its data is exported as a native clustered column chart instead.`});
|
|
@@ -4059,6 +4228,16 @@ async function normalizePptxZip(raw, context) {
|
|
|
4059
4228
|
}
|
|
4060
4229
|
reportPerSlideNotesScriptFonts(context.scriptFonts, slideThemes);
|
|
4061
4230
|
}
|
|
4231
|
+
// RR-81: one native slide layout per 0.19 template the slides use, under each slide's master, and the objects bound to its
|
|
4232
|
+
// placeholders (src/template-layouts.js). Before RR-72, which copies a slide's own layout for a furniture variant.
|
|
4233
|
+
if (context.templateSlides.some(Boolean) || context.deckTemplates?.length || context.automaticSlides.some(Boolean)) {
|
|
4234
|
+
try {
|
|
4235
|
+
writeTemplateLayouts(output, context.templateSlides, context.deckTemplates ?? [], context.placeholderBindings,
|
|
4236
|
+
(path, xml) => context.scriptFonts ? partScriptFonts(path, xml, context.scriptFonts, 0) : xml, {records: context.provenanceMode === 'full', automaticSlides: context.automaticSlides});
|
|
4237
|
+
} catch (error) {
|
|
4238
|
+
throw new OPFPptxError('packaging-failed', 'Native slide layouts for the layout templates could not be written.', {cause: errorMessage(error)});
|
|
4239
|
+
}
|
|
4240
|
+
}
|
|
4062
4241
|
// RR-72: furniture drawn the same on several slides moves to the slide master or a layout (src/master-furniture.js).
|
|
4063
4242
|
try {
|
|
4064
4243
|
liftMasterFurniture(output, context.furnitureParts, diagnostic => context.reportDiagnostic?.(diagnostic));
|
package/dist/master-furniture.js
CHANGED
|
@@ -157,7 +157,7 @@ export function liftMasterFurniture(output, slides, report = () => {}) {
|
|
|
157
157
|
const showsMaster = plan.candidates.size ? common.every(slot => free(plan, slot)) : !common.length;
|
|
158
158
|
const extras = [...plan.candidates.keys()].filter(slot => free(plan, slot) && (showsMaster ? !common.includes(slot) && counts.get(id(slot, value(plan, slot))) >= 2
|
|
159
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')}`;
|
|
160
|
+
const key = `${plan.layout}\u0002${showsMaster ? 1 : 0}\u0001${extras.map(slot => id(slot, value(plan, slot))).join('\u0001')}`;
|
|
161
161
|
plan.extras = extras;
|
|
162
162
|
plan.lifted = new Map([...(showsMaster ? common.map(slot => [slot, 'master']) : []), ...extras.map(slot => [slot, 'layout'])]);
|
|
163
163
|
plan.layoutKey = showsMaster && !extras.length ? undefined : key;
|
|
@@ -228,20 +228,38 @@ export function liftMasterFurniture(output, slides, report = () => {}) {
|
|
|
228
228
|
for (const {master, group, common, layouts} of assignments) {
|
|
229
229
|
const showing = group.find(plan => common.every(slot => free(plan, slot)) && plan.candidates.size);
|
|
230
230
|
for (const slot of common) transplant(master, showing, slot);
|
|
231
|
-
// The layout
|
|
232
|
-
|
|
231
|
+
// The layout a slide uses now, copied once per layout key: the master's one layout, or the slide's 0.19 template layout (RR-81),
|
|
232
|
+
// whose variant keeps its placeholders and its OPF_LAYOUT_V1 tag (`Pillars (no furniture)`).
|
|
233
233
|
let masterXml = partXml(master), masterRels = partRels(master);
|
|
234
234
|
let number = 0;
|
|
235
|
+
const templateNumbers = new Map();
|
|
235
236
|
for (const layout of layouts.values()) {
|
|
237
|
+
const base = layout.source.layout;
|
|
236
238
|
const path = `ppt/slideLayouts/slideLayout${nextLayout++}.xml`;
|
|
237
|
-
const
|
|
239
|
+
const baseRels = has(relsOf(base)) ? read(relsOf(base)) : '';
|
|
240
|
+
const tagRels = relationships(baseRels).filter(rel => rel.type === `${REL}/tags` && !rel.external);
|
|
241
|
+
const baseName = unescapeAttribute(read(base).match(/<p:cSld\b[^>]*\bname="([^"]*)"/)?.[1] ?? '');
|
|
242
|
+
let name;
|
|
243
|
+
if (tagRels.length) {
|
|
244
|
+
const count = (templateNumbers.get(base) ?? 0) + (layout.source.extras.length ? 1 : 0);
|
|
245
|
+
templateNumbers.set(base, count);
|
|
246
|
+
name = layout.source.extras.length ? `${baseName} (furniture ${count})` : `${baseName} (no furniture)`;
|
|
247
|
+
} else name = layout.source.extras.length ? `OPF furniture ${++number}` : 'OPF no furniture';
|
|
238
248
|
let xml = read(base).replace(/(<p:cSld\b[^>]*?\bname=")[^"]*(")/, `$1${escapeAttribute(name)}$2`);
|
|
239
249
|
if (!layout.showsMaster) xml = xml.replace(/<p:sldLayout\b[^>]*>/, open => `${open.slice(0, -1).replace(/\s+showMasterSp="[^"]*"/, '')} showMasterSp="0">`);
|
|
240
|
-
|
|
241
|
-
// A copied layout keeps only its master relationship; the furniture brings its own.
|
|
250
|
+
// A copied layout keeps only its master relationship and its own tags (a copy of each tag part); the furniture brings its own.
|
|
242
251
|
const masterRel = relationships(baseRels).find(rel => rel.type === `${REL}/slideMaster`);
|
|
243
252
|
if (!masterRel) throw new Error(`${base} has no slide master relationship.`);
|
|
244
|
-
|
|
253
|
+
let copiedRels = masterRel.node;
|
|
254
|
+
for (const rel of tagRels) {
|
|
255
|
+
const source = resolvePart(base, rel.target);
|
|
256
|
+
let n = 0, copy;
|
|
257
|
+
do copy = `ppt/tags/opfLayoutVariant${++n}.xml`; while (has(copy));
|
|
258
|
+
output[copy] = [output[source][0], options];
|
|
259
|
+
types = types.replace('</Types>', `<Override PartName="/${copy}" ContentType="${TAGS_TYPE}"/></Types>`);
|
|
260
|
+
copiedRels += `<Relationship Id="${rel.id}" Type="${REL}/tags" Target="${escapeAttribute(relativeTarget(path, copy))}"/>`;
|
|
261
|
+
}
|
|
262
|
+
stage(path, xml, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${copiedRels}</Relationships>`);
|
|
245
263
|
types = types.replace('</Types>', `<Override PartName="/${path}" ContentType="${LAYOUT_TYPE}"/></Types>`);
|
|
246
264
|
for (const slot of layout.source.extras) transplant(path, layout.source, slot);
|
|
247
265
|
const result = relationshipId(masterRels, `${REL}/slideLayout`, relativeTarget(master, path), false);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Full provenance stores style spans, never source words. Native edits win.
|
|
2
|
+
const keys = ['fontSize', 'fontFamily', 'color'];
|
|
3
|
+
const literalColor = value => typeof value === 'string' && /^#(?:[a-f0-9]{3}|[a-f0-9]{6}|[a-f0-9]{8})$/i.test(value);
|
|
4
|
+
export function sourceRunStyles(value) {
|
|
5
|
+
if (!Array.isArray(value) || !value.length) return undefined;
|
|
6
|
+
const spans = value.map(run => typeof run === 'string' ? {length: run.length, string: true} : {
|
|
7
|
+
length: run.text.length, ...(run.color !== undefined && !literalColor(run.color) ? {keepColor: true} : {}), ...Object.fromEntries(keys.filter(key => run[key] !== undefined && (key !== 'color' || literalColor(run.color))).map(key => [key, run[key]]))
|
|
8
|
+
});
|
|
9
|
+
if (spans.some(span => !span.length)) return undefined;
|
|
10
|
+
try { validateSourceRunStyles(spans); } catch { return undefined; }
|
|
11
|
+
return spans;
|
|
12
|
+
}
|
|
13
|
+
export function validateSourceRunStyles(spans) {
|
|
14
|
+
if (!Array.isArray(spans) || !spans.length || spans.length > 10000) throw Error('Invalid source style spans.');
|
|
15
|
+
let length = 0;
|
|
16
|
+
for (const span of spans) {
|
|
17
|
+
if (!span || typeof span !== 'object' || Array.isArray(span) || Object.keys(span).some(key => !['length', 'string', 'keepColor', ...keys].includes(key))
|
|
18
|
+
|| !Number.isSafeInteger(span.length) || span.length < 1 || (length += span.length) > 1000000
|
|
19
|
+
|| (span.keepColor !== undefined && (span.keepColor !== true || span.color !== undefined))
|
|
20
|
+
|| (span.string !== undefined && span.string !== true)
|
|
21
|
+
|| (span.fontSize !== undefined && (!Number.isFinite(span.fontSize) || span.fontSize <= 0))
|
|
22
|
+
|| (span.fontFamily !== undefined && (typeof span.fontFamily !== 'string' || !span.fontFamily || span.fontFamily.length > 256))
|
|
23
|
+
|| (span.color !== undefined && !literalColor(span.color))) throw Error('Invalid source style span.');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function restoreSourceRunStyles(value, spans, intact) {
|
|
27
|
+
if (!spans || !Array.isArray(value) || !keys.some(key => intact[key])) return value;
|
|
28
|
+
const runs = value.map(run => typeof run === 'string' ? {text: run} : run);
|
|
29
|
+
if (runs.some(run => typeof run.text !== 'string') || runs.reduce((n, run) => n + run.text.length, 0) !== spans.reduce((n, span) => n + span.length, 0)) return value;
|
|
30
|
+
const result = []; let runIndex = 0, offset = 0;
|
|
31
|
+
for (const span of spans) {
|
|
32
|
+
let left = span.length; const pieces = [];
|
|
33
|
+
while (left) {
|
|
34
|
+
const run = runs[runIndex], take = Math.min(left, run.text.length - offset);
|
|
35
|
+
if (!take) { runIndex++; offset = 0; continue; }
|
|
36
|
+
const piece = {...run, text: run.text.slice(offset, offset + take)};
|
|
37
|
+
for (const key of keys) if (intact[key] && !(key === 'color' && span.keepColor)) { if (span[key] === undefined) delete piece[key]; else piece[key] = span[key]; }
|
|
38
|
+
pieces.push(piece); left -= take; offset += take;
|
|
39
|
+
}
|
|
40
|
+
for (const piece of pieces) {
|
|
41
|
+
const previous = result.at(-1);
|
|
42
|
+
if (pieces.indexOf(piece) > 0 && previous && typeof previous === 'object' && JSON.stringify({...previous, text: ''}) === JSON.stringify({...piece, text: ''})) previous.text += piece.text;
|
|
43
|
+
else result.push(piece);
|
|
44
|
+
}
|
|
45
|
+
if (span.string && pieces.length && Object.keys(result.at(-1)).length === 1 && keys.every(key => intact[key])) result[result.length - 1] = result.at(-1).text;
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
// Property-specific evidence includes every native run/default property in order.
|
|
50
|
+
// A change anywhere conservatively disables that property's source restoration.
|
|
51
|
+
export function nativeRunStyleEvidence(tree, hash) {
|
|
52
|
+
const values = {fontSize: [], fontFamily: [], color: []};
|
|
53
|
+
const visit = (node, path = []) => {
|
|
54
|
+
if (Array.isArray(node)) { node.forEach((item, index) => visit(item, [...path, index])); return; }
|
|
55
|
+
if (!node || typeof node !== 'object') return;
|
|
56
|
+
for (const [key, value] of Object.entries(node)) {
|
|
57
|
+
if (key === 'sz') values.fontSize.push([[...path, key], value]);
|
|
58
|
+
if (/^a:(latin|ea|cs|sym)$/.test(key)) values.fontFamily.push([[...path, key], value]);
|
|
59
|
+
if (/^a:(srgbClr|schemeClr|sysClr|prstClr)$/.test(key)) values.color.push([[...path, key], value]);
|
|
60
|
+
visit(value, [...path, key]);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
visit(tree);
|
|
64
|
+
return Object.fromEntries(keys.map(key => [key, hash(JSON.stringify(values[key]))]));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function hasSourceRunStyles(topology) {
|
|
68
|
+
if (!topology || typeof topology !== 'object') return false;
|
|
69
|
+
if (Array.isArray(topology)) return topology.some(hasSourceRunStyles);
|
|
70
|
+
return topology.sourceStyles !== undefined || Object.values(topology).some(hasSourceRunStyles);
|
|
71
|
+
}
|