@openpresentation/opf-pptx 0.11.6 → 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();
@@ -245,6 +252,14 @@ export async function toPptx(input, options = {}) {
245
252
  report: diagnostic => options.onDiagnostic?.(diagnostic)
246
253
  });
247
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
+ });
248
263
  context.masterBackground = masterBackground(context);
249
264
  context.linkSentinels = linkSentinels(presentation);
250
265
  const pptx = new PptxGenJS();
@@ -326,6 +341,9 @@ export async function fromPptx(input, options = {}) {
326
341
  furnitureContexts[index].mediaRegistry = mediaRegistry;
327
342
  imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options, furniture.slides[index], furnitureContexts[index]));
328
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));
329
347
  // A watermark carried identically by every slide is the deck's design.watermark.
330
348
  const carried = imported.slides.map(slide => slide.design?.watermark);
331
349
  if (carried.length && carried[0] && carried.every(value => JSON.stringify(value) === JSON.stringify(carried[0]))) {
@@ -352,7 +370,8 @@ export async function fromPptx(input, options = {}) {
352
370
  const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true,
353
371
  // Host catalogs format stored socials exactly as export did (FF-34).
354
372
  socialPlatformRecords: catalogs => socialPlatformRecords({catalogs}, options),
355
- 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);
356
375
  // The stored language wins while the runs still carry its tag (FF-07).
357
376
  restored.groups = reconcileLanguage(restored.groups, observedLanguage, report);
358
377
  imported = applyDocumentProvenance(imported, restored, validatePresentation, report);
@@ -361,6 +380,7 @@ export async function fromPptx(input, options = {}) {
361
380
  report({code: "invalid-document-provenance", path: "", message: `${errorMessage(error)} Ordinary import keeps the values observed in the PPTX.`});
362
381
  }
363
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);
364
384
  if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
365
385
  languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
366
386
 
@@ -375,6 +395,23 @@ export async function fromPptx(input, options = {}) {
375
395
  return imported;
376
396
  }
377
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
+
378
415
  function readPptxZip(input) {
379
416
  let bytes;
380
417
  if (input instanceof Uint8Array) {
@@ -473,6 +510,17 @@ function resolveSlidePaths(entries, presentationRoot, relationships) {
473
510
  .sort(compareSlidePaths);
474
511
  }
475
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
+
476
524
  function compareSlidePaths(left, right) {
477
525
  return slideNumber(left) - slideNumber(right) || (left < right ? -1 : left > right ? 1 : 0);
478
526
  }
@@ -535,6 +583,16 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
535
583
  diagnostic => options.onDiagnostic?.({...diagnostic, path: watermarkPath}));
536
584
  if (watermark.design) slide.design = {...slide.design, ...watermark.design};
537
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;
538
596
 
539
597
  const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex, furniture, nativeContext)
540
598
  .sort(comparePositionedItems);
@@ -558,10 +616,13 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
558
616
  item.text = current.map(paragraph => paragraph.text).join('\n');
559
617
  }
560
618
  }
561
- const blocks = mergeAdjacentBulletShapes(items)
562
- .map((item) => payloadFromSlideItem(item))
563
- .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);
564
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);
565
626
 
566
627
  const notes = readSlideNotes(entries, relationships);
567
628
  if (notes) slide.notes = notes;
@@ -611,7 +672,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
611
672
  for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
612
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});
613
674
  for (const [index,shape] of shapes.entries()) {
614
- 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;
615
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;
616
677
  const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
617
678
  const item = importShape(shape, dimensions, paragraphs[index], ordinaryBody || furniture.taggedText.has(index) || media.captionShapes.has(shape));
@@ -642,7 +703,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
642
703
  }
643
704
 
644
705
  for (const [index, picture] of nativeContext.pictures.entries()) {
645
- 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;
646
707
  const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
647
708
  const item = importPicture(entries, picture, slidePath, relationships, report);
648
709
  if (item) items.push(item);
@@ -719,10 +780,23 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
719
780
  };
720
781
  }
721
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
+
722
793
  function importPicture(entries, picture, slidePath, relationships, report) {
723
794
  const bounds = shapeBounds(picture["p:spPr"]?.["a:xfrm"]);
724
795
  const name = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.name).trim();
