@openpresentation/opf-pptx 0.11.5 → 0.11.7

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
@@ -5,7 +5,7 @@ import {applyChartFonts, applyPitchFamilies, finalizeFontsUsed, fontPitchFamilie
5
5
  import {readChartCategoryHeading,writeChartCategoryHeading} from './chart-workbook.js';
6
6
  import {CHARTEX_FALLBACK,resolveChartType,chartTypeFromNative,applyChartConstruct,NATIVE_CHART_ELEMENTS} from './chart-types.js';
7
7
  import {attachChartexParts,chartFromChartex,CHARTEX_GRAPHIC_DATA_URI} from './chartex.js';
8
- import {attachCodeTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
8
+ import {attachCodeTags, attachTextTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
9
9
  import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
10
10
  import {attachCardTags,importCardFrames} from './card-provenance.js';
11
11
  import {attachMediaTags,importMediaGroups,mediaCaption,mediaFrameRecord,mediaTextFingerprint} from './media-provenance.js';
@@ -13,13 +13,15 @@ import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
13
13
  import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
14
14
  import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
15
15
  import {attachQuoteTags,quoteManifest,importQuoteGroups} from './quote-provenance.js';
16
- import {attachFurnitureTags, furnitureManifest, importFurniture, staticDateFallback} from './furniture-provenance.js';
17
- import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, restoreDocumentProvenance} from './document-provenance.js';
16
+ import {attachFurnitureTags, furnitureManifest, importFurniture, manifestPartIndex, staticDateFallback} from './furniture-provenance.js';
17
+ import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, recordContentTopology, restoreDocumentProvenance} from './document-provenance.js';
18
+ import {INVALID_XML_CHARACTER, nativeSections, writeSectionList} from './sections.js';
18
19
  import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
19
20
  import {importImageOrientation} from './image-import.js';
20
21
  import {placeSlideImages, importSlideImage, slideImageName, slideImageOverlayName} from './slide-image-provenance.js';
21
22
  import {dedupeMedia} from './media-dedupe.js';
22
23
  import {placeWatermarks, importWatermark, watermarkName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
24
+ import {placeLogos, importLogo, importLogoPlaceholders, logoName, LOGO_TAG} from './logo-provenance.js';
23
25
  import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
24
26
  import {importBackground} from './background-import.js';
25
27
  import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
@@ -27,7 +29,7 @@ import {languageDiagnostics, observeLanguage, partScriptFonts, planScriptFonts,
27
29
  import { webpToPng } from '#image-fallback';
28
30
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
29
31
  import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
30
- import { colorContext, resolveExportColor, resolveVariableColors } from "./color-ref.js";
32
+ import { colorContext, resolveColorRefValue, resolveExportColor, resolveVariableColors } from "./color-ref.js";
31
33
  import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
32
34
  import { unzipSync, zipSync } from "fflate";
33
35
  import { XMLParser } from "fast-xml-parser";
@@ -209,8 +211,12 @@ export async function toPptx(input, options = {}) {
209
211
  context.tableHeaders = new Map();
210
212
  context.tableCells = new Map();
211
213
  context.imagePlacements = new Map();
214
+ context.pictureText = new Map();
212
215
  context.slideImages = new Map();
213
216
  context.watermarks = new Map();
217
+ context.logos = new Map();
218
+ context.logoPlaceholderTags = new Map();
219
+ context.bulletImages = new Map();
214
220
  context.backgroundFills = new Map();
215
221
  context.notesWithCarriageReturns = new Map();
216
222
  context.cardTags = new Map();
@@ -221,6 +227,7 @@ export async function toPptx(input, options = {}) {
221
227
  context.timelineTags = new Map();
222
228
  context.quoteTags = new Map();
223
229
  context.furnitureTags = new Map();
230
+ context.furnitureLogoTags = new Map();
224
231
  context.furnitureFields = new Map();
225
232
  context.furnitureManifests = new Map();
226
233
  context.codeTags = new Map();
@@ -229,12 +236,13 @@ export async function toPptx(input, options = {}) {
229
236
  context.chartFonts = new Map();
230
237
  context.chartex = new Map();
231
238
  context.imageFormat = options.imageFormat ?? "compatible";
232
- // Native chartex parts are opt-in until the native PowerPoint check confirms them (FF-22b); the default keeps the
233
- // clustered column export of the chartex chart types.
234
- if (options.chartex !== undefined && !['native', 'fallback'].includes(options.chartex)) {
235
- throw new OPFPptxError('invalid-chartex-mode', 'chartex must be native or fallback.', {path: 'options.chartex'});
239
+ // Chartex export mode (FF-22b): 'auto' (default) writes native chartex parts for the constructs the native PowerPoint
240
+ // check confirmed (treemap, histogram, pareto, box-and-whisker, waterfall, funnel) and the clustered column fallback
241
+ // for the unconfirmed map; 'native' writes every chartex part; 'fallback' writes clustered columns only.
242
+ if (options.chartex !== undefined && !['auto', 'native', 'fallback'].includes(options.chartex)) {
243
+ throw new OPFPptxError('invalid-chartex-mode', 'chartex must be auto, native or fallback.', {path: 'options.chartex'});
236
244
  }
237
- context.chartexMode = options.chartex ?? 'fallback';
245
+ context.chartexMode = options.chartex ?? 'auto';
238
246
  Object.assign(context, exportTheme(presentation, context));
239
247
  context.reportedFontSchemes = new Set();
240
248
  // Document references and metadata tags (FF-32, docs/document-roundtrip.md).
@@ -244,6 +252,14 @@ export async function toPptx(input, options = {}) {
244
252
  report: diagnostic => options.onDiagnostic?.(diagnostic)
245
253
  });
246
254
  context.scriptFonts = planScriptFonts(presentation, options.onDiagnostic);
255
+ // Slide section labels become PowerPoint's native section list (src/sections.js).
256
+ // A label is an XML attribute: the characters text runs reject are rejected here too.
257
+ context.sections = presentation.slides.map((slide, index) => {
258
+ if (typeof slide.section !== 'string') return undefined;
259
+ const invalid = INVALID_XML_CHARACTER.exec(slide.section);
260
+ if (invalid) throw new OPFPptxError('invalid-text', `Text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4, '0')} at UTF-16 offset ${invalid.index}, which DrawingML XML cannot represent.`, {path: `slides.${index}.section`});
261
+ return slide.section;
262
+ });
247
263
  context.masterBackground = masterBackground(context);
248
264
  context.linkSentinels = linkSentinels(presentation);
249
265
  const pptx = new PptxGenJS();
@@ -325,6 +341,9 @@ export async function fromPptx(input, options = {}) {
325
341
  furnitureContexts[index].mediaRegistry = mediaRegistry;
326
342
  imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options, furniture.slides[index], furnitureContexts[index]));
327
343
  }
344
+ // Native sections (PowerPoint's own section list, `Default Section` = none)
345
+ // are reconciled with the footer text and the stored value in restoreDocumentProvenance.
346
+ const slideSections = nativeSections(presentationRoot, slideIdsInOrder(presentationRoot, presentationRels, entries, slidePaths));
328
347
  // A watermark carried identically by every slide is the deck's design.watermark.
329
348
  const carried = imported.slides.map(slide => slide.design?.watermark);
330
349
  if (carried.length && carried[0] && carried.every(value => JSON.stringify(value) === JSON.stringify(carried[0]))) {
@@ -351,7 +370,8 @@ export async function fromPptx(input, options = {}) {
351
370
  const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true,
352
371
  // Host catalogs format stored socials exactly as export did (FF-34).
353
372
  socialPlatformRecords: catalogs => socialPlatformRecords({catalogs}, options),
354
- slides: slidePaths.map((path, index) => ({path, root: furnitureContexts[index].root, relationships: furnitureContexts[index].relationships}))}, report);
373
+ nativeSections: slideSections,
374
+ slides: slidePaths.map((path, index) => ({path, root: furnitureContexts[index].root, relationships: furnitureContexts[index].relationships, contentBounds: furnitureContexts[index].contentBounds}))}, report);
355
375
  // The stored language wins while the runs still carry its tag (FF-07).
356
376
  restored.groups = reconcileLanguage(restored.groups, observedLanguage, report);
357
377
  imported = applyDocumentProvenance(imported, restored, validatePresentation, report);
@@ -360,6 +380,7 @@ export async function fromPptx(input, options = {}) {
360
380
  report({code: "invalid-document-provenance", path: "", message: `${errorMessage(error)} Ordinary import keeps the values observed in the PPTX.`});
361
381
  }
362
382
  if (slideProvenance.length !== imported.slides.length) throw new OPFPptxError("invalid-import-opf", "Slide provenance does not match the imported slides.");
383
+ restoreLogoFallback(imported, furnitureContexts);
363
384
  if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
364
385
  languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
365
386
 
@@ -374,6 +395,23 @@ export async function fromPptx(input, options = {}) {
374
395
  return imported;
375
396
  }
376
397
 
398
+ // A consumed logo picture (OPF_LOGO_V1) never becomes content. Its own image restores the logo only when nothing else
399
+ // did: the stored document and slide design (or the organization) win and carry LogoSet variants, so a package exported
400
+ // without provenance still keeps its logo. A slide-level logo path restores slide design; every other path the deck's.
401
+ function restoreLogoFallback(imported, contexts) {
402
+ const organizations = asArray(imported.organization);
403
+ contexts.forEach((context, index) => {
404
+ const fallback = context.logoFallback;
405
+ if (!fallback) return;
406
+ const slide = imported.slides[index];
407
+ const image = fallback.image;
408
+ // A slide's own logo restores whenever the slide states none, whatever the deck logo is; the deck logo only
409
+ // when neither it nor the organization has one.
410
+ if (fallback.path.startsWith('slides.')) { if (slide.design?.logo === undefined) slide.design = {...slide.design, logo: image}; }
411
+ else if (imported.design?.logo === undefined && !organizations.some(item => item?.logo !== undefined)) imported.design = {...imported.design, logo: image};
412
+ });
413
+ }
414
+
377
415
  function readPptxZip(input) {
378
416
  let bytes;
379
417
  if (input instanceof Uint8Array) {
@@ -472,6 +510,17 @@ function resolveSlidePaths(entries, presentationRoot, relationships) {
472
510
  .sort(compareSlidePaths);
473
511
  }
474
512
 
513
+ // The `p:sldId` ids of the imported slides, aligned with `slidePaths`; empty
514
+ // when the slides were listed from the package rather than `p:sldIdLst`.
515
+ function slideIdsInOrder(presentationRoot, relationships, entries, slidePaths) {
516
+ const ids = [];
517
+ for (const slideId of asArray(presentationRoot["p:sldIdLst"]?.["p:sldId"])) {
518
+ const relationship = relationships.get(slideId?.["r:id"]);
519
+ if (relationship?.type.endsWith("/slide") && entries[relationship.path]) ids.push(String(slideId?.id ?? ""));
520
+ }
521
+ return ids.length === slidePaths.length ? ids : slidePaths.map(() => "");
522
+ }
523
+
475
524
  function compareSlidePaths(left, right) {
476
525
  return slideNumber(left) - slideNumber(right) || (left < right ? -1 : left > right ? 1 : 0);
477
526
  }
@@ -534,6 +583,16 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
534
583
  diagnostic => options.onDiagnostic?.({...diagnostic, path: watermarkPath}));
535
584
  if (watermark.design) slide.design = {...slide.design, ...watermark.design};
536
585
  nativeContext.watermarkPictures = watermark.consumed;
586
+ // The generated deck logo picture is not content; its value returns from the stored design (or, without one, from the picture).
587
+ const logoPath = `slides.${slideIndex}.design.logo`;
588
+ const logo = importLogo(nativeContext.pictures, relationships, entries, slideIndex,
589
+ picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
590
+ if (diagnostic.code !== 'unsupported-image-crop') options.onDiagnostic?.({...diagnostic, path: logoPath});
591
+ }),
592
+ diagnostic => options.onDiagnostic?.({...diagnostic, path: logoPath}));
593
+ nativeContext.logoPictures = logo.consumed;
594
+ nativeContext.logoPlaceholderShapes = importLogoPlaceholders(nativeContext.shapes, relationships, entries, slideIndex);
595
+ nativeContext.logoFallback = logo.fallback;
537
596
 
538
597
  const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex, furniture, nativeContext)
