@openpresentation/opf-pptx 0.17.0 → 0.18.1

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
@@ -10,7 +10,7 @@ import {applyChartNumberFormats,chartNumber,excelCode,formattedColumns,inlineCha
10
10
  import {attachDataProvenance,chartDataRecord,chartEvidence,readDataTag,readDatasetsTag,restoreChartData,restoreTableData,tableDataRecord,tableCellEvidence,tableEnvironmentEvidence} from './data-provenance.js';
11
11
  import {CHARTEX_FALLBACK,resolveChartType,chartTypeFromNative,applyChartConstruct,comboFromNative,NATIVE_CHART_ELEMENTS} from './chart-types.js';
12
12
  import {attachChartexParts,chartFromChartex,CHARTEX_GRAPHIC_DATA_URI} from './chartex.js';
13
- import {applyFrameAlt,readFrameAlt} from './frame-alt.js';
13
+ import {applyFrameAlt,readFrameAlt,writeFrameAlt} from './frame-alt.js';
14
14
  import {applyDataLabels,chartOptionsFromClassic,chartTargetFor,classicChartOptions,reportChartOptionDiagnostics,resolveChartOptionsFor} from './chart-options.js';
15
15
  import {attachCodeTags, attachTextTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
16
16
  import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
@@ -20,26 +20,28 @@ import {attachMediaTags,importMediaGroups,mediaCaption,mediaFrameRecord,mediaTex
20
20
  import {addCaption, addFootnotes} from './annotation-export.js';
21
21
  import {attachAnnotationTags, captionValue, importAnnotations, restoreCitations} from './annotation-provenance.js';
22
22
  import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
23
- import {headingValue,joinRichLines} from './rich-heading.js';
23
+ import {cleanBase,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, storedMetadata, DEFAULT_AUTHOR, DEFAULT_TITLE} from './document-provenance.js';
30
+ import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, recordContentTopology, recordListRuns, restoreDocumentProvenance, joinAuthors, splitAuthors, slideListRecord, storedMetadata, DEFAULT_AUTHOR, DEFAULT_TITLE} from './document-provenance.js';
31
31
  import {INVALID_XML_CHARACTER, nativeSections, writeSectionList} from './sections.js';
32
32
  import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
33
33
  import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, nativePlaceholderForPart, writeNativeMasters} from './native-furniture.js';
34
+ import {liftMasterFurniture} from './master-furniture.js';
34
35
  import {importImageOrientation} from './image-import.js';
35
36
  import {extractSignals, normalizeSignalOptions, themeFactsFor} from './import-signals.js';
36
- import {placeImages, importImages, imageName, imageOverlayName, backgroundImageName, backgroundOverlayName, IMAGE_TREATMENT_KEYS, BACKGROUND_IMAGE_KEYS} from './image-provenance.js';
37
+ import {placeImages, importImages, importImagePlaceholders, isImagePlaceholderGroup, imageName, imageOverlayName, imagePlaceholderGroupName, backgroundImageName, backgroundOverlayName, IMAGE_TREATMENT_KEYS, BACKGROUND_IMAGE_KEYS, IMAGE_PLACEHOLDER_TAG} from './image-provenance.js';
37
38
  import {dedupeMedia} from './media-dedupe.js';
38
39
  import {placeWatermarks, importWatermark, importTextWatermark, tagTextWatermarks, watermarkName, watermarkTextName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
39
40
  import {placeLogos, importLogo, importLogoPlaceholders, logoName, LOGO_TAG} from './logo-provenance.js';
40
41
  import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
41
42
  import {importBackground} from './background-import.js';
42
43
  import {chartHighlightPlan, applyChartHighlight} from './chart-highlight.js';
44
+ import {orderChartSeries} from './chart-order.js';
43
45
  import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
44
46
  import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, planSlideThemes, reconcileLanguage, reportPerSlideNotesScriptFonts, runLanguageFonts, runLanguageTag, stripRunScriptFonts, themeEastAsianFromLatin} from './script-fonts.js';
45
47
  import { webpToPng, svgToPng, readLocalFile, localFileReadable } from '#image-fallback';
@@ -254,6 +256,9 @@ export async function toPptx(input, options = {}) {
254
256
  const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
255
257
  context.listMarkers = new Map();
256
258
  context.imagePlaceholders = new Map();
259
+ // opf-pptx#221: the placeholder panels written as one native group, and the OPF_IMAGE_PLACEHOLDER_V1 records of image blocks.
260
+ context.imagePlaceholderGroups = new Set();
261
+ context.imagePlaceholderTags = new Map();
257
262
  context.tableHeaders = new Map();
258
263
  context.tableCells = new Map();
259
264
  // FA-23: every exported image picture (image blocks, the alt-text form of an image background, timeline pictures) and
@@ -278,10 +283,13 @@ export async function toPptx(input, options = {}) {
278
283
  context.quoteTags = new Map();
279
284
  context.quotePhotos = new Map();
280
285
  context.furnitureTags = new Map();
281
- context.furnitureLogoTags = new Map();
286
+ context.furnitureImages = new Map();
282
287
  context.furnitureFields = new Map();
283
288
  context.furnitureManifests = new Map();
284
289
  context.nativeFurniture = new Map();
290
+ context.nativeFurnitureBands = new Map();
291
+ // RR-72: each slide's furniture parts and the object names of their shapes, for the slide master and layouts.
292
+ context.furnitureParts = [];
285
293
  context.hostDate = options.date;
286
294
  context.authored = authored;
287
295
  context.codeTags = new Map();
@@ -408,6 +416,19 @@ export async function fromPptx(input, options = {}) {
408
416
  // Layouts and the master are read once however many slides inherit a placeholder from them (RR-11).
409
417
  const cached = read => { const memo = new Map(); return path => { if (!memo.has(path)) memo.set(path, read(path)); return memo.get(path); }; };
410
418
  const cachedRelationships = cached(path => parseRelationships(entries, path)), cachedPart = cached(path => parseOptionalXml(entries, path));
419
+ // RR-72: the furniture a slide shows from its layout and slide master (src/master-furniture.js), read once per part.
420
+ const inheritedParts = new Map();
421
+ const inheritedPart = path => {
422
+ if (!path) return undefined;
423
+ if (!inheritedParts.has(path)) {
424
+ const element = /slideMaster/.test(path) ? 'p:sldMaster' : 'p:sldLayout', root = cachedPart(path)?.[element];
425
+ const tree = root?.['p:cSld']?.['p:spTree'];
426
+ inheritedParts.set(path, root ? {path, root, relationships: cachedRelationships(path), shapes: nativeTextShapes(tree), pictures: nativePictures(tree),
427
+ paragraphs: nativeShapeParagraphs(decodeText(entries[path]), element)} : undefined);
428
+ }
429
+ return inheritedParts.get(path);
430
+ };
431
+ const relatedPath = (path, type) => path ? [...cachedRelationships(path).values()].find(rel => rel.type === `http://schemas.openxmlformats.org/officeDocument/2006/relationships/${type}` && rel.targetMode !== 'External' && entries[rel.path])?.path : undefined;
411
432
  const furnitureContexts = slidePaths.map((slidePath, index) => {
412
433
  const root = parseRequiredXml(entries, slidePath)['p:sld'];
413
434
  if (!root) throw new OPFPptxError('invalid-pptx', `PPTX slide is not a PresentationML slide: ${slidePath}.`, {path: slidePath});
@@ -417,7 +438,12 @@ export async function fromPptx(input, options = {}) {
417
438
  path: slidePath, slideWidth: Number(presentationRoot['p:sldSz']?.cx), relationshipsOf: cachedRelationships, readPart: cachedPart,
418
439
  paragraphs: nativeShapeParagraphs(decodeText(entries[slidePath])),
419
440
  readPicture: picture => importPicture(entries, picture, slidePath, relationships,
420
- diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${index}.design`}))};
441
+ diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${index}.design`})),
442
+ inherited: () => {
443
+ const layout = relatedPath(slidePath, 'slideLayout'), master = relatedPath(layout, 'slideMaster');
444
+ return Object.fromEntries([['layout', inheritedPart(layout)], ['master', inheritedPart(master)]].filter(([, part]) => part).map(([on, part]) => [on, {...part,
445
+ readPicture: picture => importPicture(entries, picture, part.path, part.relationships, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${index}.design`}))}]));
446
+ }};
421
447
  });
422
448
  // Native sections (PowerPoint's own section list, `Default Section` = none) name each slide's `{{slide.section}}` for the footer text check
423
449
  // and, after the slides, are reconciled with the stored value in restoreDocumentProvenance.
@@ -432,7 +458,7 @@ export async function fromPptx(input, options = {}) {
432
458
  ...(stored.organization !== undefined ? {organization: stored.organization} : {}),
433
459
  ...(stored.speaker !== undefined ? {speaker: stored.speaker} : {}),
434
460
  };
435
- const furniture = importFurniture(furnitureContexts, entries, options.onDiagnostic, {sections: slideSections ?? [], organizations: asArray(stored.organization).filter(item => isPlainObject(item) && typeof item.id === 'string' && typeof item.name === 'string').map(item => ({id: item.id, name: item.name})),
461
+ const furniture = importFurniture(furnitureContexts, entries, options.onDiagnostic, {sections: slideSections ?? [], organizations: asArray(stored.organization).filter(item => isPlainObject(item) && typeof item.id === 'string' && typeof item.name === 'string').map(item => ({id: item.id, name: item.name, ...(typeof item.role === 'string' ? {role: item.role} : {}), ...(logoShape(item.logo) !== undefined ? {logo: logoShape(item.logo)} : {})})),
436
462
  draw: (template, {index, count, section}) => drawFurnitureText(template, {index, count, section}, importedMetadata)});
437
463
  if (Object.keys(furniture.design).length) imported.design = {...imported.design, ...furniture.design};
438
464
  if (furniture.organization) imported.organization = furniture.organization;
@@ -530,21 +556,19 @@ function applyRunColors(imported, slideProvenance, restoredGroups, report, optio
530
556
  restoreRunColors(imported.slides, info, resolve, report);
531
557
  }
532
558
 
533
- // A consumed logo picture (OPF_LOGO_V1) never becomes content. Its own image restores the logo only when nothing else
534
- // did: the stored document and slide design (or the organization) win and carry LogoSet variants, so a package exported
535
- // without provenance still keeps its logo. A slide-level logo path restores slide design; every other path the deck's.
559
+ // A consumed logo picture (OPF_LOGO_V1) never becomes content. Its own image restores the organization's logo only when the
560
+ // stored document did not (RR-71: logos live on the organization), so a package exported without provenance still keeps its
561
+ // logo: on the primary organization, else on a new one named after the deck. The picture is one drawn asset, so it becomes the
562
+ // organization's single logo for every shape.
536
563
  function restoreLogoFallback(imported, contexts) {
537
564
  const organizations = asArray(imported.organization);
538
- contexts.forEach((context, index) => {
539
- const fallback = context.logoFallback;
540
- if (!fallback) return;
541
- const slide = imported.slides[index];
542
- const image = fallback.image;
543
- // A slide's own logo restores whenever the slide states none, whatever the deck logo is; the deck logo only
544
- // when neither it nor the organization has one.
545
- if (fallback.path.startsWith('slides.')) { if (slide.design?.logo === undefined) slide.design = {...slide.design, logo: image}; }
546
- else if (imported.design?.logo === undefined && !organizations.some(item => item?.logo !== undefined)) imported.design = {...imported.design, logo: image};
547
- });
565
+ if (organizations.some(item => item?.logo !== undefined)) return;
566
+ const image = contexts.map(context => context.logoFallback?.image).find(Boolean);
567
+ if (!image) return;
568
+ const primary = organizations.find(item => item?.role === 'primary') ?? organizations[0];
569
+ if (!primary) { imported.organization = {id: 'brand', name: imported.name ?? 'Brand', logo: image}; return; }
570
+ const index = organizations.indexOf(primary), withLogo = {...primary, logo: image};
571
+ imported.organization = Array.isArray(imported.organization) ? imported.organization.map((item, at) => at === index ? withLogo : item) : withLogo;
548
572
  }
549
573
 
550
574
  function readPptxZip(input) {
@@ -752,7 +776,7 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
752
776
  diagnostic => options.onDiagnostic?.({...diagnostic, path: watermarkPath}));
753
777
  if (watermark.design) slide.design = {...slide.design, ...watermark.design};
754
778
  nativeContext.watermarkPictures = watermark.consumed;
755
- // The generated deck logo picture is not content; its value returns from the stored design (or, without one, from the picture).
779
+ // The generated cover logo picture is not content; the logo returns from the stored organization (or, without one, from the picture).
756
780
  const logoPath = `slides.${slideIndex}.design.logo`;
757
781
  const logo = importLogo(nativeContext.pictures, relationships, entries, slideIndex,
758
782
  picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
@@ -795,8 +819,8 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
795
819
  item.text = current.map(paragraph => paragraph.text).join('\n');
796
820
  }
797
821
  }
798
- const listBreaks = slideListBreaks(entries, slidePath, slideRoot, relationships);
799
- const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items, listBreaks, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`})))
822
+ const listRecord = slideListRecord(entries, slidePath, slideRoot, relationships);
823
+ const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items, listRecord.breaks, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`}), listRecord.runs))
800
824
  .map((item) => ({payload: payloadFromSlideItem(item, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`})), bounds: item.visualBounds ?? item.bounds, sources: item.sources ?? []}))
801
825
  .filter((entry) => entry.payload);
802
826
  const blocks = content.map((entry) => entry.payload);
@@ -840,7 +864,8 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
840
864
  const tree = slideRoot["p:cSld"]?.["p:spTree"];
841
865
  const items = [];
842
866
  const shapes = nativeContext.shapes;
843
- if (tree?.['p:grpSp']) options.onDiagnostic?.({code:'grouped-text-reflow',path:`slides.${slideIndex}`,message:'Grouped native text and pictures are retained, but group transforms and unsupported group members are not reconstructed; review the reflowed OPF.'});
867
+ // opf-pptx#221: an exported image placeholder group (identity transform) is not a reflow risk.
868
+ if (asArray(tree?.['p:grpSp']).some(group => !isImagePlaceholderGroup(group))) options.onDiagnostic?.({code:'grouped-text-reflow',path:`slides.${slideIndex}`,message:'Grouped native text and pictures are retained, but group transforms and unsupported group members are not reconstructed; review the reflowed OPF.'});
844
869
  const paragraphs = nativeContext.paragraphs;
845
870
  // FF-45: OMML equations (a14:m math zones) have no OPF model. Their fallback text is imported as plain text; the equation
846
871
  // layout (fractions, radicals, limits, matrices) is lost, and that loss is diagnosed per shape instead of passing silently.
@@ -894,11 +919,17 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
894
919
  for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload,sources:sourcesOf([item.shape])});