725
- 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);
726
800
  const relId = picture["p:blipFill"]?.["a:blip"]?.["r:embed"];
727
801
  const relationship = relationships.get(relId);
728
802
  let bytes = relationship?.path ? entries[relationship.path] : null;
@@ -744,12 +818,14 @@ function importPicture(entries, picture, slidePath, relationships, report) {
744
818
  return {
745
819
  kind: "image",
746
820
  bounds,
821
+ visualBounds: visualBounds(picture["p:spPr"]?.["a:xfrm"]),
747
822
  name,
748
823
  payload: {
749
824
  type: "image",
750
825
  image: {
751
826
  src: `data:${rasterMetadata(bytes)?.mediaType ?? mediaTypeForPath(relationship.path)};base64,${bytesToBase64(bytes)}`,
752
- ...(alt ? { alt } : {})
827
+ ...(alt ? { alt } : {}),
828
+ ...(title && alt ? { title } : {})
753
829
  }
754
830
  }
755
831
  };
@@ -772,7 +848,7 @@ function readParagraphs(txBody) {
772
848
  }
773
849
  return {
774
850
  text: texts.join(""),
775
- 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),
776
852
  level: Number(asArray(paragraph?.["a:pPr"])[0]?.lvl ?? 0),
777
853
  maxFontSize: sizes.length > 0 ? Math.max(...sizes) : 0
778
854
  };
@@ -829,6 +905,59 @@ function takeSubtitleItem(items, titleItem, dimensions, inferHeadings) {
829
905
  return null;
830
906
  }
831
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
+
832
961
  // Adjacent native bullet boxes on the same text column form one imported list.
833
962
  // This is a geometry heuristic, not a lossless reconstruction of arbitrary PPTX.
834
963
  function mergeAdjacentBulletShapes(items) {
@@ -856,8 +985,8 @@ function payloadFromSlideItem(item) {
856
985
  type: "list",
857
986
  items: item.paragraphs.map((paragraph) => (
858
987
  paragraph.level > 0
859
- ? { text: paragraph.richText ?? paragraph.text, level: paragraph.level }
860
- : (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)
861
990
  ))
862
991
  };
863
992
  }
@@ -1159,7 +1288,8 @@ function resolvePresentationContext(presentation, options) {
1159
1288
  const fontSchemeId = referenceId(design.fontScheme ?? theme?.fontScheme);
1160
1289
  const unresolvedFontScheme = fontSchemeId && !findById(normalizeRecords(presentation.catalogs?.fontSchemes), fontSchemeId) && !findById(defaultCatalog("fontSchemes"), fontSchemeId) ? fontSchemeId : null;
1161
1290
  const dimensions = resolveDimensions(design.dimensions ?? theme?.dimensions);
1162
- const background = resolveBackground(design.background ?? theme?.background, colorScheme);
1291
+ const variables = resolveVariableColors(presentation.variables);
1292
+ const background = resolveBackground(design.background ?? theme?.background, colorScheme, variables);
1163
1293
  const fonts = resolveFonts(fontScheme);
1164
1294
  for (const role of ["heading","body","code"]) fonts[role] = resolveTextStyle({fontFamily:fonts[role],fontWeight:role === "heading" ? 700 : 400},options.textMeasurement).fontFamily;
1165
1295
  const textColor = readableTextColor(background, colorScheme);
@@ -1185,7 +1315,7 @@ function resolvePresentationContext(presentation, options) {
1185
1315
  surface: normalizeHex(colorScheme.surface ?? (darkBackground ? colorScheme.dark2 : colorScheme.light2) ?? "#F8FAFC"),
1186
1316
  border: normalizeHex(colorScheme.accent5 ?? "#CBD5E1")
1187
1317
  },
1188
- variables: resolveVariableColors(presentation.variables)
1318
+ variables
1189
1319
  };
1190
1320
  }
1191
1321
 