539
598
  .sort(comparePositionedItems);
@@ -557,10 +616,13 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
557
616
  item.text = current.map(paragraph => paragraph.text).join('\n');
558
617
  }
559
618
  }
560
- const blocks = mergeAdjacentBulletShapes(items)
561
- .map((item) => payloadFromSlideItem(item))
562
- .filter(Boolean);
619
+ const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items))
620
+ .map((item) => ({payload: payloadFromSlideItem(item), bounds: item.visualBounds ?? item.bounds}))
621
+ .filter((entry) => entry.payload);
622
+ const blocks = content.map((entry) => entry.payload);
563
623
  if (blocks.length > 0) slide.blocks = blocks;
624
+ // Native bounds of each block in reference px, for content topology matching (document provenance).
625
+ 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);
564
626
 
565
627
  const notes = readSlideNotes(entries, relationships);
566
628
  if (notes) slide.notes = notes;
@@ -610,7 +672,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
610
672
  for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
611
673
  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});
612
674
  for (const [index,shape] of shapes.entries()) {
613
- if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index)) continue;
675
+ if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index)) continue;
614
676
  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;
615
677
  const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
616
678
  const item = importShape(shape, dimensions, paragraphs[index], ordinaryBody || furniture.taggedText.has(index) || media.captionShapes.has(shape));
@@ -641,7 +703,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
641
703
  }
642
704
 
643
705
  for (const [index, picture] of nativeContext.pictures.entries()) {
644
- if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index) || nativeContext.watermarkPictures?.has(index)) continue;
706
+ if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index) || nativeContext.watermarkPictures?.has(index) || nativeContext.logoPictures?.has(index)) continue;
645
707
  const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
646
708
  const item = importPicture(entries, picture, slidePath, relationships, report);
647
709
  if (item) items.push(item);
@@ -718,10 +780,23 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
718
780
  };
719
781
  }
720
782
 
783
+ // The box a quarter-turned picture occupies on the slide: `a:xfrm` describes the
784
+ // frame before rotation, so a 90 or 270 degree turn swaps its width and height
785
+ // around the same centre. Content topology matching compares this visual box.
786
+ function visualBounds(xfrm) {
787
+ const bounds = shapeBounds(xfrm);
788
+ const rotation = Number(xfrm?.rot ?? 0);
789
+ if (!bounds || !Number.isFinite(rotation) || ((rotation % 10800000) + 10800000) % 10800000 !== 5400000) return bounds;
790
+ return { x: bounds.x + bounds.w / 2 - bounds.h / 2, y: bounds.y + bounds.h / 2 - bounds.w / 2, w: bounds.h, h: bounds.w };
791
+ }
792
+
721
793
  function importPicture(entries, picture, slidePath, relationships, report) {
722
794
  const bounds = shapeBounds(picture["p:spPr"]?.["a:xfrm"]);
723
795
  const name = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.name).trim();
724
- const alt = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.descr);
796
+ // PptxGenJS (and so exports before this fix) wrote `preencoded.<ext>` for a picture with no alt text; that is no authored description.
797
+ const authoredAlt = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.descr);
798
+ const alt = /^preencoded\.[a-z0-9]+$/i.test(authoredAlt.trim()) ? "" : authoredAlt;
799
+ const title = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.title);
725
800
  const relId = picture["p:blipFill"]?.["a:blip"]?.["r:embed"];
726
801
  const relationship = relationships.get(relId);
727
802
  let bytes = relationship?.path ? entries[relationship.path] : null;