895
920
  for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload,sources:sourcesOf([item.shape])});
896
921
  for (const item of media.items) items.push({kind:'media',sourceText:item.payload.type==='text',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload,sources:sourcesOf([item.shape])});
922
+ // opf-pptx#221: a tagged unavailable-image placeholder is its image block again; its panel and label shapes are consumed.
923
+ const placeholders = importImagePlaceholders({shapes, relationships, entries, slideIndex, valid: payload => isValidFormat({slides: [{blocks: [payload]}]}), report: diagnostic => options.onDiagnostic?.(diagnostic)});
924
+ for (const item of placeholders.items) {
925
+ for (const member of item.members) roles.set(`sp:${member}`, 'image');
926
+ items.push({kind: 'image', bounds: shapeBounds(shapes[item.panel]['p:spPr']?.['a:xfrm']), payload: item.payload, sources: item.members.map(member => `sp:${member}`)});
927
+ }
897
928
  for (const [index,shape] of shapes.entries()) {
898
929
  const role = furniture.text.has(index) ? 'furniture' : nativeContext.imageShapes?.has(index) ? 'image-overlay' : nativeContext.watermarkShapes?.has(index) ? 'watermark' : nativeContext.logoPlaceholderShapes?.has(index) ? 'logo' : cards.has(shape) ? 'card-frame'
899
930
  : code.consumed.has(shape) ? 'code' : metric.consumed.has(shape) ? 'metric' : media.consumed.has(shape) ? 'media' : timelines.consumed.has(shape) ? 'timeline' : quotes.consumed.has(shape) ? 'quote' : undefined;
900
931
  if (role) roles.set(shapeKeys.get(shape), role);
901
- if (furniture.text.has(index) || nativeContext.imageShapes?.has(index) || nativeContext.watermarkShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index)) continue;
932
+ if (furniture.text.has(index) || nativeContext.imageShapes?.has(index) || nativeContext.watermarkShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index) || placeholders.consumed.has(index)) continue;
902
933
  if (annotations.consumed.has(shape)) { roles.set(shapeKeys.get(shape), 'annotation'); continue; }