@@ -1208,7 +1338,23 @@ function nativeColor(reference, hex, context, fallback) {
1208
1338
 
1209
1339
  // FF-59: the slide tag is the eyebrow label. It is written in the deck primary color, the scheme accent1 slot the
1210
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
+ }
1211
1356
  function tagColor(context) {
1357
+ if (contrastRatio(context.colors.accent, context.colors.background) < TAG_MIN_CONTRAST) return context.textColor;
1212
1358
  return nativeColor('primary', context.colors.accent, context);
1213
1359
  }
1214
1360
 
@@ -1314,14 +1460,15 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1314
1460
  const backgroundPath = `${opfSlide.design?.background !== undefined ? `slides.${slideIndex}.` : ''}design.background`;
1315
1461
  const backgroundFill = schemeBackgroundFill(backgroundDefinition, slideContext) ?? nativeBackgroundFill(backgroundDefinition, {
1316
1462
  width: slideContext.dimensions.widthInches, height: slideContext.dimensions.heightInches
1317
- }, 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));
1318
1464
  if (backgroundFill) context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, backgroundFill);
1319
1465
  if (backgroundDefinition?.type === 'pattern' && !nativePatternPreset(backgroundDefinition.pattern?.preset)) {
1320
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.`});
1321
1467
  }
1322
1468
  if (backgroundDefinition?.type === 'image') {
1323
1469
  const imagePath = `${backgroundPath}.image`;
1324
- const resolved = await resolveImage(backgroundDefinition.image, presentation, options, imagePath);
1470
+ const outcome = {};
1471
+ const resolved = await resolveImage(backgroundDefinition.image, presentation, options, imagePath, outcome);
1325
1472
  if (resolved) {
1326
1473
  // PptxGenJS embeds the raster and its relationship; packaging replaces
1327
1474
  // its stretched fill with the fitted native picture fill.
@@ -1330,7 +1477,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1330
1477
  fit: backgroundDefinition.image?.fit ?? 'cover', opacity: backgroundDefinition.opacity ?? 1, imageFill: slideContext.imageFill, path: imagePath,
1331
1478
  width: slideContext.dimensions.widthInches * 96, height: slideContext.dimensions.heightInches * 96, report: options.onDiagnostic
1332
1479
  } });
1333
- } else {
1480
+ } else if (!outcome.reported) {
1334
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.'});
1335
1482
  }
1336
1483
  }
@@ -1350,11 +1497,15 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1350
1497
  // Core composition resolves one alignment per composed item for every engine
1351
1498
  // (FF-29); the design fallback keeps cores published before item.alignment working.
1352
1499
  const alignmentFor=item=>item.alignment??fieldAlignment(item.field);
1353
- 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) });
1354
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);
1355
1504
  // The preview paints background, slide image (and its overlay), then design.watermark, then content.
1356
1505
  if (geometry.slideImage) await addSlideImage(slide, presentation, geometry.slideImage, slideIndex, slideContext, options);
1357
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);
1358
1509
  for (const item of geometry.items) {
1359
1510
  // Card text sits on a literal card fill, not the slide background: keep it literal.
1360
1511
  const itemContext = item.frameBox ? {...slideContext, textColor: slideContext.colors.text, mutedColor: slideContext.colors.mutedText} : slideContext;
@@ -1384,7 +1535,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1384
1535
  breakLine: false
1385
1536
  });
1386
1537
  } else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
1387
- addMeasuredList(slide,item.text,itemContext,item.path);
1538
+ await addMeasuredList(slide,item.text,itemContext,item.path,item.bulletImage,presentation,slideIndex,options);
1388
1539
  } else if (item.field === "text" && item.text?.richLines) {
1389
1540
  const alignment=item.text.placement?.alignment??alignmentFor(item)??'left';
1390
1541
  for(const [index,line] of item.text.richLines.entries()){
@@ -1522,7 +1673,25 @@ function richLineRuns(line,color,native,context) {
1522
1673
  // the other measured text shapes. A reader maps lines to their list by name, not
1523
1674
  // by geometry, so lines of an overflowing list that fall below its box still
1524
1675
  // belong to it.
1525
- 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
+ }
1526
1695
  let lineNumber=0;
1527
1696
  for(const entry of fit.listEntries){
1528
1697
  const addLines=(text,box,color,native,withBullet)=>{
@@ -1530,7 +1699,7 @@ function addMeasuredList(slide,fit,context,path) {
1530
1699
  const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
1531
1700
  const region={x:(box.x-inset)/96,y:(box.y+line.y)/96,w:(box.width+inset)/96,h:line.height/96};
1532
1701
  const objectName=`OPF list ${path} line ${lineNumber++}`;
1533
- 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});
1534
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};
1535
1704
  const runs=richLineRuns(line,color,native,context);
1536
1705
  if(!runs.length)runs.push({text:'',options:{}});
@@ -1592,6 +1761,7 @@ async function addImagePayload(slide, presentation, asset, region, path, context
1592
1761
  }
1593
1762
  const objectName = `OPF image ${context.imagePlacements.size + 1}`;
1594
1763
  context.imagePlacements.set(objectName, { region, mode: context.imageFill, path });
1764
+ context.pictureText.set(objectName, pictureText(assetAlt(asset, presentation), asset, presentation));
1595
1765
  slide.addImage({
1596
1766
  ...resolved,
1597
1767
  objectName,
@@ -1619,9 +1789,10 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
1619
1789
  notExported('The watermark needs an image source (a string, or an object with src); no watermark was exported for this slide.');
1620
1790
  return;
1621
1791
  }
1622
- const resolved = await resolveImage(asset, presentation, options, path);
1792
+ const outcome = {};
1793
+ const resolved = await resolveImage(asset, presentation, options, path, outcome);
1623
1794
  if (!resolved) {
1624
- 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.'});
1625
1796
  return;
1626
1797
  }
1627
1798
  const {widthInches, heightInches} = slideContext.dimensions;
@@ -1631,6 +1802,24 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
1631
1802
  slide.addImage({...resolved, objectName: watermarkName(), ...box, altText: assetAlt(asset, presentation) ?? 'Watermark'});
1632
1803
  }
1633
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
+
1634
1823
  // design.slideImage: one native picture at the shared frame, beneath content.
1635
1824
  // Crop/fit and treatments are written after PptxGenJS embeds the bytes.
1636
1825
  async function addSlideImage(slide, presentation, image, slideIndex, context, options) {
@@ -1660,7 +1849,9 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
1660
1849
  // FF-53: a root `image` with the slide image's source is the slide image (core `replacesContent`); the
1661
1850
  // manifest records that so an unchanged picture imports back as both design.slideImage and slide.image.
1662
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 });
1663
- 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 });
1664
1855
  // The overlay scrim is a separate native shape directly above the picture.
1665
1856
  if (effects.overlay) {
1666
1857
  const overlay = effects.overlay.box;
@@ -1668,6 +1859,21 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
1668
1859
  }
1669
1860
  }
1670
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
+
1671
1877
  function addChartPayload(slide, chart, region, context, options = {}, path = "chart") {
1672
1878
  const chartData = toPptxChartData(chart, context.chartexMode);
1673
1879
  if (!chartData.series) {
@@ -1686,14 +1892,15 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1686
1892
  const fill = {color:normalizeHex(panelFill),transparency};
1687
1893
  const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
1688
1894
  const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
1689
- 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});
1690
1897
  context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
1691
1898
  const palette = CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`)));