@@ -743,12 +818,14 @@ function importPicture(entries, picture, slidePath, relationships, report) {
743
818
  return {
744
819
  kind: "image",
745
820
  bounds,
821
+ visualBounds: visualBounds(picture["p:spPr"]?.["a:xfrm"]),
746
822
  name,
747
823
  payload: {
748
824
  type: "image",
749
825
  image: {
750
826
  src: `data:${rasterMetadata(bytes)?.mediaType ?? mediaTypeForPath(relationship.path)};base64,${bytesToBase64(bytes)}`,
751
- ...(alt ? { alt } : {})
827
+ ...(alt ? { alt } : {}),
828
+ ...(title && alt ? { title } : {})
752
829
  }
753
830
  }
754
831
  };
@@ -771,7 +848,7 @@ function readParagraphs(txBody) {
771
848
  }
772
849
  return {
773
850
  text: texts.join(""),
774
- bullet: asArray(paragraph?.["a:pPr"]).some(props => props?.["a:buChar"] !== undefined || props?.["a:buAutoNum"] !== undefined),
851
+ bullet: asArray(paragraph?.["a:pPr"]).some(props => props?.["a:buChar"] !== undefined || props?.["a:buBlip"] !== undefined || props?.["a:buAutoNum"] !== undefined),
775
852
  level: Number(asArray(paragraph?.["a:pPr"])[0]?.lvl ?? 0),
776
853
  maxFontSize: sizes.length > 0 ? Math.max(...sizes) : 0
777
854
  };
@@ -828,6 +905,59 @@ function takeSubtitleItem(items, titleItem, dimensions, inferHeadings) {
828
905
  return null;
829
906
  }
830
907
 
908
+ // The export writes every native line of a list as its own shape named "OPF list <path> line N". The lines of one list
909
+ // import as one list: a bulleted line starts an entry, an unbulleted line of the entry's own size continues it (a wrapped
910
+ // entry) and a smaller one is its description. Without a bulleted first line the shapes stay as they are.
911
+ const richRuns = value => typeof value === 'string' ? [{text: value}] : value;
912
+ function joinRich(a, b) {
913
+ if (typeof a === 'string' && typeof b === 'string') return a + b;
914
+ const runs = [...richRuns(a), ...richRuns(b)], joined = [];
915
+ for (const run of runs) {
916
+ const last = joined.at(-1);
917
+ if (last && JSON.stringify({...last, text: ''}) === JSON.stringify({...run, text: ''})) last.text += run.text;
918
+ else joined.push({...run});
919
+ }
920
+ return joined;
921
+ }
922
+ function mergeOpfListShapes(items) {
923
+ const lists = new Map();
924
+ for (const item of items) {
925
+ const match = /^OPF list (.+) line (\d+)$/.exec(item.name ?? '');
926
+ if (match && item.kind === 'text' && item.paragraphs?.length === 1) {
927
+ if (!lists.has(match[1])) lists.set(match[1], []);
928
+ lists.get(match[1]).push({item, line: Number(match[2])});
929
+ }
930
+ }
931
+ const merged = new Map(), consumed = new Set();
932
+ for (const [path, entries] of lists) {
933
+ entries.sort((a, b) => a.line - b.line);
934
+ if (!entries[0].item.paragraphs[0].bullet) continue;
935
+ const paragraphs = [];
936
+ let bounds, size = 0;
937
+ for (const {item} of entries) {
938
+ const [paragraph] = item.paragraphs, current = paragraphs.at(-1);
939
+ if (paragraph.bullet) { paragraphs.push({...paragraph}); size = paragraph.maxFontSize; }
940
+ else if (paragraph.maxFontSize < size - 0.01) {
941
+ const text = paragraph.richText ?? paragraph.text;
942
+ current.description = current.description === undefined ? text : joinRich(current.description, text);
943
+ } else {
944
+ const before = current.text;
945
+ current.text += paragraph.text;
946
+ if (current.richText !== undefined || paragraph.richText !== undefined) current.richText = joinRich(current.richText ?? before, paragraph.richText ?? paragraph.text);
947
+ }
948
+ const b = item.visualBounds ?? item.bounds;
949
+ if (b) {
950
+ const x = Math.min(bounds?.x ?? b.x, b.x), y = Math.min(bounds?.y ?? b.y, b.y);
951
+ bounds = {x, y, w: Math.max((bounds ? bounds.x + bounds.w : -Infinity), b.x + b.w) - x, h: Math.max((bounds ? bounds.y + bounds.h : -Infinity), b.y + b.h) - y};
952
+ }
953
+ consumed.add(item);
954
+ }
955
+ const first = entries[0].item;
956
+ merged.set(first, {...first, paragraphs, text: paragraphs.map(paragraph => paragraph.text).join('\n'), bounds, visualBounds: undefined});
957
+ }
958
+ return items.flatMap(item => merged.has(item) ? [merged.get(item)] : consumed.has(item) ? [] : [item]);
959
+ }
960
+
831
961
  // Adjacent native bullet boxes on the same text column form one imported list.
832
962
  // This is a geometry heuristic, not a lossless reconstruction of arbitrary PPTX.
833
963
  function mergeAdjacentBulletShapes(items) {
@@ -855,8 +985,8 @@ function payloadFromSlideItem(item) {
855
985
  type: "list",
856
986
  items: item.paragraphs.map((paragraph) => (
857
987
  paragraph.level > 0
858
- ? { text: paragraph.richText ?? paragraph.text, level: paragraph.level }
859
- : (paragraph.richText ?? paragraph.text)
988
+ ? { text: paragraph.richText ?? paragraph.text, level: paragraph.level, ...(paragraph.description !== undefined ? {description: paragraph.description} : {}) }
989
+ : paragraph.description !== undefined ? { text: paragraph.richText ?? paragraph.text, description: paragraph.description } : (paragraph.richText ?? paragraph.text)
860
990
  ))
861
991
  };
862
992
  }
@@ -1158,7 +1288,8 @@ function resolvePresentationContext(presentation, options) {
1158
1288
  const fontSchemeId = referenceId(design.fontScheme ?? theme?.fontScheme);
1159
1289
  const unresolvedFontScheme = fontSchemeId && !findById(normalizeRecords(presentation.catalogs?.fontSchemes), fontSchemeId) && !findById(defaultCatalog("fontSchemes"), fontSchemeId) ? fontSchemeId : null;
1160
1290
  const dimensions = resolveDimensions(design.dimensions ?? theme?.dimensions);
1161
- const background = resolveBackground(design.background ?? theme?.background, colorScheme);
1291
+ const variables = resolveVariableColors(presentation.variables);
1292
+ const background = resolveBackground(design.background ?? theme?.background, colorScheme, variables);
1162
1293
  const fonts = resolveFonts(fontScheme);
1163
1294
  for (const role of ["heading","body","code"]) fonts[role] = resolveTextStyle({fontFamily:fonts[role],fontWeight:role === "heading" ? 700 : 400},options.textMeasurement).fontFamily;
1164
1295
  const textColor = readableTextColor(background, colorScheme);
@@ -1184,7 +1315,7 @@ function resolvePresentationContext(presentation, options) {
1184
1315
  surface: normalizeHex(colorScheme.surface ?? (darkBackground ? colorScheme.dark2 : colorScheme.light2) ?? "#F8FAFC"),
1185
1316
  border: normalizeHex(colorScheme.accent5 ?? "#CBD5E1")
1186
1317
  },
1187
- variables: resolveVariableColors(presentation.variables)
1318
+ variables
1188
1319
  };
1189
1320
  }
1190
1321
 
@@ -1207,7 +1338,23 @@ function nativeColor(reference, hex, context, fallback) {
1207
1338
 
1208
1339
  // FF-59: the slide tag is the eyebrow label. It is written in the deck primary color, the scheme accent1 slot the
1209
1340
  // preview draws it from: a:schemeClr accent1 where the deck theme holds that exact color, the literal otherwise.
1341
+ // FF-61: unless that primary is under 4.5:1 (WCAG 2.x) against the slide background, in which case the tag takes the
1342
+ // slide text color, the value the title and body text get (the scheme text slot where the theme holds it). opf-render
1343
+ // applies the same rule with the same arithmetic (test/tag-colour.mjs in both repositories pins the same color pairs).
1344
+ const TAG_MIN_CONTRAST = 4.5;
1345
+ function relativeLuminance(hex) {
1346
+ const [red, green, blue] = [0, 2, 4].map(offset => {
1347
+ const value = Number.parseInt(normalizeHex(hex).slice(offset, offset + 2), 16) / 255;
1348
+ return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
1349
+ });
1350
+ return red * 0.2126 + green * 0.7152 + blue * 0.0722;
1351
+ }
1352
+ function contrastRatio(first, second) {
1353
+ const a = relativeLuminance(first), b = relativeLuminance(second);
1354
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
1355
+ }
1210
1356
  function tagColor(context) {
1357
+ if (contrastRatio(context.colors.accent, context.colors.background) < TAG_MIN_CONTRAST) return context.textColor;
1211
1358
  return nativeColor('primary', context.colors.accent, context);
1212
1359
  }
1213
1360
 
@@ -1313,14 +1460,15 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1313
1460
  const backgroundPath = `${opfSlide.design?.background !== undefined ? `slides.${slideIndex}.` : ''}design.background`;
1314
1461
  const backgroundFill = schemeBackgroundFill(backgroundDefinition, slideContext) ?? nativeBackgroundFill(backgroundDefinition, {
1315
1462
  width: slideContext.dimensions.widthInches, height: slideContext.dimensions.heightInches
1316
- }, slideContext.colors.background, slideContext.colors.text, (reference, hex) => schemeColorValue(reference, hex, slideContext));
1463
+ }, slideContext.colors.background, slideContext.colors.text, (reference, hex) => schemeColorValue(reference, hex, slideContext), reference => resolveBackgroundColorRef(reference, slideContext.colorScheme, slideContext.variables));
1317
1464
  if (backgroundFill) context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, backgroundFill);
