@openpresentation/opf-pptx 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -24,13 +24,14 @@ import {headingValue,joinRichLines} from './rich-heading.js';
24
24
  import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
25
25
  import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
26
26
  import {attachQuoteTags,quoteManifest,importQuoteGroups,quotePhotoName} from './quote-provenance.js';
27
- import {attachFurnitureTags, furnitureManifest, importFurniture, manifestPartIndex, staticDateFallback} from './furniture-provenance.js';
27
+ import {attachFurnitureTags, furnitureManifest, importFurniture, logoShape, staticDateFallback, stampFurnitureImageKeys} from './furniture-provenance.js';
28
28
  import {restoreRunColors} from './run-colors.js';
29
29
  import {joinWrappedText} from './content-topology.js';
30
- import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, recordContentTopology, restoreDocumentProvenance, joinAuthors, splitAuthors, slideListBreaks, DEFAULT_AUTHOR} from './document-provenance.js';
30
+ import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, recordContentTopology, restoreDocumentProvenance, joinAuthors, splitAuthors, slideListBreaks, storedMetadata, DEFAULT_AUTHOR, DEFAULT_TITLE} from './document-provenance.js';
31
31
  import {INVALID_XML_CHARACTER, nativeSections, writeSectionList} from './sections.js';
32
32
  import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
33
- import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, writeNativeMasters} from './native-furniture.js';
33
+ import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, nativePlaceholderForPart, writeNativeMasters} from './native-furniture.js';
34
+ import {liftMasterFurniture} from './master-furniture.js';
34
35
  import {importImageOrientation} from './image-import.js';
35
36
  import {extractSignals, normalizeSignalOptions, themeFactsFor} from './import-signals.js';
36
37
  import {placeImages, importImages, imageName, imageOverlayName, backgroundImageName, backgroundOverlayName, IMAGE_TREATMENT_KEYS, BACKGROUND_IMAGE_KEYS} from './image-provenance.js';
@@ -59,6 +60,7 @@ import {
59
60
  isTemplate,
60
61
  OPFCatalogsOptionError,
61
62
  resolveSlideContext as resolveCoreSlideContext,
63
+ resolveSlideVariables,
62
64
  resolveVariables,
63
65
  validate
64
66
  } from "@openpresentation/opf";