1692
1899
  if (chartData.chartex) {
1693
1900
  // The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
1694
1901
  // PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
1695
1902
  const series = chartData.series.map((entry) => ({name: entry.name, labels: [...entry.labels], values: [...entry.values]}));
1696
- 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});
1697
1904
  if (chartData.chartex.layoutId === 'regionMap') {
1698
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.`});
1699
1906
  }
@@ -1712,14 +1919,16 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1712
1919
  // Paint alpha once in the chart area, rather than stacking two alpha fills.
1713
1920
  plotArea: {fill:{color:null}},
1714
1921
  catAxisLabelFontFace: context.fonts.body,
1715
- catAxisLabelFontSize: 9,
1922
+ catAxisLabelFontSize: textSize / 100,
1716
1923
  catAxisLabelColor: labelColor,
1717
1924
  valAxisLabelFontFace: context.fonts.body,
1718
- valAxisLabelFontSize: 9,
1925
+ valAxisLabelFontSize: textSize / 100,
1719
1926
  valAxisLabelColor: labelColor,
1720
1927
  legendColor: labelColor,
1721
1928
  legendFontFace: context.fonts.body,
1929
+ legendFontSize: textSize / 100,
1722
1930
  dataLabelColor: labelColor,
1931
+ dataLabelFontSize: textSize / 100,
1723
1932
  showValue: false,
1724
1933
  valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
1725
1934
  barDir: chartData.barDir,
@@ -1893,7 +2102,9 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
1893
2102
  if(part.type==='image'){
1894
2103
  const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
1895
2104
  const objectName = await addImagePayload(slide,presentation,part.image,region,part.path,{...context,imageFill:'fit'},options);
1896
- 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)});
1897
2108
  }else{
1898
2109
  if(!part.fit?.sourceLines)throw new OPFPptxError('missing-furniture-layout','Repeated text requires accepted source lines from core.',{path:part.path});
1899
2110
  // Slide numbers and current dates become native PowerPoint fields; {total}
@@ -1913,13 +2124,15 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
1913
2124
  if(marker) staticDates.set(index,marker);
1914
2125
  }
1915
2126
  }
1916
- 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});
1917
2128
  }
1918
2129
  }
1919
2130
  const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates);
1920
2131
  if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
1921
2132
  }
1922
2133
 
2134
+ const bulletImageName = () => 'OPF bullet image';
2135
+
1923
2136
  function addCodePayload(slide, value, layout, region, context, path) {
1924
2137
  if (!layout) throw new OPFPptxError('missing-code-layout', 'Code export requires a coordinated core build with shared code geometry.', {path});
1925
2138
  for (const part of layout.parts) {
@@ -2055,7 +2268,9 @@ function placeholderAsset(asset, presentation) {
2055
2268
  // minimum) in the readable text colour for the panel, or a cross when the label
2056
2269
  // cannot fit even at that minimum. The panel carries the accessible name the
2057
2270
  // preview gives its group, "Image unavailable: <description>".
2058
- 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} = {}) {
2059
2274
  const layered = placeholderAsset(asset, presentation);
2060
2275
  const description = String(layered.alt ?? layered.title ?? "Image");
2061
2276
  const label = `Image unavailable\n${description}`;
@@ -2089,17 +2304,18 @@ function addImagePlaceholder(slide, presentation, asset, region, path, context,
2089
2304
  const centered = { ...inner, y: inner.y + offset };
2090
2305
  addMeasuredPayloadText(slide, label, centered, context, options, {
2091
2306
  path, fit: fitText(label, centered, 20 * scale, minimum, measurer), textStyle: style,
2092
- diagnosticsHandled: true, align: "center", color: textColor
2307
+ diagnosticsHandled: true, align: "center", color: textColor, ...(logo ? {objectName: `${objectName} text`} : {})
2093
2308
  });
2094
- return;
2309
+ return logo ? objectName : undefined;
2095
2310
  }
2096
2311
  // A status indicator, not shortened authored content: the full description stays
2097
2312
  // in the panel's accessible name.
2098
2313
  const size = Math.max(0, Math.min(inner.width, inner.height, 24 * scale));
2099
- if (!size) return;
2314
+ if (!size) return logo ? objectName : undefined;
2100
2315
  const icon = { x: inner.x + (inner.width - size) / 2, y: inner.y + (inner.height - size) / 2 };
2101
2316
  const line = { color: textColor, width: Math.min(2 * scale, size / 8) * .75 };
2102
- 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;
2103
2319
  }
2104
2320
 
2105
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.
@@ -2296,7 +2512,9 @@ function toPptxChartData(chart, chartexMode = 'auto') {
2296
2512
  let series = data.columns.slice(1).map((name, seriesIndex) => ({
2297
2513
  name: stringifyText(name),
2298
2514
  labels,
2299
- 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]))
2300
2518
  }));
2301
2519
  const plotted = spec.family === 'circular' ? 1 : chartex ? chartex.series : Infinity;
2302
2520
  if (series.length > plotted) {
@@ -2340,7 +2558,42 @@ function histogramBins(values) {
2340
2558
  }
2341
2559
  }
2342
2560
 
2343
- 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) {
2344
2597
  const assetObject = dereferenceAsset(asset, presentation, path);
2345
2598
  const src = typeof assetObject === "string" ? assetObject : assetObject?.src;
2346
2599
  if (!src) {
@@ -2411,6 +2664,24 @@ function dereferenceAsset(asset, presentation, path, seen = new Set()) {
2411
2664
  return asset;
2412
2665
  }
2413
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
+
2414
2685
  function assetAlt(asset, presentation) {
2415
2686
  const resolved = dereferenceAsset(asset, presentation, "asset-alt");
2416
2687
  if (resolved && typeof resolved === "object" && !Array.isArray(resolved)) {
@@ -2450,10 +2721,13 @@ const DEFAULT_SOURCE_PREFIX = "https://www.pptx.gallery/";
2450
2721
  // catalog for pptx.gallery/pkg sources), injected options.catalogs, and the
2451
2722
  // bundled catalog (the engine default source also resolves to it).
2452
2723
  function socialPlatformRecords(presentation, options) {
2453
- const kind = "socialPlatforms", source = presentation.catalogs?.[kind]?.source;
2454
- const bySource = typeof source === "string" ? options.catalogSources?.[source] : undefined;
2455
- const sourceRecords = bySource ? normalizeRecords(bySource)
2456
- : 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
+ });
2457
2731
  return [...sourceRecords, ...normalizeRecords(options.catalogs?.[kind]), ...defaultCatalog(kind)];
2458
2732
  }
2459
2733
 
@@ -2481,19 +2755,26 @@ function resolveDimensions(value) {
2481
2755
  return { widthInches: width / 96, heightInches: height / 96 };
2482
2756
  }
2483
2757
 
2484
- 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 = {}) {
2485
2765
  const fallback = "FFFFFF";
2766
+ const reference = entry => normalizeHex(resolveBackgroundColorRef(entry, colorScheme, variables) ?? entry, fallback);
2486
2767
  if (typeof value === "string") {
2487
2768
  if (value.startsWith("#")) return normalizeHex(value, fallback);
2488
2769
  return normalizeHex(colorScheme[value] ?? colorScheme.background ?? colorScheme.light1 ?? "#FFFFFF", fallback);
2489
2770
  }
2490
2771
  if (isPlainObject(value)) {
2491
- if (value.type === "solid" && value.color) return normalizeHex(value.color, fallback);
2772
+ if (value.type === "solid" && value.color) return reference(value.color);
2492
2773
  if (value.type === "theme" && value.slot) {
2493
2774
  return normalizeHex(colorScheme[value.slot] ?? colorScheme.light1 ?? "#FFFFFF", fallback);
2494
2775
  }
2495
2776
  // Like the SVG preview, text contrast follows a pattern's background color.
2496
- if (value.type === "pattern") return normalizeHex(value.pattern?.backgroundColor ?? "#FFFFFF", fallback);
2777
+ if (value.type === "pattern") return reference(value.pattern?.backgroundColor ?? "#FFFFFF");
2497
2778
  if (value.backgroundColor) return normalizeHex(value.backgroundColor, fallback);
2498
2779
  }
2499
2780
  return normalizeHex(colorScheme.background ?? colorScheme.light1 ?? "#FFFFFF", fallback);
@@ -2550,7 +2831,9 @@ async function normalizePptxZip(raw, context) {
2550
2831
  attachTimelineTags(entries,context.timelineTags);
2551
2832
  attachQuoteTags(entries,context.quoteTags);
2552
2833
  attachFurnitureFields(entries,context.furnitureFields);
2553
- 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');
2554
2837
  for(const [part,bytes]of Object.entries(entries)){
2555
2838
  if(!/^ppt\/slides\/slide\d+\.xml$/.test(part))continue;
2556
2839
  const relationships=parseRelationships(entries,part);
@@ -2559,12 +2842,13 @@ async function normalizePptxZip(raw, context) {
2559
2842
  if(!context.chartHeadings.has(name))continue;
2560
2843
  const id=frame.match(/<c:chart\b[^>]*\br:id="([^"]+)"/)?.[1],chartPart=relationships.get(id)?.path;
2561
2844
  if(!chartPart)throw new OPFPptxError('packaging-failed','Generated chart relationship is missing.');
2562
- const {heading,labelColor,spec}=context.chartHeadings.get(name);
2845
+ const {heading,labelColor,spec,textSize}=context.chartHeadings.get(name);
2563
2846
  if(heading!==undefined)writeChartCategoryHeading(entries,chartPart,heading);
2564
- entries[chartPart]=encodeText(applyChartConstruct(decodeText(entries[chartPart]),spec));
2847
+ entries[chartPart]=encodeText(omitEmptyNumberPoints(applyChartConstruct(decodeText(entries[chartPart]),spec)));
2565
2848
  // PptxGenJS hardcodes a black fallback in pie/doughnut label properties.
2566
2849
  // Normalize only our generated chart text styles; point/series fills stay intact.
2567
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));
2568
2852
  }
2569
2853
  }
2570
2854
  applyChartFonts(entries,context.chartFonts,parseRelationships);
@@ -2575,13 +2859,18 @@ async function normalizePptxZip(raw, context) {
2575
2859
  throw new OPFPptxError('packaging-failed', `Chartex chart parts could not be attached: ${errorMessage(error)}`);
2576
2860
  }
2577
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
+ }
2578
2867
  const imageSources = new Map();
2579
2868
  for (const [part, bytes] of Object.entries(entries)) {
2580
2869
  if (!/^ppt\/slides\/slide\d+\.xml$/.test(part)) continue;
2581
2870
  const relationships = parseRelationships(entries, part);
2582
2871
  for (const [picture] of decodeText(bytes).matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
2583
2872
  const name = picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
2584
- 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);
2585
2874
  const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
2586
2875
  if (placement) imageSources.set(relationships.get(id)?.path, placement.path);
2587
2876
  }
@@ -2614,6 +2903,9 @@ async function normalizePptxZip(raw, context) {
2614
2903
  placeWatermarks(entries, context.watermarks, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
2615
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 });
2616
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
+ });
2617
2909
  // A picture repeated across slides embeds once (after fitting, which needs each slide's own relationship).
2618
2910
  dedupeMedia(entries, imageMetadata, resolveRelationshipTarget);
2619
2911
  // Charts and notes follow the language and fonts of the slide they belong to.
@@ -2817,7 +3109,15 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
2817
3109
  // Image data is already resolved and embedded by PptxGenJS. Read those
2818
3110
  // exact bytes instead of fetching or resolving the source a second time.
2819
3111
  const relationships = parseRelationships(entries, path);
3112
+ let bulletRelationship;
2820
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);
2821
3121
  const placement = context.imagePlacements.get(picture.match(/name="(OPF image \d+)"/)?.[1]);
2822
3122
  if (!placement) return picture;
2823
3123
  const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
@@ -2838,6 +3138,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
2838
3138
  xml=xml.replace(/<p:sp>([\s\S]*?)<\/p:sp>/g,(shape)=>{
2839
3139
  const marker=context.listMarkers.get(shape.match(/name="(OPF list [^"]* line \d+)"/)?.[1]);
2840
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>`);
2841
3142
  const family=marker.fontFamily.replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[char]));
2842
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}"/>`);
2843
3144
  });
@@ -2926,9 +3227,12 @@ function normalizeNestedZip(bytes, context) {
2926
3227
  const entries = unzipSync(bytes);
2927
3228
  const output = {};
2928
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.
2929
3231
  const entryBytes = path === "docProps/core.xml"
2930
3232
  ? encodeText(normalizeCoreProperties(decodeText(entries[path]), context.timestamp))
2931
- : 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];
2932
3236
  output[path] = [entryBytes, {
2933
3237
  level: context.compressionLevel,
2934
3238
  mtime: context.zipDate
@@ -3055,6 +3359,12 @@ function parsedNumber(value) {
3055
3359
  return Number.isFinite(parsed) ? parsed : null;
3056
3360
  }
3057
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
+
3058
3368
  function numericValue(value) {
3059
3369
  return parsedNumber(value) ?? 0;
3060
3370
  }