1318
1465
  if (backgroundDefinition?.type === 'pattern' && !nativePatternPreset(backgroundDefinition.pattern?.preset)) {
1319
1466
  options.onDiagnostic?.({code: 'unsupported-pattern', path: `${backgroundPath}.pattern.preset`, message: `Pattern ${backgroundDefinition.pattern?.preset} has no DrawingML preset; only its background color was exported.`});
1320
1467
  }
1321
1468
  if (backgroundDefinition?.type === 'image') {
1322
1469
  const imagePath = `${backgroundPath}.image`;
1323
- const resolved = await resolveImage(backgroundDefinition.image, presentation, options, imagePath);
1470
+ const outcome = {};
1471
+ const resolved = await resolveImage(backgroundDefinition.image, presentation, options, imagePath, outcome);
1324
1472
  if (resolved) {
1325
1473
  // PptxGenJS embeds the raster and its relationship; packaging replaces
1326
1474
  // its stretched fill with the fitted native picture fill.
@@ -1329,7 +1477,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1329
1477
  fit: backgroundDefinition.image?.fit ?? 'cover', opacity: backgroundDefinition.opacity ?? 1, imageFill: slideContext.imageFill, path: imagePath,
1330
1478
  width: slideContext.dimensions.widthInches * 96, height: slideContext.dimensions.heightInches * 96, report: options.onDiagnostic
1331
1479
  } });
1332
- } else {
1480
+ } else if (!outcome.reported) {
1333
1481
  options.onDiagnostic?.({code: 'unresolved-asset', path: imagePath, message: 'The background image needs an embedded raster, a declared asset or a host imageResolver; the slide background color was exported instead.'});
1334
1482
  }
1335
1483
  }
@@ -1349,11 +1497,15 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1349
1497
  // Core composition resolves one alignment per composed item for every engine
1350
1498
  // (FF-29); the design fallback keeps cores published before item.alignment working.
1351
1499
  const alignmentFor=item=>item.alignment??fieldAlignment(item.field);
1352
- const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, presentation, slideIndex, fonts: slideContext.fonts, contentAlignment, titleAlignment, textRasterPadding:options.textRasterPadding, contentBox:opfSlide.design?.contentBox??presentation.design?.contentBox, textMeasurement: options.textMeasurement, date: options.date, socialPlatforms: socialPlatformRecords(presentation, options) });
1500
+ const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, presentation, slideIndex, fonts: slideContext.fonts, contentAlignment, titleAlignment, textRasterPadding:options.textRasterPadding, contentBox:opfSlide.design?.contentBox??presentation.design?.contentBox, darkBackground: isDarkHex(slideContext.colors.background), textMeasurement: options.textMeasurement, date: options.date, socialPlatforms: socialPlatformRecords(presentation, options) });
1353
1501
  for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
1502
+ // The content topology (groups, regions, root form, block ids) with the leaf boxes this geometry draws.
1503
+ recordContentTopology(context.documentProvenance, opfSlide, slideIndex, geometry.items);
1354
1504
  // The preview paints background, slide image (and its overlay), then design.watermark, then content.
1355
1505
  if (geometry.slideImage) await addSlideImage(slide, presentation, geometry.slideImage, slideIndex, slideContext, options);
1356
1506
  await addWatermark(slide, presentation, opfSlide, slideIndex, slideContext, context, options);
1507
+ // Cover and section slides: the deck logo core composed at the top-left of the free area, after the watermark and before content.
1508
+ if (geometry.logo) await addLogo(slide, presentation, geometry.logo, slideIndex, slideContext, context, options);
1357
1509
  for (const item of geometry.items) {
1358
1510
  // Card text sits on a literal card fill, not the slide background: keep it literal.
1359
1511
  const itemContext = item.frameBox ? {...slideContext, textColor: slideContext.colors.text, mutedColor: slideContext.colors.mutedText} : slideContext;
@@ -1383,7 +1535,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1383
1535
  breakLine: false
1384
1536
  });
1385
1537
  } else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