903
934
  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;
904
935
  const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
@@ -1000,7 +1031,7 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
1000
1031
  };
1001
1032
  if (table) {
1002
1033
  const stored = importedTable && record('table');
1003
- const restoredTable = stored ? restore('table', importedTable, () => restoreTableData(importedTable, stored, {evidence: tableCellEvidence(table, relationships), environment: tableEnvironmentEvidence(entries), datasets: data.datasets, report: diagnostic => data.report?.('table', diagnostic)})) : importedTable;
1034
+ const restoredTable = stored ? restore('table', importedTable, () => restoreTableData(importedTable, stored, {evidence: tableCellEvidence(table, relationships), olderEvidence: [tableCellEvidence(table, relationships, {form: 'raw'}), tableCellEvidence(table, relationships, {form: 'saved'})], environment: tableEnvironmentEvidence(entries), datasets: data.datasets, report: diagnostic => data.report?.('table', diagnostic)})) : importedTable;
1004
1035
  // FA-27: the frame's descr (or PowerPoint's decorative marker) is the table's text alternative.
1005
1036
  const alt = restoredTable && typeof restoredTable === 'object' ? readFrameAlt(frame["p:nvGraphicFramePr"]?.["p:cNvPr"]) : undefined;
1006
1037
  return {
@@ -1220,10 +1251,13 @@ function joinRich(a, b) {
1220
1251
  }
1221
1252
  return joined;
1222
1253
  }
1223
- // `breaks` (slideListBreaks) holds the whitespace of the hard breaks the export recorded, by list path and line number: the
1254
+ // `breaks` (slideListRecord) holds the whitespace of the hard breaks the export recorded, by list path and line number: the
1224
1255
  // continuation line after a break joins with it, so a newline inside an item (or a description) returns. A blank line is a
1225
1256
  // native shape with no text: it is never a description. Without a record the lines join without a separator, as before.
1226
- function mergeOpfListShapes(items, breaks = null, report = () => {}) {
1257
+ // `runs` (opf-pptx#212) holds the look export wrote on every run of a list ({text, description?}): a run value still equal to
1258
+ // it is inherited formatting, not authored run style, and is removed (as for a tagged heading), so an untouched round trip
1259
+ // 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) {
1227
1261
  const lists = new Map();
1228
1262
  for (const item of items) {
1229
1263
  const match = /^OPF list (.+) line (\d+)$/.exec(item.name ?? '');
@@ -1236,6 +1270,26 @@ function mergeOpfListShapes(items, breaks = null, report = () => {}) {
1236
1270
  for (const [path, entries] of lists) {
1237
1271
  entries.sort((a, b) => a.line - b.line);
1238
1272
  if (!entries[0].item.paragraphs[0].bullet) continue;
1273
+ const look = runs?.get(path), textLook = cleanBase(look?.text), descriptionLook = cleanBase(look?.description);
1274
+ // 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
+ // 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);
1276
+ // they stay run objects until the lines are joined (as before), then `plain` gives the authored form back.
1277
+ const own = (paragraph, base) => {
1278
+ if (!base) return paragraph.richText;
1279
+ return richRuns(paragraph.richText ?? paragraph.text).flatMap(run => {
1280
+ const value = headingValue([run], base);
1281
+ const parts = value === '' ? [] : typeof value === 'string' ? [{text: value}] : value.map(part => typeof part === 'string' ? {text: part} : part);
1282
+ // A removed colour stays known until the run colour references are restored (restoreRunColors): an authored `text`
1283
+ // role equals the inherited colour natively.
1284
+ if (typeof run === 'object' && typeof run.color === 'string' && parts.length === 1 && parts[0].color === undefined) parts[0]._opfInherited = run.color;
1285
+ return parts;
1286
+ });
1287
+ };
1288
+ const plain = value => {
1289
+ if (!Array.isArray(value)) return value;
1290
+ const runs = value.map(run => typeof run === 'object' && Object.keys(run).length === 1 ? run.text : run);
1291
+ return runs.length === 0 ? '' : runs.length === 1 && typeof runs[0] === 'string' ? runs[0] : runs;
1292
+ };
1239
1293
  const paragraphs = [];
1240
1294
  const recorded = breaks?.get(path), applied = new Set();
1241
1295
  let bounds, size = 0;
@@ -1243,16 +1297,16 @@ function mergeOpfListShapes(items, breaks = null, report = () => {}) {
1243
1297
  const [paragraph] = item.paragraphs, current = paragraphs.at(-1);
1244
1298
  const gap = recorded?.get(line);
1245
1299
  const join = (a, b) => gap === undefined ? joinRich(a, b) : joinWrappedText([a, b], [gap]);
1246
- if (paragraph.bullet) { paragraphs.push({...paragraph}); size = paragraph.maxFontSize; }
1300
+ if (paragraph.bullet) { paragraphs.push({...paragraph, richText: own(paragraph, textLook)}); size = paragraph.maxFontSize; }
1247
1301
  else if (paragraph.text === '') { /* a blank line of the entry: no text, no description */ }
1248
1302
  else if (paragraph.maxFontSize < size - 0.01) {
1249
- const text = paragraph.richText ?? paragraph.text;
1303
+ const text = own(paragraph, descriptionLook) ?? paragraph.text;
1250
1304
  if (current.description === undefined) current.description = text;
1251
1305
  else { current.description = join(current.description, text); applied.add(line); }
1252
1306
  } else {
1253
- const before = current.text;
1307
+ const before = current.text, rich = own(paragraph, textLook);
1254
1308
  current.text += (gap === undefined ? '' : Array.isArray(gap) ? gap[0] : gap) + paragraph.text;
1255
- if (current.richText !== undefined || paragraph.richText !== undefined) current.richText = join(current.richText ?? before, paragraph.richText ?? paragraph.text);
1309
+ if (current.richText !== undefined || rich !== undefined) current.richText = join(current.richText ?? before, rich ?? paragraph.text);
1256
1310
  applied.add(line);
1257
1311
  }
1258
1312
  const b = item.visualBounds ?? item.bounds;
@@ -1263,6 +1317,8 @@ function mergeOpfListShapes(items, breaks = null, report = () => {}) {
1263
1317
  consumed.add(item);
1264
1318
  }
1265
1319
  if (recorded && [...recorded.keys()].some(line => !applied.has(line))) report({code: 'list-line-break-changed', message: `A hard line break recorded at export inside the list ${path} no longer has the continuation line it belonged to (a line was edited away, or became an entry), so that break was not restored; the list keeps the lines it has.`});
1320
+ if (textLook) for (const paragraph of paragraphs) paragraph.richText = plain(paragraph.richText);
1321
+ if (descriptionLook) for (const paragraph of paragraphs) if (paragraph.description !== undefined) paragraph.description = plain(paragraph.description);
1266
1322
  const first = entries[0].item;
1267
1323
  merged.set(first, {...first, paragraphs, text: paragraphs.map(paragraph => paragraph.text).join('\n'), bounds, visualBounds: undefined, sources: entries.flatMap(({item}) => item.sources ?? [])});
1268
1324
  }
@@ -1948,6 +2004,7 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
1948
2004
  const geometry = composeSlide(opfSlide, composeOptions);
1949
2005
  // The default footer band (date left, text center, number right): where a footer added natively lands (RR-11).
1950
2006
  if (slideIndex === 0) context.defaultFooterOptions = composeOptions;
2007
+ slideContext.zoneBand = zoneBands(composeOptions);
1951
2008
  slide.background = { color: slideContext.colors.background };
1952
2009
  // An image background is core's geometry.backgroundImage (slide, deck, then theme background); every other kind is the
1953
2010
  // effective design or theme background definition.
@@ -2008,6 +2065,8 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
2008
2065
  });
2009
2066
  } else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
2010
2067
  await addMeasuredList(slide,item.text,itemContext,item.path,item.bulletImage,presentation,slideIndex,options,item.payload?.numbering!==undefined);
2068
+ // opf-pptx#212: the look every run of the list is written with where its source states none (full provenance only).
2069
+ if(item.textStyle)recordListRuns(context.documentProvenance,slideIndex,item.path,listRunLook(item.text,item.textStyle,itemContext));
2011
2070
  } else if (["text", "title", "subtitle", "tag"].includes(item.field) && item.text?.richLines) {
2012
2071
  // FA-10: a TextRun[] heading draws and exports like rich body text; its lines are named and tagged as a heading (the body's are not).
2013
2072
  const heading = item.field !== 'text';
@@ -2142,6 +2201,14 @@ function addTextPayload(slide, value, region, context) {
2142
2201
  slide.addText(stringifyText(value), textBoxOptions(region, context, 18));
2143
2202
  }
2144
2203
 
2204
+ // opf-pptx#212: the look (richBase) the runs of a measured list are written with where the source states none: {text,
2205
+ // description?}, or undefined when its entries do not share one (import then reads every run value as observed).
2206
+ function listRunLook(fit,style,context){
2207
+ const looks=kind=>new Set(fit.listEntries.filter(entry=>entry[kind]).map(entry=>JSON.stringify(richBase(entry[kind],style,kind==='text'?context.colors.text:context.colors.mutedText))));
2208
+ const text=looks('text'),description=looks('description');
2209
+ if(text.size!==1||description.size>1)return undefined;
2210
+ return {text:JSON.parse([...text][0]),...(description.size?{description:JSON.parse([...description][0])}:{})};
2211
+ }
2145
2212
  function richLineRuns(line,color,native,context) {
2146
2213
  const fallback=color.replace(/^#/,'');
2147
2214
  return line.fragments.map(fragment=>{
@@ -2245,9 +2312,10 @@ function addListPayload(slide, items, region, context) {
2245
2312
  }
2246
2313
 
2247
2314
  // A content picture outside an image block (a timeline event image): one native picture contained in its region.
2248
- async function addImagePayload(slide, presentation, asset, region, path, context, options) {
2315
+ // Generated furniture is never grouped (src/master-furniture.js), so a furniture part passes {group: false}.
2316
+ async function addImagePayload(slide, presentation, asset, region, path, context, options, {group = true} = {}) {
2249
2317
  const name = await addImagePicture(slide, presentation, asset, region, path, context, options, {fit: 'contain'});
2250
- if (!name) addImagePlaceholder(slide, presentation, asset, region, path, context, options);
2318
+ if (!name) addImagePlaceholder(slide, presentation, asset, region, path, context, options, {group});
2251
2319
  return name;
2252
2320
  }
2253
2321
 
@@ -2327,8 +2395,13 @@ async function addImageItem(slide, presentation, item, slideIndex, context, opti
2327
2395
  fit: image.fit, focus: image.focus, effects: treated ? effects : null, shrink: !treated && !image.placement, treatment: tagged ? treatment : undefined
2328
2396
  });
2329
2397
  if (!name) {
2330
- addImagePlaceholder(slide, presentation, item.value, box, item.path, context, options);
2331
- return `OPF image placeholder ${context.imagePlaceholders.size}`;
2398
+ const panel = addImagePlaceholder(slide, presentation, item.value, box, item.path, context, options);
2399
+ // opf-pptx#221: in full mode the panel records the authored image and the block's own fields, so import restores the block.
2400
+ if (context.provenanceMode === 'full') {
2401
+ const fields = Object.fromEntries(IMAGE_TREATMENT_KEYS.filter(key => block[key] !== undefined).map(key => [key, structuredClone(block[key])]));
2402
+ context.imagePlaceholderTags.set(panel, {v: 1, slide: `slides.${slideIndex}`, path: item.path, image: structuredClone(item.value), ...(Object.keys(fields).length ? {treatment: fields} : {})});
2403
+ }
2404
+ return panel;
2332
2405
  }
2333
2406
  if (image.overlay) addImageOverlay(slide, image.overlay, structuredClone(block.overlay), imageOverlayName(name), name, { ...context, slidePath: `slides.${slideIndex}` }, effects);
2334
2407
  return name;
@@ -2809,10 +2882,19 @@ function defaultFooterParts(composeOptions) {
2809
2882
  }
2810
2883
  }
2811
2884
 
2885
+ // The band core gives a lone text part in a zone (x and width in pixels): where a footer added natively sits.
2886
+ function zoneBands(composeOptions) {
2887
+ let bands;
2888
+ return zone => (bands ??= new Map((defaultFooterParts(composeOptions) ?? []).map(part => [part.zone, {x: part.box.x, width: part.box.width}]))).get(zone);
2889
+ }
2890
+
2812
2891
  // The slide and deck as given to toPptx (variables not yet resolved), for the furniture manifest's authored header and footer text.
2813
2892
  const authoredFurniture = (context, slideIndex) => ({presentation: context.authored, slide: context.authored?.slides?.[slideIndex]});
2814
2893
 
2815
2894
  async function addFurniture(slide,presentation,source,layout,context,options,slideIndex) {
2895
+ // RR-72: every slide is listed, a slide without furniture too (it may need a layout that hides the master's).
2896
+ const drawn = [];
2897
+ context.furnitureParts[slideIndex] = {path: `ppt/slides/slide${slideIndex + 1}.xml`, parts: drawn};
2816
2898
  if(!layout){
2817
2899
  if(['header','footer'].some(kind=>(source.design?.[kind]??presentation.design?.[kind])))throw new OPFPptxError('missing-furniture-layout','Header/footer export requires coordinated core furniture geometry.',{path:`slides.${slideIndex}.design`});
2818
2900
  const manifest = furnitureManifest(presentation, source, {parts: []}, slideIndex, undefined, undefined, authoredFurniture(context, slideIndex));
@@ -2823,12 +2905,18 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
2823
2905
  // RR-11: the first footer text, date and slide number that fit one line are native placeholders (src/native-furniture.js).
2824
2906
  const natives = nativeFurnitureParts(layout);
2825
2907
  for(const [index,part]of layout.parts.entries()){
2908
+ const entry = {index, kind: part.kind, zone: part.zone, field: part.field, type: part.type, path: part.path, native: natives.has(index), names: []};
2909
+ drawn.push(entry);
2826
2910
  if(part.type==='image'){
2827
2911
  const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
2828
- const objectName = await addImagePayload(slide,presentation,part.image,region,part.path,context,options);
2829
- // A generated deck logo is listed beside the manifest topology (see furnitureManifest) and tagged as a logo.
2830
- if (objectName && part.field === 'logo') context.furnitureLogoTags.set(objectName, {v:1, role:'furniture', group:String(slideIndex), furniture:part.kind, zone:part.zone});
2831
- else if (objectName) context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:manifestPartIndex(layout.parts, index)});
2912
+ // A logo reference draws the organization's asset: a source that cannot be drawn is reported where the asset is written.
2913
+ const objectName = await addImagePayload(slide,presentation,part.image,region,part.reference!==undefined?part.sourcePath:part.path,context,options,{group:false});
2914
+ if (objectName) {
2915
+ entry.names.push(objectName);
2916
+ context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:index});
2917
+ // RR-71: a part drawn from a logo reference (var:organization.logo.icon) records its picture, so import can tell it is unchanged.
2918
+ if (part.reference !== undefined) context.furnitureImages.set(`ppt/slides/slide${slideIndex + 1}.xml|${part.kind}.${part.zone}`, objectName);
2919
+ }
2832
2920
  }else{
2833
2921
  if(!part.fit?.sourceLines)throw new OPFPptxError('missing-furniture-layout','Repeated text requires accepted source lines from core.',{path:part.path});
2834
2922
  // Slide numbers ({{slide.number}}) and current dates become native PowerPoint fields; {{deck.slideCount}},
@@ -2848,8 +2936,15 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
2848
2936
  if(marker) staticDates.set(index,marker);
2849
2937
  }
2850
2938
  }
2851
- addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.mutedColor,keepEmpty:true,objectName:`OPF furniture ${slideIndex} part ${index}`,furniture:{group:String(slideIndex),part:manifestPartIndex(layout.parts,index)},liveFields,links:part.links});
2852
- if(natives.has(index))context.nativeFurniture.set(`OPF furniture ${slideIndex} part ${index} line 0`,natives.get(index));
2939
+ 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});
2940
+ entry.names.push(...part.fit.lines.map((line, number) => `OPF furniture ${slideIndex} part ${index} line ${number}`));
2941
+ if(natives.has(index)){
2942
+ const name=`OPF furniture ${slideIndex} part ${index} line 0`;
2943
+ context.nativeFurniture.set(name,natives.get(index));
2944
+ // RR-71: a part in a row with other parts is as wide as its text; the master's placeholder spans the zone.
2945
+ const band=context.zoneBand?.(part.zone);
2946
+ if(band&&part.box.width<band.width-.5)context.nativeFurnitureBands.set(name,band);
2947
+ }
2853
2948
  }