@@ -236,7 +238,9 @@ export async function toPptx(input, options = {}) {
236
238
  // resolves nowhere instead of composing automatically or drawing with the engine default.
237
239
  checkCatalogs('toPptx', options.catalogs);
238
240
  options = {...options, strictReferences: options.strictReferences ?? options.strictAssets === true};
239
- const presentation = resolveTemplateInput(parseInput(input), options);
241
+ // The document as given, before the deck-wide variable pass: the furniture manifest records the authored header and footer text.
242
+ const authored = parseInput(input);
243
+ const presentation = resolveTemplateInput(authored, options);
240
244
  assertValidBoundary(presentation);
241
245
  // `options.fonts` is the fonts handle (the renderer's loadFonts()); the exporter reads its measurement, wrapped so the package names the
242
246
  // chosen family (FF-31). Internally `options.textMeasurement` is that wrapped measurement; it is not a public option.
@@ -275,11 +279,15 @@ export async function toPptx(input, options = {}) {
275
279
  context.quoteTags = new Map();
276
280
  context.quotePhotos = new Map();
277
281
  context.furnitureTags = new Map();
278
- context.furnitureLogoTags = new Map();
282
+ context.furnitureImages = new Map();
279
283
  context.furnitureFields = new Map();
280
284
  context.furnitureManifests = new Map();
281
285
  context.nativeFurniture = new Map();
286
+ context.nativeFurnitureBands = new Map();
287
+ // RR-72: each slide's furniture parts and the object names of their shapes, for the slide master and layouts.
288
+ context.furnitureParts = [];
282
289
  context.hostDate = options.date;
290
+ context.authored = authored;
283
291
  context.codeTags = new Map();
284
292
  context.captionTags = new Map();
285
293
  context.footnoteTags = new Map();
@@ -404,6 +412,19 @@ export async function fromPptx(input, options = {}) {
404
412
  // Layouts and the master are read once however many slides inherit a placeholder from them (RR-11).
405
413
  const cached = read => { const memo = new Map(); return path => { if (!memo.has(path)) memo.set(path, read(path)); return memo.get(path); }; };
406
414
  const cachedRelationships = cached(path => parseRelationships(entries, path)), cachedPart = cached(path => parseOptionalXml(entries, path));
415
+ // RR-72: the furniture a slide shows from its layout and slide master (src/master-furniture.js), read once per part.
416
+ const inheritedParts = new Map();
417
+ const inheritedPart = path => {
418
+ if (!path) return undefined;
419
+ if (!inheritedParts.has(path)) {
420
+ const element = /slideMaster/.test(path) ? 'p:sldMaster' : 'p:sldLayout', root = cachedPart(path)?.[element];
421
+ const tree = root?.['p:cSld']?.['p:spTree'];
422
+ inheritedParts.set(path, root ? {path, root, relationships: cachedRelationships(path), shapes: nativeTextShapes(tree), pictures: nativePictures(tree),
423
+ paragraphs: nativeShapeParagraphs(decodeText(entries[path]), element)} : undefined);
424
+ }
425
+ return inheritedParts.get(path);
426
+ };
427
+ const relatedPath = (path, type) => path ? [...cachedRelationships(path).values()].find(rel => rel.type === `http://schemas.openxmlformats.org/officeDocument/2006/relationships/${type}` && rel.targetMode !== 'External' && entries[rel.path])?.path : undefined;
407
428
  const furnitureContexts = slidePaths.map((slidePath, index) => {
408
429
  const root = parseRequiredXml(entries, slidePath)['p:sld'];
409
430
  if (!root) throw new OPFPptxError('invalid-pptx', `PPTX slide is not a PresentationML slide: ${slidePath}.`, {path: slidePath});
@@ -413,12 +434,30 @@ export async function fromPptx(input, options = {}) {
413
434
  path: slidePath, slideWidth: Number(presentationRoot['p:sldSz']?.cx), relationshipsOf: cachedRelationships, readPart: cachedPart,
414
435
  paragraphs: nativeShapeParagraphs(decodeText(entries[slidePath])),
415
436
  readPicture: picture => importPicture(entries, picture, slidePath, relationships,
416
- diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${index}.design`}))};
437
+ diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${index}.design`})),
438
+ inherited: () => {
439
+ const layout = relatedPath(slidePath, 'slideLayout'), master = relatedPath(layout, 'slideMaster');
440
+ return Object.fromEntries([['layout', inheritedPart(layout)], ['master', inheritedPart(master)]].filter(([, part]) => part).map(([on, part]) => [on, {...part,
441
+ readPicture: picture => importPicture(entries, picture, part.path, part.relationships, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${index}.design`}))}]));
442
+ }};
417
443
  });
418
- const furniture = importFurniture(furnitureContexts, entries, options.onDiagnostic);
444
+ // Native sections (PowerPoint's own section list, `Default Section` = none) name each slide's `{{slide.section}}` for the footer text check
445
+ // and, after the slides, are reconciled with the stored value in restoreDocumentProvenance.
446
+ const slideSections = nativeSections(presentationRoot, slideIdsInOrder(presentationRoot, presentationRels, entries, slidePaths));
447
+ const stored = storedMetadata(entries, presentationRoot, presentationRels);
448
+ // A stored zone text with built-in variables is drawn from the document's own metadata, as toPptx resolved it: the stored record
449
+ // (organization, speaker ...) and the observed name, description and author (a default the exporter writes is no value).
450
+ const importedMetadata = {
451
+ ...(imported.name && imported.name !== DEFAULT_TITLE ? {name: imported.name} : {}),
452
+ ...(imported.description ? {description: imported.description} : {}),
453
+ ...(imported.author && joinAuthors(imported.author) !== DEFAULT_AUTHOR ? {author: imported.author} : {}),
454
+ ...(stored.organization !== undefined ? {organization: stored.organization} : {}),
455
+ ...(stored.speaker !== undefined ? {speaker: stored.speaker} : {}),
456
+ };
457
+ const furniture = importFurniture(furnitureContexts, entries, options.onDiagnostic, {sections: slideSections ?? [], organizations: asArray(stored.organization).filter(item => isPlainObject(item) && typeof item.id === 'string' && typeof item.name === 'string').map(item => ({id: item.id, name: item.name, ...(typeof item.role === 'string' ? {role: item.role} : {}), ...(logoShape(item.logo) !== undefined ? {logo: logoShape(item.logo)} : {})})),
458
+ draw: (template, {index, count, section}) => drawFurnitureText(template, {index, count, section}, importedMetadata)});
419
459
  if (Object.keys(furniture.design).length) imported.design = {...imported.design, ...furniture.design};
420
460
  if (furniture.organization) imported.organization = furniture.organization;
421
- if (furniture.speaker) imported.speaker = furniture.speaker;
422
461
 
423
462
  // RR-54: the datasets recorded at export (OPF_DATASETS_V1). Chart and table frames restore their dataset references against
424
463
  // them, so the document carries them before document provenance validates the restored document.
@@ -429,9 +468,6 @@ export async function fromPptx(input, options = {}) {
429
468
  imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, {...options, rtlDeck: observedRtl(observedLanguage), deckLang: observedLanguage.lang, codeFamily: Object.keys(entries).some(path => /^ppt\/tags\/opf/i.test(path)) ? importedCodeFamily(imported, options.catalogs) : undefined, dataProvenance: {datasets}}, furniture.slides[index], furnitureContexts[index]));
430
469
  }
431
470
  if (datasets) imported.datasets = datasets;
432
- // Native sections (PowerPoint's own section list, `Default Section` = none)
433
- // are reconciled with the footer text and the stored value in restoreDocumentProvenance.
434
- const slideSections = nativeSections(presentationRoot, slideIdsInOrder(presentationRoot, presentationRels, entries, slidePaths));
435
471
  // A watermark carried identically by every slide is the deck's design.watermark.
436
472
  const carried = imported.slides.map(slide => slide.design?.watermark);
437
473
  if (carried.length && carried[0] && carried.every(value => JSON.stringify(value) === JSON.stringify(carried[0]))) {
@@ -456,7 +492,7 @@ export async function fromPptx(input, options = {}) {
456
492
  let slideProvenance = slidePaths.map(() => ({structure: "untagged"}));
457
493
  let restoredGroups = [];
458
494
  try {
459
- const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true, speakerConflict: furniture.speakerConflict === true,
495
+ const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels,
460
496
  // Stored layout ids also resolve in the host's registered catalogs (FA-23).
461
497
  catalogs: options.catalogs,
462
498
  nativeSections: slideSections,
@@ -516,21 +552,19 @@ function applyRunColors(imported, slideProvenance, restoredGroups, report, optio
516
552
  restoreRunColors(imported.slides, info, resolve, report);
517
553
  }
518
554
 
519
- // A consumed logo picture (OPF_LOGO_V1) never becomes content. Its own image restores the logo only when nothing else
520
- // did: the stored document and slide design (or the organization) win and carry LogoSet variants, so a package exported
521
- // without provenance still keeps its logo. A slide-level logo path restores slide design; every other path the deck's.
555
+ // A consumed logo picture (OPF_LOGO_V1) never becomes content. Its own image restores the organization's logo only when the
556
+ // stored document did not (RR-71: logos live on the organization), so a package exported without provenance still keeps its
557
+ // logo: on the primary organization, else on a new one named after the deck. The picture is one drawn asset, so it becomes the
558
+ // organization's single logo for every shape.
522
559
  function restoreLogoFallback(imported, contexts) {
523
560
  const organizations = asArray(imported.organization);
524
- contexts.forEach((context, index) => {
525
- const fallback = context.logoFallback;
526
- if (!fallback) return;
527
- const slide = imported.slides[index];
528
- const image = fallback.image;
529
- // A slide's own logo restores whenever the slide states none, whatever the deck logo is; the deck logo only
530
- // when neither it nor the organization has one.
531
- if (fallback.path.startsWith('slides.')) { if (slide.design?.logo === undefined) slide.design = {...slide.design, logo: image}; }
532
- else if (imported.design?.logo === undefined && !organizations.some(item => item?.logo !== undefined)) imported.design = {...imported.design, logo: image};
533
- });
561
+ if (organizations.some(item => item?.logo !== undefined)) return;
562
+ const image = contexts.map(context => context.logoFallback?.image).find(Boolean);
563
+ if (!image) return;
564
+ const primary = organizations.find(item => item?.role === 'primary') ?? organizations[0];
565
+ if (!primary) { imported.organization = {id: 'brand', name: imported.name ?? 'Brand', logo: image}; return; }
566
+ const index = organizations.indexOf(primary), withLogo = {...primary, logo: image};
567
+ imported.organization = Array.isArray(imported.organization) ? imported.organization.map((item, at) => at === index ? withLogo : item) : withLogo;
534
568
  }
535
569
 
536
570
  function readPptxZip(input) {
@@ -614,6 +648,17 @@ function resolveRelationshipTarget(sourcePartPath, target) {
614
648
  return parts.join("/");
615
649
  }
616
650
 
651
+ // A header or footer text as the exporter drew it for one slide: deck-wide built-ins resolved from `metadata` (core
652
+ // resolveVariables), then the slide-scoped tokens for the slide's position, count and section. undefined when core refuses.
653
+ function drawFurnitureText(template, {index, count, section}, metadata) {
654
+ try {
655
+ const slide = resolveVariables({...metadata, slides: [{...(section !== undefined ? {section} : {}), text: template}]}).presentation.slides[0];
656
+ return resolveSlideVariables(slide, {slideNumber: index + 1, slideCount: count}).text;
657
+ } catch {
658
+ return undefined;
659
+ }
660
+ }
661
+
617
662
  function resolveSlidePaths(entries, presentationRoot, relationships) {
618
663
  const slideIds = asArray(presentationRoot["p:sldIdLst"]?.["p:sldId"]);
619
664
  const paths = [];
@@ -709,7 +754,6 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
709
754
  }
710
755
  if (imageBackground) slide.design = {background: imageBackground};
711
756
  if (Object.keys(furniture.design).length) slide.design = {...slide.design, ...furniture.design};
712
- if (furniture.section !== undefined) slide.section = furniture.section;
713
757
  nativeContext.imageBlocks = images.blocks;
714
758
  nativeContext.imagePictures = images.consumed;
715
759
  // An overlay of a background that was not recovered stays an ordinary shape.
@@ -728,7 +772,7 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
728
772
  diagnostic => options.onDiagnostic?.({...diagnostic, path: watermarkPath}));
729
773
  if (watermark.design) slide.design = {...slide.design, ...watermark.design};
730
774
  nativeContext.watermarkPictures = watermark.consumed;
731
- // The generated deck logo picture is not content; its value returns from the stored design (or, without one, from the picture).
775
+ // The generated cover logo picture is not content; the logo returns from the stored organization (or, without one, from the picture).
732
776
  const logoPath = `slides.${slideIndex}.design.logo`;
733
777
  const logo = importLogo(nativeContext.pictures, relationships, entries, slideIndex,
734
778
  picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
@@ -1903,10 +1947,15 @@ function configurePresentation(pptx, presentation, context) {
1903
1947
  };
1904
1948
  }
1905
1949
 
1906
- async function addSlide(pptx, presentation, opfSlide, slideIndex, context, options) {
1950
+ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context, options) {
1907
1951
  options = {...options, textMeasurement: slideTextMeasurement(presentation, slideIndex, options)};
1908
1952
  const slide = pptx.addSlide();
1909
1953
  const slideContext = exportSlideContext(presentation, slideIndex, context, options);
1954
+ // FA-31: the slide is composed and drawn with its slide-scoped variables substituted ({{slide.number}}, {{slide.section}},
1955
+ // {{deck.slideCount}} for this position in the exported deck, in body text as fixed text). The authored slide still holds the
1956
+ // tokens: header and footer text is substituted by core's furniture layout (a slide number stays a native field), and the
1957
+ // furniture manifest records the authored text.
1958
+ const opfSlide = slideContext.core.slide;
1910
1959
  const { widthInches, heightInches } = slideContext.dimensions;
1911
1960
  // A slide with no layout, or one whose layout resolves nowhere, is composed with no layout record (automatic composition),
1912
1961
  // as in the preview; core reports the reference as unresolved-reference.
@@ -1919,6 +1968,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1919
1968
  const geometry = composeSlide(opfSlide, composeOptions);
1920
1969
  // The default footer band (date left, text center, number right): where a footer added natively lands (RR-11).
1921
1970
  if (slideIndex === 0) context.defaultFooterOptions = composeOptions;
1971
+ slideContext.zoneBand = zoneBands(composeOptions);
1922
1972
  slide.background = { color: slideContext.colors.background };
1923
1973
  // An image background is core's geometry.backgroundImage (slide, deck, then theme background); every other kind is the
1924
1974
  // effective design or theme background definition.
@@ -2009,7 +2059,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
2009
2059
  // Core composes furniture above all content and opf-render paints it last, so
2010
2060
  // spTree order (PowerPoint's z-order) matches: header/footer parts come after
2011
2061
  // every content item, and an overlapping footer stays visible over content.
2012
- await addFurniture(slide,presentation,opfSlide,geometry.furniture,slideContext,options,slideIndex);
2062
+ await addFurniture(slide,presentation,authoredSlide,geometry.furniture,slideContext,options,slideIndex);
2013
2063
 
2014
2064
  if (opfSlide.notes) {
2015
2065
  const notes = String(opfSlide.notes);
@@ -2772,7 +2822,7 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
2772
2822
  // The default footer band core composes for a deck with no footer of its own: where a footer added natively lands.
2773
2823
  function defaultFooterParts(composeOptions) {
2774
2824
  try {
2775
- const slide = {design: {footer: {left: {date: '2026-01-01'}, center: {text: 'Footer'}, right: {slideNumber: true}}}};
2825
+ const slide = {design: {footer: {left: {date: '2026-01-01'}, center: {text: 'Footer'}, right: {text: '{{slide.number}}'}}}};
2776
2826
  // Only the zone boxes and line height are used, and they do not depend on text widths: estimated measurement keeps the host's measurer (and its call count) out of this.
2777
2827
  return (composeSlide(slide, {...composeOptions, textMeasurement: undefined}).furniture?.parts ?? []).filter(part => part.kind === 'footer' && part.type === 'text');
2778
2828
  } catch {
@@ -2780,10 +2830,22 @@ function defaultFooterParts(composeOptions) {
2780
2830
  }
2781
2831
  }
2782
2832
 
2833
+ // The band core gives a lone text part in a zone (x and width in pixels): where a footer added natively sits.
2834
+ function zoneBands(composeOptions) {
2835
+ let bands;
2836
+ return zone => (bands ??= new Map((defaultFooterParts(composeOptions) ?? []).map(part => [part.zone, {x: part.box.x, width: part.box.width}]))).get(zone);
2837
+ }
2838
+
2839
+ // The slide and deck as given to toPptx (variables not yet resolved), for the furniture manifest's authored header and footer text.
2840
+ const authoredFurniture = (context, slideIndex) => ({presentation: context.authored, slide: context.authored?.slides?.[slideIndex]});
2841
+
2783
2842
  async function addFurniture(slide,presentation,source,layout,context,options,slideIndex) {
2843
+ // RR-72: every slide is listed, a slide without furniture too (it may need a layout that hides the master's).
2844
+ const drawn = [];
2845
+ context.furnitureParts[slideIndex] = {path: `ppt/slides/slide${slideIndex + 1}.xml`, parts: drawn};
2784
2846
  if(!layout){
2785
2847
  if(['header','footer'].some(kind=>(source.design?.[kind]??presentation.design?.[kind])))throw new OPFPptxError('missing-furniture-layout','Header/footer export requires coordinated core furniture geometry.',{path:`slides.${slideIndex}.design`});
2786
- const manifest = furnitureManifest(presentation, source, {parts: []}, slideIndex);
2848
+ const manifest = furnitureManifest(presentation, source, {parts: []}, slideIndex, undefined, undefined, authoredFurniture(context, slideIndex));
2787
2849
  if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
2788
2850
  return;
2789
2851
  }
@@ -2791,16 +2853,22 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
2791
2853
  // RR-11: the first footer text, date and slide number that fit one line are native placeholders (src/native-furniture.js).
2792
2854
  const natives = nativeFurnitureParts(layout);
2793
2855
  for(const [index,part]of layout.parts.entries()){
2856
+ const entry = {index, kind: part.kind, zone: part.zone, field: part.field, type: part.type, path: part.path, native: natives.has(index), names: []};
2857
+ drawn.push(entry);
2794
2858
  if(part.type==='image'){
2795
2859
  const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
2796
- const objectName = await addImagePayload(slide,presentation,part.image,region,part.path,context,options);
2797
- // A generated deck logo is listed beside the manifest topology (see furnitureManifest) and tagged as a logo.
2798
- if (objectName && part.field === 'logo') context.furnitureLogoTags.set(objectName, {v:1, role:'furniture', group:String(slideIndex), furniture:part.kind, zone:part.zone});
2799
- else if (objectName) context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:manifestPartIndex(layout.parts, index)});
2860
+ // A logo reference draws the organization's asset: a source that cannot be drawn is reported where the asset is written.
2861
+ const objectName = await addImagePayload(slide,presentation,part.image,region,part.reference!==undefined?part.sourcePath:part.path,context,options);
2862
+ if (objectName) {
2863
+ entry.names.push(objectName);
2864
+ context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:index});
2865
+ // RR-71: a part drawn from a logo reference (var:organization.logo.icon) records its picture, so import can tell it is unchanged.
2866
+ if (part.reference !== undefined) context.furnitureImages.set(`ppt/slides/slide${slideIndex + 1}.xml|${part.kind}.${part.zone}`, objectName);
2867
+ }
2800
2868
  }else{
2801
2869
  if(!part.fit?.sourceLines)throw new OPFPptxError('missing-furniture-layout','Repeated text requires accepted source lines from core.',{path:part.path});
2802
- // Slide numbers and current dates become native PowerPoint fields; {total}
2803
- // and fixed dates stay fixed text. A current date whose pattern has no
2870
+ // Slide numbers ({{slide.number}}) and current dates become native PowerPoint fields; {{deck.slideCount}},
2871
+ // {{slide.section}} and fixed dates stay fixed text. A current date whose pattern has no
2804
2872
  // en-US field type is written as its laid-out text.
2805
2873
  const fields=[];
2806
2874
  for(const field of furniturePartFields(part)){
@@ -2816,11 +2884,18 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
2816
2884
  if(marker) staticDates.set(index,marker);
2817
2885
  }
2818
2886
  }
2819
- addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.mutedColor,keepEmpty:true,objectName:`OPF furniture ${slideIndex} part ${index}`,furniture:{group:String(slideIndex),part:manifestPartIndex(layout.parts,index)},liveFields,links:part.links});
2820
- if(natives.has(index))context.nativeFurniture.set(`OPF furniture ${slideIndex} part ${index} line 0`,natives.get(index));
2887
+ addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.mutedColor,keepEmpty:true,objectName:`OPF furniture ${slideIndex} part ${index}`,furniture:{group:String(slideIndex),part:index},liveFields,links:part.links});
2888
+ entry.names.push(...part.fit.lines.map((line, number) => `OPF furniture ${slideIndex} part ${index} line ${number}`));
2889
+ if(natives.has(index)){
2890
+ const name=`OPF furniture ${slideIndex} part ${index} line 0`;
2891
+ context.nativeFurniture.set(name,natives.get(index));
2892
+ // RR-71: a part in a row with other parts is as wide as its text; the master's placeholder spans the zone.
2893
+ const band=context.zoneBand?.(part.zone);
2894
+ if(band&&part.box.width<band.width-.5)context.nativeFurnitureBands.set(name,band);
2895
+ }
2821
2896
  }
2822
2897
  }
2823
- const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates, natives);
2898
+ const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates, natives, authoredFurniture(context, slideIndex));
2824
2899
  if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
2825
2900
  }
2826
2901
 
@@ -3523,7 +3598,7 @@ async function resolveImage(asset, presentation, options, path, outcome = {}) {
3523
3598
  if (raster) return dataUriMediaType(resolved.data) === raster.mediaType ? resolved : {...resolved, data: `data:${raster.mediaType};base64,${bytesToBase64(bytes)}`};
3524
3599
  const svg = svgDataUriBytes(resolved.data);
3525
3600
  if (svg) return resolveSvgImage(svg, options, path, outcome);
3526
- const message = `The image is not a readable PNG, JPEG, GIF or WebP (${dataUriMediaType(resolved.data) ?? "unknown type"}); supply a raster through imageResolver (for example opf-render svgToPng).`;
3601
+ const message = `The image is not a readable PNG, JPEG, GIF or WebP (${dataUriMediaType(resolved.data) ?? "unknown type"}); supply a raster through imageResolver (for example opf-render toPng).`;
3527
3602
  if (options.strictAssets) throw new OPFPptxError("unsupported-image-dimensions", `Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. ${message}`, { path });
3528
3603
  options.onDiagnostic?.({ code: "unresolved-asset", path, reason: "unsupported-format", message });
3529
3604
  outcome.reported = true;
@@ -3730,7 +3805,7 @@ function normalizeHex(value, fallback = "000000") {
3730
3805
  function writeNativeFurnitureMasters(output, context) {
3731
3806
  const size = /<p:sldSz\b[^>]*\bcx="(\d+)"[^>]*\bcy="(\d+)"/.exec(decodeText(output['ppt/presentation.xml'][0]));
3732
3807
  const defaults = new Map((context.defaultFooterOptions ? defaultFooterParts(context.defaultFooterOptions) ?? [] : []).flatMap(part => {
3733
- const ph = {date: 'dt', text: 'ftr', slideNumber: 'sldNum'}[part.field];
3808
+ const ph = nativePlaceholderForPart(part);
3734
3809
  const geometry = ph && defaultPlaceholderGeometry(part);
3735
3810
  return geometry ? [[ph, geometry]] : [];
3736
3811
  }));
@@ -3767,9 +3842,9 @@ async function normalizePptxZip(raw, context) {
3767
3842
  attachQuoteTags(entries,context.quoteTags);
3768
3843
  attachAnnotationTags(entries,context);
3769
3844
  attachFurnitureFields(entries,context.furnitureFields);
3770
- attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests,context.furnitureLogoTags);
3845
+ attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests);
3771
3846
  // RR-11: the recorded footer shapes become PowerPoint's date, footer and slide-number placeholders.
3772
- context.nativePlaceholders = attachNativePlaceholders(entries,context.nativeFurniture);
3847
+ context.nativePlaceholders = attachNativePlaceholders(entries,context.nativeFurniture,context.nativeFurnitureBands);
3773
3848
  // The "Image unavailable" panel of an unresolved logo: identity only, so import does not read it as content.
3774
3849
  attachTextTags(entries,context.logoPlaceholderTags,LOGO_TAG,'opfLogoPlaceholder','logo placeholder');
3775
3850
  for(const [part,bytes]of Object.entries(entries)){
@@ -3873,6 +3948,8 @@ async function normalizePptxZip(raw, context) {
3873
3948
  });
3874
3949
  // A picture repeated across slides embeds once (after fitting, which needs each slide's own relationship).
3875
3950
  dedupeMedia(entries, imageMetadata, resolveRelationshipTarget);
3951
+ // The media keys of the logo-reference pictures, now that the package holds its final media (src/furniture-provenance.js).
3952
+ stampFurnitureImageKeys(entries, context.furnitureManifests, context.furnitureImages, parseRelationships);
3876
3953
  // Charts and notes follow the language and fonts of the slide they belong to.
3877
3954
  context.partSlides = new Map();
3878
3955
  for (const part of Object.keys(entries)) {
@@ -3919,6 +3996,12 @@ async function normalizePptxZip(raw, context) {
3919
3996
  }
3920
3997
  reportPerSlideNotesScriptFonts(context.scriptFonts, slideThemes);
3921
3998
  }
3999
+ // RR-72: furniture drawn the same on several slides moves to the slide master or a layout (src/master-furniture.js).
4000
+ try {
4001
+ liftMasterFurniture(output, context.furnitureParts, diagnostic => context.reportDiagnostic?.(diagnostic));
4002
+ } catch (error) {
4003
+ throw new OPFPptxError('packaging-failed', 'Header and footer furniture could not be written to the slide master and layouts.', {cause: errorMessage(error)});
4004
+ }
3922
4005
  finalizeFontsUsed(output);
3923
4006
  // Document references record evidence from the final normalized parts.
3924
4007
  if (context.documentProvenance) {
@@ -3,13 +3,13 @@ import {normalizeCrop, withoutSvgBlip} from './svg-image.js';
3
3
  import {decodeTextTag, encodeTextTag} from './code-provenance.js';
4
4
  import {pictureTransform} from './image-geometry.js';
5
5
 
6
- // The deck logo (core's `geometry.logo`: design.logo, a slide's own logo or the
7
- // primary organization's logo on a cover or section slide) exports as one native
6
+ // The cover and section logo (core's `geometry.logo`: the primary organization's full logo, or the
7
+ // organization a `design.logo` reference names, RR-71) exports as one native
8
8
  // picture per slide named `OPF logo`, after the watermark and before content. It
9
9
  // is fitted into core's box without cropping, anchored left and vertically
10
10
  // centered (the preview's `xMinYMid meet`). The native tag records the exact
11
11
  // picture, so an unchanged picture is consumed on import (the logo itself returns
12
- // from the document and slide design records) while an edited one stays an
12
+ // from the document record's organization) while an edited one stays an
13
13
  // ordinary picture and reports `invalid-logo-provenance`.
14
14
  const TAG = 'OPF_LOGO_V1', REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
15
15
  const EMUS_PER_INCH = 914400;
@@ -19,8 +19,8 @@ const array = value => value === undefined ? [] : Array.isArray(value) ? value :
19
19
  // Whitespace-only text between elements is not identity: the vendored writer indents, PowerPoint drops it on save.
20
20
  const canonical = value => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
21
21
  ? Object.fromEntries(Object.keys(item).sort().filter(key => !(key === '#text' && typeof item[key] === 'string' && !item[key].trim())).map(key => [key, item[key]])) : item);
22
- // The OPF paths core reports for a logo source.
23
- const LOGO_PATH = /^(?:slides\.\d{1,9}\.design\.logo|design\.logo|organization(?:\.\d{1,9})?\.logo)(?:\.[A-Za-z]{1,16})?$/;
22
+ // The OPF paths core reports for a logo source: the organization's `logo`, then a shape and a tone below it.
23
+ const LOGO_PATH = /^organization(?:\.\d{1,9})?\.logo(?:\.[A-Za-z]{1,16}){0,2}$/;
24
24
 
25
25
  export const LOGO_TAG = TAG;
26
26
  export const logoName = () => 'OPF logo';
@@ -138,10 +138,10 @@ export function importLogoPlaceholders(shapes, relationships, entries, slideInde
138
138
  }
139
139
 
140
140
  /**
141
- * Consume an unchanged tagged logo picture: it is generated from design.logo (or
142
- * the organization's logo), never content. Returns the consumed picture indexes
141
+ * Consume an unchanged tagged logo picture: it is drawn from the organization's
142
+ * logo, never content. Returns the consumed picture indexes
143
143
  * and `fallback`, the picture's own `{path, image}`: the caller uses it for the
144
- * logo only when the stored document and slide design did not restore one (an
144
+ * organization's logo only when the stored document did not restore one (an
145
145
  * export without provenance, or one that omitted the field), so the logo is never
146
146
  * lost. An edited, ambiguous or damaged tagged picture stays an ordinary picture
147
147
  * and is reported. readPicture(picture) returns the ordinary imported payload.