1386
- addMeasuredList(slide,item.text,itemContext,item.path);
1538
+ await addMeasuredList(slide,item.text,itemContext,item.path,item.bulletImage,presentation,slideIndex,options);
1387
1539
  } else if (item.field === "text" && item.text?.richLines) {
1388
1540
  const alignment=item.text.placement?.alignment??alignmentFor(item)??'left';
1389
1541
  for(const [index,line] of item.text.richLines.entries()){
@@ -1521,7 +1673,25 @@ function richLineRuns(line,color,native,context) {
1521
1673
  // the other measured text shapes. A reader maps lines to their list by name, not
1522
1674
  // by geometry, so lines of an overflowing list that fall below its box still
1523
1675
  // belong to it.
1524
- function addMeasuredList(slide,fit,context,path) {
1676
+ // design.listBullet: image (core's item.bulletImage, the deck's icon logo) makes every entry marker a native picture bullet.
1677
+ // PptxGenJS embeds the media and its relationship through one picture per slide (OPF bullet image); packaging removes
1678
+ // that picture and points each marker paragraph at the relationship with a:buBlip. An icon that cannot be embedded
1679
+ // keeps the character bullets, as the preview does.
1680
+ async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,slideIndex,options) {
1681
+ let picture=false;
1682
+ if(bulletImage){
1683
+ const part=`ppt/slides/slide${slideIndex+1}.xml`;
1684
+ if(context.bulletImages.has(part))picture=context.bulletImages.get(part);
1685
+ else{
1686
+ const outcome={};
1687
+ const resolved=await resolveImage(bulletImage.source,presentation,options,bulletImage.path,outcome);
1688
+ if(resolved){
1689
+ slide.addImage({...resolved,objectName:bulletImageName(),x:0,y:0,w:.1,h:.1,altText:''});
1690
+ picture=true;
1691
+ }else if(!outcome.reported)options.onDiagnostic?.({code:'unresolved-asset',path:bulletImage.path,message:'A picture bullet needs an embedded raster, a declared asset or a host imageResolver; the character bullets were exported instead.'});
1692
+ context.bulletImages.set(part,picture);
1693
+ }
1694
+ }
1525
1695
  let lineNumber=0;
1526
1696
  for(const entry of fit.listEntries){
1527
1697
  const addLines=(text,box,color,native,withBullet)=>{
@@ -1529,7 +1699,7 @@ function addMeasuredList(slide,fit,context,path) {
1529
1699
  const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
1530
1700
  const region={x:(box.x-inset)/96,y:(box.y+line.y)/96,w:(box.width+inset)/96,h:line.height/96};
1531
1701
  const objectName=`OPF list ${path} line ${lineNumber++}`;
1532
- if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:pptxColor(context.textColor)});
1702
+ if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:pptxColor(context.textColor),picture});
1533
1703
  const paragraph=first?{bullet:{characterCode:entry.marker.text.codePointAt(0).toString(16).padStart(4,'0'),indent:entry.marker.indent*.75},indentLevel:level}:{bullet:false};
1534
1704
  const runs=richLineRuns(line,color,native,context);
1535
1705
  if(!runs.length)runs.push({text:'',options:{}});
@@ -1591,6 +1761,7 @@ async function addImagePayload(slide, presentation, asset, region, path, context
1591
1761
  }
1592
1762
  const objectName = `OPF image ${context.imagePlacements.size + 1}`;
1593
1763
  context.imagePlacements.set(objectName, { region, mode: context.imageFill, path });
1764
+ context.pictureText.set(objectName, pictureText(assetAlt(asset, presentation), asset, presentation));
1594
1765
  slide.addImage({
1595
1766
  ...resolved,
1596
1767
  objectName,
@@ -1618,9 +1789,10 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
1618
1789
  notExported('The watermark needs an image source (a string, or an object with src); no watermark was exported for this slide.');
1619
1790
  return;
1620
1791
  }
1621
- const resolved = await resolveImage(asset, presentation, options, path);
1792
+ const outcome = {};
1793
+ const resolved = await resolveImage(asset, presentation, options, path, outcome);
1622
1794
  if (!resolved) {
1623
- options.onDiagnostic?.({code: 'unresolved-asset', path, message: 'The watermark image needs an embedded raster, a declared asset or a host imageResolver; no watermark was exported for this slide.'});
1795
+ if (!outcome.reported) options.onDiagnostic?.({code: 'unresolved-asset', path, message: 'The watermark image needs an embedded raster, a declared asset or a host imageResolver; no watermark was exported for this slide.'});
1624
1796
  return;
1625
1797
  }
1626
1798
  const {widthInches, heightInches} = slideContext.dimensions;
@@ -1630,6 +1802,24 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
1630
1802
  slide.addImage({...resolved, objectName: watermarkName(), ...box, altText: assetAlt(asset, presentation) ?? 'Watermark'});
1631
1803
  }
1632
1804
 
1805
+ // The deck logo (design.logo, a slide's own logo or the primary organization's) on a cover or section slide: one native
1806
+ // picture per slide at core's geometry.logo box, fitted without cropping, anchored left and vertically centered, after
1807
+ // the watermark and before content (the preview's paint order). The frame and the provenance tag are written after
1808
+ // PptxGenJS embeds the bytes. An unresolved source draws the preview's "Image unavailable" panel in the logo box.
1809
+ async function addLogo(slide, presentation, logo, slideIndex, slideContext, context, options) {
1810
+ const region = {x: logo.box.x / 96, y: logo.box.y / 96, w: logo.box.width / 96, h: logo.box.height / 96};
1811
+ const outcome = {};
1812
+ const resolved = await resolveImage(logo.source, presentation, options, logo.path, outcome);
1813
+ if (!resolved) {
1814
+ if (!outcome.reported) options.onDiagnostic?.({code: 'unresolved-asset', path: logo.path, message: 'The logo needs an embedded raster, a declared asset or a host imageResolver; the logo panel shows "Image unavailable" instead.'});
1815
+ const prefix = addImagePlaceholder(slide, presentation, logo.source, region, logo.path, slideContext, options, {logo: true});
1816
+ if (prefix) context.logoPlaceholderTags.set(prefix, {v: 1, role: 'placeholder', slide: `slides.${slideIndex}`, path: logo.path});
1817
+ return;
1818
+ }
1819
+ context.logos.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, box: region, path: logo.path, variant: logo.variant});
1820
+ slide.addImage({...resolved, objectName: logoName(), ...region, altText: assetAlt(logo.source, presentation) ?? 'Logo'});
1821
+ }
1822
+
1633
1823
  // design.slideImage: one native picture at the shared frame, beneath content.
1634
1824
  // Crop/fit and treatments are written after PptxGenJS embeds the bytes.
1635
1825
  async function addSlideImage(slide, presentation, image, slideIndex, context, options) {
@@ -1659,7 +1849,9 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
1659
1849
  // FF-53: a root `image` with the slide image's source is the slide image (core `replacesContent`); the
1660
1850
  // manifest records that so an unchanged picture imports back as both design.slideImage and slide.image.
1661
1851
  context.slideImages.set(objectName, { slide: `slides.${slideIndex}`, box, fill: image.fill, path: image.sourcePath, treatment: { ...treatment, position: image.position }, effects, content: image.replacesContent === true });
1662
- slide.addImage({ ...resolved, objectName, ...box, altText: image.alt ?? assetAlt(image.value, presentation) });
1852
+ const slideImageAlt = image.alt ?? assetAlt(image.value, presentation);
1853
+ context.pictureText.set(objectName, pictureText(slideImageAlt, image.value, presentation));
1854
+ slide.addImage({ ...resolved, objectName, ...box, altText: slideImageAlt });
1663
1855
  // The overlay scrim is a separate native shape directly above the picture.
1664
1856
  if (effects.overlay) {
1665
1857
  const overlay = effects.overlay.box;
@@ -1667,6 +1859,21 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
1667
1859
  }
1668
1860
  }
1669
1861
 
1862
+ // FF-62: chart text is one size. The preview draws every chart label (axis, legend, data label) at
1863
+ // max(14, composition.minFontSize ?? 16) px scaled by the slide's shorter side over 720 px (renderer charts.js, `fontPx`),
1864
+ // the readability floor: 12 pt on a 13.33 x 7.5 in slide. The export writes that size, in hundredths of a point (DrawingML `sz`).
1865
+ function chartTextSize(context) {
1866
+ const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
1867
+ return Math.round(Math.max(14, context.composition?.minFontSize ?? 16) * scale * 75);
1868
+ }
1869
+
1870
+ // Every c:txPr of a generated classic chart carries the chart text size: PptxGenJS writes no size on the legend (PowerPoint's
1871
+ // 18 pt default) and hardcodes 18 pt on pie and doughnut data labels. Only the size changes; faces and colours stay as written.
1872
+ function applyChartTextSize(xml, size) {
1873
+ return xml.replace(/<c:txPr>[\s\S]*?<\/c:txPr>/g, properties => properties.replace(/<a:defRPr\b([^>]*?)(\/?)>/g, (match, attributes, close) =>
1874
+ `<a:defRPr${/\bsz="[^"]*"/.test(attributes) ? attributes.replace(/\bsz="[^"]*"/, `sz="${size}"`) : `${attributes} sz="${size}"`}${close}>`));
1875
+ }
1876
+
1670
1877
  function addChartPayload(slide, chart, region, context, options = {}, path = "chart") {
1671
1878
  const chartData = toPptxChartData(chart, context.chartexMode);
1672
1879
  if (!chartData.series) {
@@ -1685,16 +1892,17 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1685
1892
  const fill = {color:normalizeHex(panelFill),transparency};
1686
1893
  const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
1687
1894
  const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
1688
- context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor,spec:chartData.spec});
1895
+ const textSize = chartTextSize(context);
1896
+ context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor,spec:chartData.spec,textSize});
1689
1897
  context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