2854
2949
  }
2855
2950
  const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates, natives, authoredFurniture(context, slideIndex));
@@ -3087,9 +3182,10 @@ function placeholderAsset(asset, presentation) {
3087
3182
  // minimum) in the readable text colour for the panel, or a cross when the label
3088
3183
  // cannot fit even at that minimum. The panel carries the accessible name the
3089
3184
  // preview gives its group, "Image unavailable: <description>".
3090
- // {logo: true} names the panel's other shapes after its panel (OPF image placeholder N text line i / icon i) and returns the
3091
- // panel's name, so a logo placeholder can be tagged and consumed on import.
3092
- function addImagePlaceholder(slide, presentation, asset, region, path, context, options, {logo = false} = {}) {
3185
+ // The panel's other shapes are named after it (OPF image placeholder N text line i / icon i) and the panel's name is returned,
3186
+ // so a logo placeholder can be tagged and consumed on import. opf-pptx#221: unless it is a logo's ({logo: true}) or furniture's
3187
+ // ({group: false}) placeholder, packaging writes the panel and those shapes as one native group carrying the accessible name.
3188
+ function addImagePlaceholder(slide, presentation, asset, region, path, context, options, {logo = false, group = !logo} = {}) {
3093
3189
  const layered = placeholderAsset(asset, presentation);
3094
3190
  const description = String(layered.alt ?? layered.title ?? "Image");
3095
3191
  const label = `Image unavailable\n${description}`;
@@ -3110,6 +3206,8 @@ function addImagePlaceholder(slide, presentation, asset, region, path, context,
3110
3206
  const fit = fitText(label, inner, 20 * scale, minimum, measurer);
3111
3207
  const objectName = `OPF image placeholder ${context.imagePlaceholders.size + 1}`;
3112
3208
  context.imagePlaceholders.set(objectName, `Image unavailable: ${description}`);
3209
+ if (group) context.imagePlaceholderGroups.add(objectName);
3210
+ const named = logo || group;
3113
3211
  slide.addShape("rect", {
3114
3212
  x: region.x, y: region.y, w: region.w, h: region.h,
3115
3213
  fill: { color: surface },
@@ -3123,18 +3221,18 @@ function addImagePlaceholder(slide, presentation, asset, region, path, context,
3123
3221
  const centered = { ...inner, y: inner.y + offset };
3124
3222
  addMeasuredPayloadText(slide, label, centered, context, options, {
3125
3223
  path, fit: fitText(label, centered, 20 * scale, minimum, measurer), textStyle: style,
3126
- diagnosticsHandled: true, align: "center", color: textColor, ...(logo ? {objectName: `${objectName} text`} : {})
3224
+ diagnosticsHandled: true, align: "center", color: textColor, ...(named ? {objectName: `${objectName} text`} : {})
3127
3225
  });
3128
- return logo ? objectName : undefined;
3226
+ return objectName;
3129
3227
  }
3130
3228
  // A status indicator, not shortened authored content: the full description stays
3131
3229
  // in the panel's accessible name.
3132
3230
  const size = Math.max(0, Math.min(inner.width, inner.height, 24 * scale));
3133
- if (!size) return logo ? objectName : undefined;
3231
+ if (!size) return objectName;
3134
3232
  const icon = { x: inner.x + (inner.width - size) / 2, y: inner.y + (inner.height - size) / 2 };
3135
3233
  const line = { color: textColor, width: Math.min(2 * scale, size / 8) * .75 };
3136
- for (const flipV of [false, true]) slide.addShape("line", { x: icon.x / 96, y: icon.y / 96, w: size / 96, h: size / 96, line, flipV, ...(logo ? {objectName: `${objectName} icon ${flipV ? 2 : 1}`} : {}) });
3137
- return logo ? objectName : undefined;
3234
+ for (const flipV of [false, true]) slide.addShape("line", { x: icon.x / 96, y: icon.y / 96, w: size / 96, h: size / 96, line, flipV, ...(named ? {objectName: `${objectName} icon ${flipV ? 2 : 1}`} : {}) });
3235
+ return objectName;
3138
3236
  }
3139
3237
 
3140
3238
  // A placeholder names what is missing in plain words. It never dumps the source value, so no data or URL lands in slide text.
@@ -3555,7 +3653,7 @@ async function resolveImage(asset, presentation, options, path, outcome = {}) {
3555
3653
  if (raster) return dataUriMediaType(resolved.data) === raster.mediaType ? resolved : {...resolved, data: `data:${raster.mediaType};base64,${bytesToBase64(bytes)}`};
3556
3654
  const svg = svgDataUriBytes(resolved.data);
3557
3655
  if (svg) return resolveSvgImage(svg, options, path, outcome);
3558
- const message = `The image is not a readable PNG, JPEG, GIF or WebP (${dataUriMediaType(resolved.data) ?? "unknown type"}); supply a raster through imageResolver (for example opf-render svgToPng).`;
3656
+ const message = `The image is not a readable PNG, JPEG, GIF or WebP (${dataUriMediaType(resolved.data) ?? "unknown type"}); supply a raster through imageResolver (for example opf-render toPng).`;
3559
3657
  if (options.strictAssets) throw new OPFPptxError("unsupported-image-dimensions", `Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. ${message}`, { path });
3560
3658
  options.onDiagnostic?.({ code: "unresolved-asset", path, reason: "unsupported-format", message });
3561
3659
  outcome.reported = true;
@@ -3799,11 +3897,13 @@ async function normalizePptxZip(raw, context) {
3799
3897
  attachQuoteTags(entries,context.quoteTags);
3800
3898
  attachAnnotationTags(entries,context);
3801
3899
  attachFurnitureFields(entries,context.furnitureFields);
3802
- attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests,context.furnitureLogoTags);
3900
+ attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests);
3803
3901
  // RR-11: the recorded footer shapes become PowerPoint's date, footer and slide-number placeholders.
3804
- context.nativePlaceholders = attachNativePlaceholders(entries,context.nativeFurniture);
3902
+ context.nativePlaceholders = attachNativePlaceholders(entries,context.nativeFurniture,context.nativeFurnitureBands);
3805
3903
  // The "Image unavailable" panel of an unresolved logo: identity only, so import does not read it as content.
3806
3904
  attachTextTags(entries,context.logoPlaceholderTags,LOGO_TAG,'opfLogoPlaceholder','logo placeholder');
3905
+ // opf-pptx#221: the image block of an unavailable-image placeholder, on its panel (full provenance).
3906
+ attachTextTags(entries,context.imagePlaceholderTags,IMAGE_PLACEHOLDER_TAG,'opfImagePlaceholder','image placeholder');
3807
3907
  for(const [part,bytes]of Object.entries(entries)){
3808
3908
  if(!/^ppt\/slides\/slide\d+\.xml$/.test(part))continue;
3809
3909
  const relationships=parseRelationships(entries,part);
@@ -3836,6 +3936,12 @@ async function normalizePptxZip(raw, context) {
3836
3936
  }
3837
3937
  }
3838
3938
  }
3939
+ // opf-pptx#175: after every chart rewrite, each classic series in its CT_*Ser sequence (src/chart-order.js).
3940
+ for(const part of Object.keys(entries)){
3941
+ if(!/^ppt\/charts\/chart\d+\.xml$/.test(part))continue;
3942
+ const xml=decodeText(entries[part]),ordered=orderChartSeries(xml);
3943
+ if(ordered!==xml)entries[part]=encodeText(ordered);
3944
+ }
3839
3945
  applyChartFonts(entries,context.chartFonts,parseRelationships);
3840
3946
  if (context.frameAlts.size) for (const [part, bytes] of Object.entries(entries)) if (/^ppt\/slides\/slide\d+\.xml$/.test(part)) { const xml = decodeText(bytes), next = applyFrameAlt(xml, context.frameAlts); if (next !== xml) entries[part] = encodeText(next); }
3841
3947
  // Chartex charts: the native cx:chartSpace part, its style parts and the mc:AlternateContent frame (the classic chart above is the fallback).
@@ -3905,6 +4011,8 @@ async function normalizePptxZip(raw, context) {
3905
4011
  });
3906
4012
  // A picture repeated across slides embeds once (after fitting, which needs each slide's own relationship).
3907
4013
  dedupeMedia(entries, imageMetadata, resolveRelationshipTarget);
4014
+ // The media keys of the logo-reference pictures, now that the package holds its final media (src/furniture-provenance.js).
4015
+ stampFurnitureImageKeys(entries, context.furnitureManifests, context.furnitureImages, parseRelationships);
3908
4016
  // Charts and notes follow the language and fonts of the slide they belong to.
3909
4017
  context.partSlides = new Map();
3910
4018
  for (const part of Object.keys(entries)) {
@@ -3951,6 +4059,12 @@ async function normalizePptxZip(raw, context) {
3951
4059
  }
3952
4060
  reportPerSlideNotesScriptFonts(context.scriptFonts, slideThemes);
3953
4061
  }
4062
+ // RR-72: furniture drawn the same on several slides moves to the slide master or a layout (src/master-furniture.js).
4063
+ try {
4064
+ liftMasterFurniture(output, context.furnitureParts, diagnostic => context.reportDiagnostic?.(diagnostic));
4065
+ } catch (error) {
4066
+ throw new OPFPptxError('packaging-failed', 'Header and footer furniture could not be written to the slide master and layouts.', {cause: errorMessage(error)});
4067
+ }
3954
4068
  finalizeFontsUsed(output);
3955
4069
  // Document references record evidence from the final normalized parts.
3956
4070
  if (context.documentProvenance) {
@@ -4172,12 +4286,18 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
4172
4286
  });
4173
4287
  // PptxGenJS gives shapes no alternative text. An unavailable image's panel
4174
4288
  // carries the accessible name the preview gives its group.
4289
+ const escapes={'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;','\r':'&#13;','\n':'&#10;','\t':'&#9;'};
4290
+ const describe=description=>description.replace(/[&<>"'\r\n\t]/g,char=>escapes[char]);
4175
4291
  xml=xml.replace(/<p:cNvPr id="(\d+)" name="(OPF image placeholder \d+)"(\/?)>/g,(node,id,name,close)=>{
4176
4292
  const description=context.imagePlaceholders.get(name);
4177
4293
  if(description===undefined)return node;
4178
- const escapes={'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;','\r':'&#13;','\n':'&#10;','\t':'&#9;'};
4179
- return `<p:cNvPr id="${id}" name="${name}" descr="${description.replace(/[&<>"'\r\n\t]/g,char=>escapes[char])}"${close}>`;
4294
+ // opf-pptx#221: a grouped placeholder's accessible name is on its group (below), as the preview names its <g>. The empty panel
4295
+ // then has no alt text of its own, so it is marked decorative (the adec:decorative extension, "Mark as decorative"), or
4296
+ // PowerPoint's Accessibility Checker flags it. The text lines carry text and are not marked. PptxGenJS writes this cNvPr self-closing.
4297
+ if(context.imagePlaceholderGroups.has(name))return close?writeFrameAlt(node,''):node;
4298
+ return `<p:cNvPr id="${id}" name="${name}" descr="${describe(description)}"${close}>`;
4180
4299
  });
4300
+ xml=groupImagePlaceholders(xml,context.imagePlaceholderGroups,name=>describe(context.imagePlaceholders.get(name)));
4181
4301
  // PptxGenJS 4.0.1 emitted pPr before each rich run (pptxgenjs-plus writes
4182
4302
  // one). OOXML allows one pPr, before all runs. Paragraph options belong
4183
4303
  // to the first run.
@@ -4205,6 +4325,34 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
4205
4325
  // their explicit per-shape buFont; only the master bodyStyle is touched.
4206
4326
  const VENDOR_MASTER_BULLET_FONT = '<a:buFont typeface="Arial" pitchFamily="34" charset="0"/>';
4207
4327
  const THEME_MINOR_BULLET_FONT = '<a:buFont typeface="+mn-lt"/>';
4328
+ // opf-pptx#221: each unavailable-image placeholder (panel, then its label lines or cross strokes, written consecutively) becomes
4329
+ // one p:grpSp (CT_GroupShape: nvGrpSpPr, grpSpPr, members) named "<panel> group" with the accessible name as its descr. Its
4330
+ // transform is the identity (chOff/chExt equal off/ext, the members' union), so every member keeps its box and stays an
4331
+ // editable shape; tags and names stay on the members.
4332
+ function groupImagePlaceholders(xml,panels,description){
4333
+ if(!panels.size)return xml;
4334
+ let next=Math.max(0,...[...xml.matchAll(/<p:cNvPr\b[^>]*\bid="(\d+)"/g)].map(match=>Number(match[1])))+1;
4335
+ const nameOf=shape=>shape.match(/<p:cNvPr\b[^>]*\bname="([^"]*)"/)?.[1]??'';
4336
+ const memberOf=(panel,name)=>name.startsWith(`${panel} `)&&/^(?:text line|icon) \d+$/.test(name.slice(panel.length+1));
4337
+ return xml.replace(/<p:sp>[\s\S]*?<\/p:sp>(?:<p:sp>[\s\S]*?<\/p:sp>)*/g,run=>{
4338
+ const shapes=run.match(/<p:sp>[\s\S]*?<\/p:sp>/g);
4339
+ let out='';
4340
+ for(let index=0;index<shapes.length;index++){
4341
+ const panel=nameOf(shapes[index]);
4342
+ if(!panels.has(panel)){out+=shapes[index];continue;}
4343
+ const members=[shapes[index]];
4344
+ while(index+1<shapes.length&&memberOf(panel,nameOf(shapes[index+1])))members.push(shapes[++index]);
4345
+ const boxes=members.map(shape=>shape.match(/<a:off x="(-?\d+)" y="(-?\d+)"\/><a:ext cx="(\d+)" cy="(\d+)"\/>/)).filter(Boolean).map(match=>match.slice(1).map(Number));
4346
+ if(!boxes.length){out+=members.join('');continue;}
4347
+ const x=Math.min(...boxes.map(box=>box[0])),y=Math.min(...boxes.map(box=>box[1]));
4348
+ const cx=Math.max(...boxes.map(box=>box[0]+box[2]))-x,cy=Math.max(...boxes.map(box=>box[1]+box[3]))-y;
4349
+ out+=`<p:grpSp><p:nvGrpSpPr><p:cNvPr id="${next++}" name="${imagePlaceholderGroupName(panel)}" descr="${description(panel)}"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>`+
4350
+ `<p:grpSpPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${cx}" cy="${cy}"/><a:chOff x="${x}" y="${y}"/><a:chExt cx="${cx}" cy="${cy}"/></a:xfrm></p:grpSpPr>${members.join('')}</p:grpSp>`;
4351
+ }
4352
+ return out;
4353
+ });
4354
+ }
4355
+
4208
4356
  function themeMasterBulletFonts(xml) {
4209
4357
  return xml.replace(/<p:bodyStyle>[\s\S]*?<\/p:bodyStyle>/, bodyStyle =>
4210
4358
  bodyStyle.split(VENDOR_MASTER_BULLET_FONT).join(THEME_MINOR_BULLET_FONT));
@@ -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.