@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/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
- return {root, relationships, shapes: nativeTextShapes(tree), pictures: nativePictures(tree),
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(colorScheme.accent5 ?? "#CBD5E1")
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(itemContext.colorScheme.accent5??`#${itemContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
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
- recordPayloadData(context, objectName, 'chart', presentation, path);
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(cellStyle.align ?? context.contentAlignment, cell.direction),
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:"accent5",width:1/scale}});
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: nativeColor("accent5", context.colors.border, context), pt: 0.75 },
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).spec;
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 native = chartexMode === 'native' || (chartexMode === 'auto' && resolved.family === 'chartex' && !resolved.unconfirmed);
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 === 'fallback'
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));
@@ -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 the slides use now (each master has one), copied once per layout key.
232
- const base = group[0].layout;
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 name = layout.source.extras.length ? `OPF furniture ${++number}` : 'OPF no furniture';
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
- const baseRels = has(relsOf(base)) ? read(relsOf(base)) : '';
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
- 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>`);
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
+ }