1690
1898
  const palette = CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`)));
1691
1899
  if (chartData.chartex) {
1692
1900
  // The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
1693
1901
  // PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
1694
1902
  const series = chartData.series.map((entry) => ({name: entry.name, labels: [...entry.labels], values: [...entry.values]}));
1695
- context.chartex.set(objectName, {spec: chartData.chartex, series, hasCategories: chartData.hasCategories, fill, labelColor, gridColor: context.colors.border, font: context.fonts.body, palette});
1903
+ context.chartex.set(objectName, {spec: chartData.chartex, series, hasCategories: chartData.hasCategories, fill, labelColor, gridColor: context.colors.border, font: context.fonts.body, palette, textSize});
1696
1904
  if (chartData.chartex.layoutId === 'regionMap') {
1697
- options.onDiagnostic?.({code: "chart-map-geodata", path, message: `The '${stringifyText(chart.type)}' chart is exported as a native PowerPoint map (chartex regionMap) without cached geography: PowerPoint matches the category names to regions and fetches their shapes from Bing Maps when the deck is opened online, so the map shows no regions offline or where Office map data is disabled. The clustered column fallback shows the same values in other readers.`});
1905
+ options.onDiagnostic?.({code: "chart-map-geodata", path, message: `The '${stringifyText(chart.type)}' chart is exported as a native PowerPoint map (chartex regionMap) without cached geography (no cx:geoCache; provider data is never fabricated): PowerPoint must fetch the region shapes from its online map service when the deck is opened, and until it does it shows "There was a problem getting the information for your map chart" and draws nothing. The clustered column fallback shows the same values in readers without chartex support.`});
1698
1906
  }
1699
1907
  }
1700
1908
  const percent = chartData.spec.grouping === 'percentStacked';
@@ -1711,14 +1919,16 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1711
1919
  // Paint alpha once in the chart area, rather than stacking two alpha fills.
1712
1920
  plotArea: {fill:{color:null}},
1713
1921
  catAxisLabelFontFace: context.fonts.body,
1714
- catAxisLabelFontSize: 9,
1922
+ catAxisLabelFontSize: textSize / 100,
1715
1923
  catAxisLabelColor: labelColor,
1716
1924
  valAxisLabelFontFace: context.fonts.body,
1717
- valAxisLabelFontSize: 9,
1925
+ valAxisLabelFontSize: textSize / 100,
1718
1926
  valAxisLabelColor: labelColor,
1719
1927
  legendColor: labelColor,
1720
1928
  legendFontFace: context.fonts.body,
1929
+ legendFontSize: textSize / 100,
1721
1930
  dataLabelColor: labelColor,
1931
+ dataLabelFontSize: textSize / 100,
1722
1932
  showValue: false,
1723
1933
  valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
1724
1934
  barDir: chartData.barDir,
@@ -1892,7 +2102,9 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
1892
2102
  if(part.type==='image'){
1893
2103
  const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
1894
2104
  const objectName = await addImagePayload(slide,presentation,part.image,region,part.path,{...context,imageFill:'fit'},options);
1895
- if (objectName) context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:index});
2105
+ // A generated deck logo is listed beside the manifest topology (see furnitureManifest) and tagged as a logo.
2106
+ if (objectName && part.field === 'logo') context.furnitureLogoTags.set(objectName, {v:1, role:'furniture', group:String(slideIndex), furniture:part.kind, zone:part.zone});
2107
+ else if (objectName) context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:manifestPartIndex(layout.parts, index)});
1896
2108
  }else{
1897
2109
  if(!part.fit?.sourceLines)throw new OPFPptxError('missing-furniture-layout','Repeated text requires accepted source lines from core.',{path:part.path});
1898
2110
  // Slide numbers and current dates become native PowerPoint fields; {total}
@@ -1912,13 +2124,15 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
1912
2124
  if(marker) staticDates.set(index,marker);
1913
2125
  }
1914
2126
  }
1915
- 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});
2127
+ 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});
1916
2128
  }
1917
2129
  }
1918
2130
  const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates);
1919
2131
  if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
1920
2132
  }
1921
2133
 
2134
+ const bulletImageName = () => 'OPF bullet image';
2135
+
1922
2136
  function addCodePayload(slide, value, layout, region, context, path) {
1923
2137
  if (!layout) throw new OPFPptxError('missing-code-layout', 'Code export requires a coordinated core build with shared code geometry.', {path});
1924
2138
  for (const part of layout.parts) {
@@ -2054,7 +2268,9 @@ function placeholderAsset(asset, presentation) {
2054
2268
  // minimum) in the readable text colour for the panel, or a cross when the label
2055
2269
  // cannot fit even at that minimum. The panel carries the accessible name the
2056
2270
  // preview gives its group, "Image unavailable: <description>".
2057
- function addImagePlaceholder(slide, presentation, asset, region, path, context, options) {
2271
+ // {logo: true} names the panel's other shapes after its panel (OPF image placeholder N text line i / icon i) and returns the
2272
+ // panel's name, so a logo placeholder can be tagged and consumed on import.
2273
+ function addImagePlaceholder(slide, presentation, asset, region, path, context, options, {logo = false} = {}) {
2058
2274
  const layered = placeholderAsset(asset, presentation);
2059
2275
  const description = String(layered.alt ?? layered.title ?? "Image");
2060
2276
  const label = `Image unavailable\n${description}`;
@@ -2088,17 +2304,18 @@ function addImagePlaceholder(slide, presentation, asset, region, path, context,
2088
2304
  const centered = { ...inner, y: inner.y + offset };
2089
2305
  addMeasuredPayloadText(slide, label, centered, context, options, {
2090
2306
  path, fit: fitText(label, centered, 20 * scale, minimum, measurer), textStyle: style,
2091
- diagnosticsHandled: true, align: "center", color: textColor
2307
+ diagnosticsHandled: true, align: "center", color: textColor, ...(logo ? {objectName: `${objectName} text`} : {})
2092
2308
  });
2093
- return;
2309
+ return logo ? objectName : undefined;
2094
2310
  }
2095
2311
  // A status indicator, not shortened authored content: the full description stays
2096
2312
  // in the panel's accessible name.
2097
2313
  const size = Math.max(0, Math.min(inner.width, inner.height, 24 * scale));
2098
- if (!size) return;
2314
+ if (!size) return logo ? objectName : undefined;
2099
2315
  const icon = { x: inner.x + (inner.width - size) / 2, y: inner.y + (inner.height - size) / 2 };
2100
2316
  const line = { color: textColor, width: Math.min(2 * scale, size / 8) * .75 };
2101
- 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 });
2317
+ 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}`} : {}) });
2318
+ return logo ? objectName : undefined;
2102
2319
  }
2103
2320
 
2104
2321
  // A placeholder names what is missing in plain words. It never dumps the source value, so no data or URL lands in slide text.
@@ -2232,7 +2449,7 @@ function textRuns(value, context, fallbackFontSize) {
2232
2449
  * into equal-width bins and exported as a column chart of the counts, every other type plots the values against
2233
2450
  * their row numbers. `adapted` names that transformation so it is reported, never silent.
2234
2451
  */
2235
- function toPptxChartData(chart, chartexMode = 'fallback') {
2452
+ function toPptxChartData(chart, chartexMode = 'auto') {
2236
2453
  const data = chart?.data;
2237
2454
  const unplottable = (reason, summary, message) => ({reason, summary, message: `${message} No native chart was exported; a placeholder frame stands in for it.`});
2238
2455
  if (!data || !Array.isArray(data.columns) || !Array.isArray(data.rows)) {
@@ -2241,17 +2458,21 @@ function toPptxChartData(chart, chartexMode = 'fallback') {
2241
2458
  if (data.rows.length === 0) return unplottable("no-rows", "The chart has no data rows.", "The chart data has no rows.");
2242
2459
  if (data.columns.length === 0) return unplottable("no-columns", "The chart has no data columns.", "The chart data has no columns.");
2243
2460
  const resolved = resolveChartType(chart.type).spec;
2244
- // A chartex type (treemap, histogram, pareto, box & whisker, waterfall, funnel, map) has no classic construct. With
2245
- // `chartex: 'native'` it is written as its cx:chartSpace part and the clustered column chart of the same data is its
2246
- // mc:Fallback (src/chartex.js); by default (pending the native PowerPoint check, FF-22b) it exports as the clustered
2247
- // column chart alone and reports chartex-fallback, never silently.
2248
- const native = chartexMode === 'native';
2461
+ // A chartex type (treemap, histogram, pareto, box & whisker, waterfall, funnel, map) has no classic construct. Natively
2462
+ // it is written as its cx:chartSpace part and the clustered column chart of the same data is its mc:Fallback
2463
+ // (src/chartex.js). 'auto' (the default) does that for the constructs the native PowerPoint check confirmed and keeps
2464
+ // the unconfirmed map on the clustered column chart alone; 'fallback' keeps every chartex id there. The fallback is
2465
+ // reported as chartex-fallback, never silent.
2466
+ const native = chartexMode === 'native' || (chartexMode === 'auto' && resolved.family === 'chartex' && !resolved.unconfirmed);
2249
2467
  const chartex = resolved.family === 'chartex' && native ? resolved : null;
2250
2468
  const spec = resolved.family === 'chartex' ? CHARTEX_FALLBACK : resolved;
2251
2469
  const typeName = stringifyText(chart.type);
2252
2470
  const adaptations = [];
2253
2471
  if (resolved.family === 'chartex' && !native) {
2254
- adaptations.push({adaptation: "chartex-fallback", message: `The '${typeName}' chart is a PowerPoint extension (chartex) chart that this exporter does not write; its data is exported as a native clustered column chart instead.`});
2472
+ const reason = chartexMode === 'fallback'
2473
+ ? 'that this exporter does not write'
2474
+ : 'that PowerPoint has not yet accepted natively from this exporter (pass chartex: \'native\' to write its chartex part)';
2475
+ 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.`});
2255
2476
  }
2256
2477
  const mapped = {type: spec.pptx, spec, chartex, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined};
2257
2478
  if (data.columns.length === 1) {
@@ -2291,7 +2512,9 @@ function toPptxChartData(chart, chartexMode = 'fallback') {
2291
2512
  let series = data.columns.slice(1).map((name, seriesIndex) => ({
2292
2513
  name: stringifyText(name),
2293
2514
  labels,
2294
- values: data.rows.map((row) => numericValue(row?.[seriesIndex + 1]))
2515
+ // A cell that holds no number (null, an empty or non-numeric string, a boolean) is a gap, as in the preview and the single-column
2516
+ // path above: it is neither plotted as a zero nor written into the cache as one.
2517
+ values: data.rows.map((row) => parsedNumber(row?.[seriesIndex + 1]))
2295
2518
  }));
2296
2519
  const plotted = spec.family === 'circular' ? 1 : chartex ? chartex.series : Infinity;
2297
2520
  if (series.length > plotted) {
@@ -2335,7 +2558,42 @@ function histogramBins(values) {
2335
2558
  }
2336
2559
  }
2337
2560
 
2338
- async function resolveImage(asset, presentation, options, path) {
2561
+ // Embedded bytes must be a raster the exporter can measure and PptxGenJS can embed (PNG, JPEG, GIF, WebP). An SVG, or
2562
+ // bytes that are no readable image, would otherwise fail deep inside packaging. Like an unresolved asset it is not
2563
+ // exported as a picture: the caller draws the preview's placeholder (or omits the picture) and one `unresolved-asset`
2564
+ // diagnostic names the cause; `strictAssets` keeps the `unsupported-image-dimensions` error. `outcome.reported` tells a
2565
+ // caller that the diagnostic was already emitted. Hosts that need SVG art supply a raster through `imageResolver`
2566
+ // (for example opf-render's `svgToPng`). Local paths are read by PptxGenJS and checked after embedding.
2567
+ async function resolveImage(asset, presentation, options, path, outcome = {}) {
2568
+ const resolved = await resolveImageSource(asset, presentation, options, path);
2569
+ if (!resolved?.data) return resolved;
2570
+ const bytes = dataUriBytes(resolved.data);
2571
+ if (bytes && rasterMetadata(bytes)) return resolved;
2572
+ 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).`;
2573
+ if (options.strictAssets) throw new OPFPptxError("unsupported-image-dimensions", `Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. ${message}`, { path });
2574
+ options.onDiagnostic?.({ code: "unresolved-asset", path, reason: "unsupported-format", message });
2575
+ outcome.reported = true;
2576
+ return null;
2577
+ }
2578
+
2579
+ function dataUriMediaType(uri) {
2580
+ return /^data:([^;,]+)/i.exec(uri)?.[1]?.toLowerCase();
2581
+ }
2582
+
2583
+ // Only base64 data URIs carry bytes the exporter can read; any other encoding is not a readable raster.
2584
+ function dataUriBytes(uri) {
2585
+ const match = /^data:[^,]*;base64,/i.exec(uri);
2586
+ if (!match) return null;
2587
+ try {
2588
+ if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(uri.slice(match[0].length), "base64"));
2589
+ const binary = atob(uri.slice(match[0].length));
2590
+ return Uint8Array.from(binary, char => char.charCodeAt(0));
2591
+ } catch {
2592
+ return null;
2593
+ }
2594
+ }
2595
+
2596
+ async function resolveImageSource(asset, presentation, options, path) {
2339
2597
  const assetObject = dereferenceAsset(asset, presentation, path);
2340
2598
  const src = typeof assetObject === "string" ? assetObject : assetObject?.src;
2341
2599
  if (!src) {
@@ -2406,6 +2664,24 @@ function dereferenceAsset(asset, presentation, path, seen = new Set()) {
2406
2664
  return asset;
2407
2665
  }
2408
2666
 
2667
+ // PptxGenJS writes its own stand-in (`preencoded.png`, or the local file path) as the picture's description when it is
2668
+ // given no alt text. The package writes only what the document authored: the alt text as `descr`, and a distinct asset
2669
+ // `title` as the native `title` attribute (a picture's tooltip and Alt Text title).
2670
+ function pictureText(alt, asset, presentation) {
2671
+ const resolved = dereferenceAsset(asset, presentation, "asset-title");
2672
+ const title = isPlainObject(resolved) && typeof resolved.title === "string" && resolved.title && resolved.title !== alt ? resolved.title : undefined;
2673
+ return { alt: typeof alt === "string" && alt ? alt : undefined, title: alt ? title : undefined };
2674
+ }
2675
+
2676
+ function writePictureText(picture, text) {
2677
+ const escapes = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;", "\r": "&#13;", "\n": "&#10;", "\t": "&#9;" };
2678
+ const escape = value => value.replace(/[&<>"'\r\n\t]/g, char => escapes[char]);
2679
+ return picture.replace(/<p:cNvPr\b([^>]*?)(\/?)>/, (tag, attributes, close) => {
2680
+ attributes = attributes.replace(/\sdescr="[^"]*"/, "").replace(/\stitle="[^"]*"/, "");
2681
+ return `<p:cNvPr${attributes}${text.alt ? ` descr="${escape(text.alt)}"` : ""}${text.title ? ` title="${escape(text.title)}"` : ""}${close}>`;
2682
+ });
2683
+ }
2684
+
2409
2685
  function assetAlt(asset, presentation) {
2410
2686
  const resolved = dereferenceAsset(asset, presentation, "asset-alt");
2411
2687
  if (resolved && typeof resolved === "object" && !Array.isArray(resolved)) {
@@ -2445,10 +2721,13 @@ const DEFAULT_SOURCE_PREFIX = "https://www.pptx.gallery/";
2445
2721
  // catalog for pptx.gallery/pkg sources), injected options.catalogs, and the
2446
2722
  // bundled catalog (the engine default source also resolves to it).
2447
2723
  function socialPlatformRecords(presentation, options) {
2448
- const kind = "socialPlatforms", source = presentation.catalogs?.[kind]?.source;
2449
- const bySource = typeof source === "string" ? options.catalogSources?.[source] : undefined;
2450
- const sourceRecords = bySource ? normalizeRecords(bySource)
2451
- : typeof source === "string" && (source.startsWith(DEFAULT_SOURCE_PREFIX) || source.startsWith("pkg:@openpresentation/opf/")) ? defaultCatalog(kind) : [];
2724
+ const kind = "socialPlatforms", declared = presentation.catalogs?.[kind]?.source;
2725
+ // `source` is one source or an ordered search path (an array): records of each source in order, first match wins.
2726
+ const sourceRecords = (Array.isArray(declared) ? declared : [declared]).filter(source => typeof source === "string").flatMap(source => {
2727
+ const bySource = options.catalogSources?.[source];
2728
+ return bySource ? normalizeRecords(bySource)
2729
+ : source.startsWith(DEFAULT_SOURCE_PREFIX) || source.startsWith("pkg:@openpresentation/opf/") ? defaultCatalog(kind) : [];
2730
+ });
2452
2731
  return [...sourceRecords, ...normalizeRecords(options.catalogs?.[kind]), ...defaultCatalog(kind)];
2453
2732
  }
2454
2733
 
@@ -2476,19 +2755,26 @@ function resolveDimensions(value) {
2476
2755
  return { widthInches: width / 96, heightInches: height / 96 };
2477
2756
  }
2478
2757
 
2479
- function resolveBackground(value, colorScheme) {
2758
+ // A solid color or pattern background color is a ColorRef (hex, `var:` variable, colour-scheme slot or role), resolved
2759
+ // as for table fills and run colours. Roles resolve through the colour scheme alone: the background cannot depend on itself.
2760
+ function resolveBackgroundColorRef(entry, colorScheme, variables = {}) {
2761
+ return resolveColorRefValue(entry, {colorScheme, colors: {}, variables});
2762
+ }
2763
+
2764
+ function resolveBackground(value, colorScheme, variables = {}) {
2480
2765
  const fallback = "FFFFFF";
2766
+ const reference = entry => normalizeHex(resolveBackgroundColorRef(entry, colorScheme, variables) ?? entry, fallback);
2481
2767
  if (typeof value === "string") {
2482
2768
  if (value.startsWith("#")) return normalizeHex(value, fallback);
2483
2769
  return normalizeHex(colorScheme[value] ?? colorScheme.background ?? colorScheme.light1 ?? "#FFFFFF", fallback);
2484
2770
  }
2485
2771
  if (isPlainObject(value)) {
2486
- if (value.type === "solid" && value.color) return normalizeHex(value.color, fallback);
2772
+ if (value.type === "solid" && value.color) return reference(value.color);
2487
2773
  if (value.type === "theme" && value.slot) {
2488
2774
  return normalizeHex(colorScheme[value.slot] ?? colorScheme.light1 ?? "#FFFFFF", fallback);
2489
2775
  }
2490
2776
  // Like the SVG preview, text contrast follows a pattern's background color.
2491
- if (value.type === "pattern") return normalizeHex(value.pattern?.backgroundColor ?? "#FFFFFF", fallback);
2777
+ if (value.type === "pattern") return reference(value.pattern?.backgroundColor ?? "#FFFFFF");
2492
2778
  if (value.backgroundColor) return normalizeHex(value.backgroundColor, fallback);
2493
2779
  }
2494
2780
  return normalizeHex(colorScheme.background ?? colorScheme.light1 ?? "#FFFFFF", fallback);
@@ -2545,7 +2831,9 @@ async function normalizePptxZip(raw, context) {
2545
2831
  attachTimelineTags(entries,context.timelineTags);
2546
2832
  attachQuoteTags(entries,context.quoteTags);
2547
2833
  attachFurnitureFields(entries,context.furnitureFields);
2548
- attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests);
2834
+ attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests,context.furnitureLogoTags);
2835
+ // The "Image unavailable" panel of an unresolved logo: identity only, so import does not read it as content.
2836
+ attachTextTags(entries,context.logoPlaceholderTags,LOGO_TAG,'opfLogoPlaceholder','logo placeholder');
2549
2837
  for(const [part,bytes]of Object.entries(entries)){
2550
2838
  if(!/^ppt\/slides\/slide\d+\.xml$/.test(part))continue;
2551
2839
  const relationships=parseRelationships(entries,part);
@@ -2554,12 +2842,13 @@ async function normalizePptxZip(raw, context) {
2554
2842
  if(!context.chartHeadings.has(name))continue;
2555
2843
  const id=frame.match(/<c:chart\b[^>]*\br:id="([^"]+)"/)?.[1],chartPart=relationships.get(id)?.path;
2556
2844
  if(!chartPart)throw new OPFPptxError('packaging-failed','Generated chart relationship is missing.');
2557
- const {heading,labelColor,spec}=context.chartHeadings.get(name);
2845
+ const {heading,labelColor,spec,textSize}=context.chartHeadings.get(name);
2558
2846
  if(heading!==undefined)writeChartCategoryHeading(entries,chartPart,heading);
2559
- entries[chartPart]=encodeText(applyChartConstruct(decodeText(entries[chartPart]),spec));
2847
+ entries[chartPart]=encodeText(omitEmptyNumberPoints(applyChartConstruct(decodeText(entries[chartPart]),spec)));
2560
2848
  // PptxGenJS hardcodes a black fallback in pie/doughnut label properties.
2561
2849
  // Normalize only our generated chart text styles; point/series fills stay intact.
2562
2850
  entries[chartPart]=encodeText(decodeText(entries[chartPart]).replace(/<c:txPr>[\s\S]*?<\/c:txPr>/g,properties=>properties.replace(/<a:solidFill>[\s\S]*?<\/a:solidFill>/g,()=>`<a:solidFill><a:srgbClr val="${labelColor}"/></a:solidFill>`)));
2851
+ entries[chartPart]=encodeText(applyChartTextSize(decodeText(entries[chartPart]),textSize));
2563
2852
  }
2564
2853
  }
2565
2854
  applyChartFonts(entries,context.chartFonts,parseRelationships);
@@ -2570,13 +2859,18 @@ async function normalizePptxZip(raw, context) {
2570
2859
  throw new OPFPptxError('packaging-failed', `Chartex chart parts could not be attached: ${errorMessage(error)}`);
2571
2860
  }
2572
2861
  applyPitchFamilies(entries,context.fontPitch);
2862
+ // Native PowerPoint sections (p14:sectionLst) from slide `section` labels, written whatever the provenance option.
2863
+ if (context.sections.some(section => section !== undefined)) {
2864
+ try { entries['ppt/presentation.xml'] = encodeText(writeSectionList(decodeText(entries['ppt/presentation.xml']), context.sections)); }
2865
+ catch (error) { throw new OPFPptxError('packaging-failed', 'Section list could not be written.', {cause: errorMessage(error)}); }
2866
+ }
2573
2867
  const imageSources = new Map();
2574
2868
  for (const [part, bytes] of Object.entries(entries)) {
2575
2869
  if (!/^ppt\/slides\/slide\d+\.xml$/.test(part)) continue;
2576
2870
  const relationships = parseRelationships(entries, part);
2577
2871
  for (const [picture] of decodeText(bytes).matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
2578
2872
  const name = picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
2579
- const placement = context.imagePlacements.get(name) ?? context.slideImages.get(name) ?? (name === watermarkName() ? context.watermarks.get(part) : undefined);
2873
+ const placement = context.imagePlacements.get(name) ?? context.slideImages.get(name) ?? (name === watermarkName() ? context.watermarks.get(part) : name === logoName() ? context.logos.get(part) : undefined);
2580
2874
  const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
2581
2875
  if (placement) imageSources.set(relationships.get(id)?.path, placement.path);
2582
2876
  }
@@ -2609,6 +2903,9 @@ async function normalizePptxZip(raw, context) {
2609
2903
  placeWatermarks(entries, context.watermarks, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
2610
2904
  throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path });
2611
2905
  });
2906
+ placeLogos(entries, context.logos, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
2907
+ throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path });
2908
+ });
2612
2909
  // A picture repeated across slides embeds once (after fitting, which needs each slide's own relationship).
2613
2910
  dedupeMedia(entries, imageMetadata, resolveRelationshipTarget);
2614
2911
  // Charts and notes follow the language and fonts of the slide they belong to.
@@ -2812,7 +3109,15 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
2812
3109
  // Image data is already resolved and embedded by PptxGenJS. Read those
2813
3110
  // exact bytes instead of fetching or resolving the source a second time.
2814
3111
  const relationships = parseRelationships(entries, path);
3112
+ let bulletRelationship;
2815
3113
  xml = xml.replace(/<p:pic>([\s\S]*?)<\/p:pic>/g, picture => {
3114
+ if (picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1] === bulletImageName()) {
3115
+ // Only its media part and relationship are wanted: every picture-bullet paragraph points at them (a:buBlip).
3116
+ bulletRelationship = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
3117
+ return '';
3118
+ }
3119
+ const text = context.pictureText.get(picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1]);
3120
+ if (text) picture = writePictureText(picture, text);
2816
3121
  const placement = context.imagePlacements.get(picture.match(/name="(OPF image \d+)"/)?.[1]);
2817
3122
  if (!placement) return picture;
2818
3123
  const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
@@ -2833,6 +3138,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
2833
3138
  xml=xml.replace(/<p:sp>([\s\S]*?)<\/p:sp>/g,(shape)=>{
2834
3139
  const marker=context.listMarkers.get(shape.match(/name="(OPF list [^"]* line \d+)"/)?.[1]);
2835
3140
  if(!marker)return shape;
3141
+ if(marker.picture&&bulletRelationship)return shape.replace(/<a:buChar\b[^>]*\/>/g,`<a:buBlip><a:blip r:embed="${bulletRelationship}"/></a:buBlip>`);
2836
3142
  const family=marker.fontFamily.replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[char]));
2837
3143
  return shape.replace(/<a:buSzPct val="100000"\/>/g,`<a:buClr>${solidColorXml(marker.color)}</a:buClr><a:buSzPts val="${Math.round(marker.fontSize*100)}"/><a:buFont typeface="${family}"/>`);
2838
3144
  });
@@ -2921,9 +3227,12 @@ function normalizeNestedZip(bytes, context) {
2921
3227
  const entries = unzipSync(bytes);
2922
3228
  const output = {};
2923
3229
  for (const path of Object.keys(entries).sort()) {
3230
+ // A gap in the chart data is a blank workbook cell, not a numeric cell with an empty value.
2924
3231
  const entryBytes = path === "docProps/core.xml"
2925
3232
  ? encodeText(normalizeCoreProperties(decodeText(entries[path]), context.timestamp))
2926
- : entries[path];
3233
+ : /^xl\/worksheets\/sheet\d+\.xml$/.test(path)
3234
+ ? encodeText(decodeText(entries[path]).replace(/<c ((?:r|s)="[^"]*"(?: (?:r|s)="[^"]*")*)><v><\/v><\/c>/g, "<c $1/>"))
3235
+ : entries[path];
2927
3236
  output[path] = [entryBytes, {
2928
3237
  level: context.compressionLevel,
2929
3238
  mtime: context.zipDate
@@ -3050,6 +3359,12 @@ function parsedNumber(value) {
3050
3359
  return Number.isFinite(parsed) ? parsed : null;
3051
3360
  }
3052
3361
 
3362
+ // PptxGenJS writes a gap (null) as `<c:pt idx="n"><c:v></c:v></c:pt>`. A native gap is a numeric cache with no point at that
3363
+ // index (ptCount keeps the row count); an empty value is not a number, so drop those points, leaving the index unwritten.
3364
+ function omitEmptyNumberPoints(xml) {
3365
+ return xml.replace(/<c:numCache>[\s\S]*?<\/c:numCache>/g, cache => cache.replace(/<c:pt idx="\d+"><c:v><\/c:v><\/c:pt>/g, ""));
3366
+ }
3367
+
3053
3368
  function numericValue(value) {
3054
3369
  return parsedNumber(value) ?? 0;
3055
3370
  }