@openpresentation/opf-pptx 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,15 +1,23 @@
1
+ import {nativeBodyReader, joinNativeParagraphs} from './body-text-import.js';
1
2
  import {importTableFrames} from './table-import.js';
3
+ import {applyChartFonts, applyPitchFamilies, finalizeFontsUsed, fontPitchFamilies} from './package-fonts.js';
2
4
  import {readChartCategoryHeading,writeChartCategoryHeading} from './chart-workbook.js';
3
5
  import {attachCodeTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
4
6
  import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
5
7
  import {attachCardTags,importCardFrames} from './card-provenance.js';
8
+ import {attachMediaTags,importMediaGroups,mediaCaption,mediaFrameRecord,mediaTextFingerprint} from './media-provenance.js';
6
9
  import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
7
10
  import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
8
11
  import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
9
- import {attachFurnitureTags, furnitureManifest, importFurniture} from './furniture-provenance.js';
12
+ import {attachFurnitureTags, furnitureManifest, importFurniture, staticDateFallback} from './furniture-provenance.js';
13
+ import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, restoreDocumentProvenance} from './document-provenance.js';
14
+ import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
10
15
  import {importImageOrientation} from './image-import.js';
11
- import {nativeBackgroundFill} from './background.js';
16
+ import {placeSlideImages, importSlideImage, slideImageName, slideImageOverlayName} from './slide-image-provenance.js';
17
+ import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
12
18
  import {importBackground} from './background-import.js';
19
+ import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
20
+ import {languageDiagnostics, observeLanguage, partScriptFonts, planScriptFonts, reconcileLanguage} from './script-fonts.js';
13
21
  import { webpToPng } from '#image-fallback';
14
22
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
15
23
  import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
@@ -22,6 +30,8 @@ import {
22
30
  validatePresentation
23
31
  } from "@openpresentation/opf";
24
32
 
33
+ export {checkPptxTypefaces, inventoryPptxTypefaces, packageFontsUsed, THEME_SCRIPT_SUPPLEMENTS} from './typeface-inventory.js';
34
+
25
35
  export const packageName = "@openpresentation/opf-pptx";
26
36
 
27
37
  export const releaseLane = Object.freeze({
@@ -57,6 +67,9 @@ const FIXED_TIMESTAMP = "1980-01-01T00:00:00Z";
57
67
  // local components: a UTC instant rolls back to 1979 west of UTC (below
58
68
  // fflate's 1980 floor) and would otherwise yield timezone-dependent bytes.
59
69
  const FIXED_ZIP_DATE = new Date(1980, 0, 1, 0, 0, 0);
70
+ // Explicit stamps overwrite these fields after ZIP generation. Keep the
71
+ // temporary instant well inside fflate's range even if the host changes TZ.
72
+ const EXPLICIT_ZIP_SENTINEL = new Date('2000-01-01T12:00:00Z');
60
73
  const DEFAULT_SEED = 0x4f504658;
61
74
  const CANONICAL_SCHEMA = "https://openpresentation.org/schema/opf/v1";
62
75
  const EMUS_PER_INCH = 914400;
@@ -69,6 +82,12 @@ const xmlParser = new XMLParser({
69
82
  parseTagValue: false,
70
83
  trimValues: false
71
84
  });
85
+ // Keep numeric character references (notably CR) in native core text. This is
86
+ // scoped to properties; the ordinary slide/relationship parser is unchanged.
87
+ const coreTextParser = new XMLParser({
88
+ ignoreAttributes: false, attributeNamePrefix: "", textNodeName: "#text",
89
+ parseAttributeValue: false, parseTagValue: false, trimValues: false, htmlEntities: true
90
+ });
72
91
 
73
92
  const ROOT_PAYLOAD_FIELDS = [
74
93
  "text",
@@ -148,6 +167,7 @@ const DIMENSION_PRESETS = Object.freeze({
148
167
  const DEFAULTS = Object.freeze({
149
168
  theme: "minimal",
150
169
  colorScheme: "cool-horizon",
170
+ // Shared last-resort font scheme (core DEFAULT_FONT_SCHEME): every engine uses aptos.
151
171
  fontScheme: "aptos"
152
172
  });
153
173
 
@@ -170,25 +190,45 @@ export async function toPptx(input, options = {}) {
170
190
  if (options.imageFormat !== undefined && !['compatible', 'preserve'].includes(options.imageFormat)) {
171
191
  throw new OPFPptxError('invalid-image-format', 'imageFormat must be compatible or preserve.', {path: 'options.imageFormat'});
172
192
  }
193
+ if (options.provenance !== undefined && !['full', 'references-only', false].includes(options.provenance)) {
194
+ throw new OPFPptxError('invalid-provenance-option', "provenance must be 'full', 'references-only' or false.", {path: 'options.provenance'});
195
+ }
173
196
  const presentation = parseInput(input);
174
197
  assertValidBoundary(presentation);
198
+ options = {...options, textMeasurement: chosenFamilyMeasurement(options.textMeasurement)};
175
199
 
176
200
  const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
177
201
  context.listMarkers = new Map();
202
+ context.imagePlaceholders = new Map();
178
203
  context.tableHeaders = new Map();
179
204
  context.tableCells = new Map();
180
205
  context.imagePlacements = new Map();
206
+ context.slideImages = new Map();
181
207
  context.backgroundFills = new Map();
208
+ context.notesWithCarriageReturns = new Map();
182
209
  context.cardTags = new Map();
210
+ context.mediaTags = new Map();
211
+ context.provenanceMode = options.provenance ?? "full";
183
212
  context.headingTags = new Map();
184
213
  context.plainTextTags = new Map();
185
214
  context.timelineTags = new Map();
186
215
  context.furnitureTags = new Map();
216
+ context.furnitureFields = new Map();
187
217
  context.furnitureManifests = new Map();
188
218
  context.codeTags = new Map();
189
219
  context.metricTags = new Map();
190
220
  context.chartHeadings = new Map();
221
+ context.chartFonts = new Map();
191
222
  context.imageFormat = options.imageFormat ?? "compatible";
223
+ Object.assign(context, exportTheme(presentation, context));
224
+ context.reportedFontSchemes = new Set();
225
+ // Document references and metadata tags (FF-32, docs/document-roundtrip.md).
226
+ context.documentProvenance = options.provenance === false ? null : documentProvenance(presentation, {
227
+ mode: options.provenance ?? "full",
228
+ isCatalogId: (kind, id) => !!(findById(normalizeRecords(presentation.catalogs?.[kind]), id) ?? findById(defaultCatalog(kind), id)),
229
+ report: diagnostic => options.onDiagnostic?.(diagnostic)
230
+ });
231
+ context.scriptFonts = planScriptFonts(presentation, options.onDiagnostic);
192
232
  const pptx = new PptxGenJS();
193
233
  configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
194
234
 
@@ -196,6 +236,10 @@ export async function toPptx(input, options = {}) {
196
236
  await addSlide(pptx, presentation, presentation.slides[index], index, context, options);
197
237
  }
198
238
 
239
+ // FF-08: pitchFamily per exported family, from each slide's resolved scheme.
240
+ context.fontPitch = fontPitchFamilies(presentation.slides.map((slide, index) => resolveSlideContext(presentation, slide, context, options, index).fonts),
241
+ [...normalizeRecords(presentation.catalogs?.fontSchemes), ...defaultCatalog("fontSchemes")]);
242
+
199
243
  let raw;
200
244
  try {
201
245
  raw = await withDeterministicRandom(context.seed, () => pptx.write({
@@ -227,15 +271,24 @@ export async function fromPptx(input, options = {}) {
227
271
 
228
272
  const core = readCoreProperties(entries);
229
273
  const dimensions = dimensionsFromPresentation(presentationRoot);
230
- const imported = {
274
+ let imported = {
231
275
  $schema: options.schema ?? CANONICAL_SCHEMA,
232
276
  name: core.title || options.fallbackName || "Imported PPTX",
233
277
  slides: []
234
278
  };
235
279
 
280
+ // FF-07: the language the runs carry. A stored FF-32 reference can still win below.
281
+ const observedLanguage = observeLanguage({
282
+ slides: slidePaths.map(path => decodeText(entries[path])),
283
+ theme: (path => path && entries[path] ? decodeText(entries[path]) : null)(presentationThemePath(presentationRoot, presentationRels, path => parseRelationships(entries, path), entries)),
284
+ catalogs: bundledCatalogs
285
+ });
286
+ if (observedLanguage.language !== undefined) imported.language = observedLanguage.language;
236
287
  if (core.description) imported.description = core.description;
237
288
  if (core.author) imported.author = core.author;
238
- if (dimensions) imported.design = { dimensions };
289
+ const themeDesign = importThemeDesign(entries, presentationRoot, presentationRels);
290
+ const design = {...themeDesign.design, ...(dimensions ? {dimensions} : {})};
291
+ if (Object.keys(design).length) imported.design = design;
239
292
 
240
293
  const furnitureContexts = slidePaths.map((slidePath, index) => {
241
294
  const root = parseRequiredXml(entries, slidePath)['p:sld'];
@@ -250,9 +303,42 @@ export async function fromPptx(input, options = {}) {
250
303
  if (Object.keys(furniture.design).length) imported.design = {...imported.design, ...furniture.design};
251
304
  if (furniture.organization) imported.organization = furniture.organization;
252
305
 
306
+ const mediaRegistry = Object.create(null);
253
307
  for (let index = 0; index < slidePaths.length; index += 1) {
308
+ furnitureContexts[index].mediaRegistry = mediaRegistry;
254
309
  imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options, furniture.slides[index], furnitureContexts[index]));
255
310
  }
311
+ // Conflicting media asset IDs fall back to their current native URLs during import.
312
+ for (const context of furnitureContexts) for (const [id, asset] of Object.entries(context.mediaAssets ?? {})) if (!Object.hasOwn(imported.assets ?? {}, id)) imported.assets = {...imported.assets, [id]: asset};
313
+ // Report after the slides so slide diagnostics keep their established order.
314
+ // A theme name FF-24 could not verify is not reported when the stored reference restores the theme.
315
+ for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code !== "theme-unverified") options.onDiagnostic?.(diagnostic);
316
+
317
+ // Catalog references, layout ids and authoring metadata recorded at export
318
+ // (FF-32). Stored references win while the package still matches them;
319
+ // otherwise the observed values (including FF-24's theme recovery) stay and
320
+ // a diagnostic names the reference. A restored field that does not validate
321
+ // is dropped on its own.
322
+ const report = diagnostic => options.onDiagnostic?.(diagnostic);
323
+ // Per slide: {layout, structure: 'match' | 'changed' | 'untagged', record, catalogRecord}.
324
+ // Layout-structure recovery (FF-29) reads the stored OPF_SLIDE_V1 record from
325
+ // here instead of writing a second slide tag.
326
+ let slideProvenance = slidePaths.map(() => ({structure: "untagged"}));
327
+ try {
328
+ const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true,
329
+ // Host catalogs format stored socials exactly as export did (FF-34).
330
+ socialPlatformRecords: catalogs => socialPlatformRecords({catalogs}, options),
331
+ slides: slidePaths.map((path, index) => ({path, root: furnitureContexts[index].root, relationships: furnitureContexts[index].relationships}))}, report);
332
+ // The stored language wins while the runs still carry its tag (FF-07).
333
+ restored.groups = reconcileLanguage(restored.groups, observedLanguage, report);
334
+ imported = applyDocumentProvenance(imported, restored, validatePresentation, report);
335
+ slideProvenance = restored.slides;
336
+ } catch (error) {
337
+ report({code: "invalid-document-provenance", path: "", message: `${errorMessage(error)} Ordinary import keeps the values observed in the PPTX.`});
338
+ }
339
+ if (slideProvenance.length !== imported.slides.length) throw new OPFPptxError("invalid-import-opf", "Slide provenance does not match the imported slides.");
340
+ if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
341
+ languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
256
342
 
257
343
  const result = validatePresentation(imported);
258
344
  if (!result.valid) {
@@ -364,7 +450,7 @@ function resolveSlidePaths(entries, presentationRoot, relationships) {
364
450
  }
365
451
 
366
452
  function compareSlidePaths(left, right) {
367
- return slideNumber(left) - slideNumber(right) || left.localeCompare(right);
453
+ return slideNumber(left) - slideNumber(right) || (left < right ? -1 : left > right ? 1 : 0);
368
454
  }
369
455
 
370
456
  function slideNumber(path) {
@@ -372,12 +458,12 @@ function slideNumber(path) {
372
458
  }
373
459
 
374
460
  function readCoreProperties(entries) {
375
- const doc = parseOptionalXml(entries, "docProps/core.xml");
461
+ const doc = entries["docProps/core.xml"] ? parseRequiredXml(entries, "docProps/core.xml", coreTextParser) : null;
376
462
  const core = doc?.["cp:coreProperties"] ?? {};
377
463
  return {
378
- title: scalarText(core["dc:title"]).trim(),
379
- description: scalarText(core["dc:description"] ?? core["dc:subject"]).trim(),
380
- author: scalarText(core["dc:creator"]).trim()
464
+ title: scalarText(core["dc:title"]),
465
+ description: scalarText(core["dc:description"] ?? core["dc:subject"]),
466
+ author: scalarText(core["dc:creator"])
381
467
  };
382
468
  }
383
469
 
@@ -405,6 +491,16 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
405
491
  if (background) slide.design = {background};
406
492
  if (Object.keys(furniture.design).length) slide.design = {...slide.design, ...furniture.design};
407
493
  if (furniture.section !== undefined) slide.section = furniture.section;
494
+ const slideImagePath = `slides.${slideIndex}.design.slideImage`;
495
+ const slideImage = importSlideImage(nativeContext.pictures, nativeContext.shapes, relationships, entries, slideIndex,
496
+ picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
497
+ // The recorded crop/fit is re-derived from design.slideImage.
498
+ if (diagnostic.code !== 'unsupported-image-crop') options.onDiagnostic?.({...diagnostic, path: slideImagePath});
499
+ }),
500
+ diagnostic => options.onDiagnostic?.({...diagnostic, path: slideImagePath}));
501
+ if (slideImage.design) slide.design = {...slide.design, ...slideImage.design};
502
+ nativeContext.slideImagePictures = slideImage.consumed;
503
+ nativeContext.slideImageShapes = slideImage.consumedShapes;
408
504
 
409
505
  const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex, furniture, nativeContext)
410
506
  .sort(comparePositionedItems);
@@ -419,6 +515,15 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
419
515
  const subtitleItem = heading('subtitle')??takeSubtitleItem(items, titleItem, dimensions, inferHeadings);
420
516
  if (subtitleItem) slide.subtitle = subtitleItem.text;
421
517
 
518
+ // Heading inference keeps its existing scalar/native metrics. Only remaining
519
+ // ordinary body shapes gain current rich values; tagged recovery is separate.
520
+ for (const item of items) if (item.readNativeBody) {
521
+ const current = item.readNativeBody();
522
+ if (current) {
523
+ item.paragraphs = current.map((paragraph, index) => ({...item.paragraphs[index], ...paragraph}));
524
+ item.text = current.map(paragraph => paragraph.text).join('\n');
525
+ }
526
+ }
422
527
  const blocks = mergeAdjacentBulletShapes(items)
423
528
  .map((item) => payloadFromSlideItem(item))
424
529
  .filter(Boolean);
@@ -438,9 +543,14 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
438
543
  const shapes = nativeContext.shapes;
439
544
  if (tree?.['p:grpSp']) options.onDiagnostic?.({code:'grouped-text-reflow',path:`slides.${slideIndex}`,message:'Grouped native text and pictures are retained, but group transforms and unsupported group members are not reconstructed; review the reflowed OPF.'});
440
545
  const paragraphs = nativeContext.paragraphs;
546
+ const readBody = nativeBodyReader(slidePath, {
547
+ part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
548
+ }, relationships, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.${diagnostic.path}`}));
441
549
  const code = importCodeGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.code`}));
442
550
  const metric = importMetricGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.metric`}));
443
551
  const cards = importCardFrames(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
552
+ const media = importMediaGroups(shapes, paragraphs, relationships, entries, slideIndex, diagnostic => options.onDiagnostic?.(diagnostic), nativeContext.mediaRegistry);
553
+ nativeContext.mediaAssets = media.assets;
444
554
  const headings=importHeadingGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
445
555
  const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
446
556
  const timelines=importTimelineGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
@@ -459,13 +569,16 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
459
569
  }
460
570
  for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
461
571
  for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
572
+ 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});
462
573
  for (const [index,shape] of shapes.entries()) {
463
- if (furniture.text.has(index)) continue;
464
- if (code.consumed.has(shape)||metric.consumed.has(shape)||cards.has(shape)||headings.consumed.has(shape)||plainText.consumed.has(shape)||timelines.consumed.has(shape)) continue;
465
- const item = importShape(shape, dimensions, paragraphs[index], furniture.taggedText.has(index));
574
+ if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index)) continue;
575
+ 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)) continue;
576
+ const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
577
+ const item = importShape(shape, dimensions, paragraphs[index], ordinaryBody || furniture.taggedText.has(index) || media.captionShapes.has(shape));
578
+ if (item && ordinaryBody) item.readNativeBody = () => readBody(index);
466
579
  // A damaged/edited furniture group falls back to current native text,
467
580
  // including cleared text boxes, without inventing a title or shape label.
468
- if (item && furniture.taggedText.has(index)) item.sourceText = true;
581
+ if (item && (furniture.taggedText.has(index) || media.captionShapes.has(shape))) item.sourceText = true;
469
582
  if (item) items.push(item);
470
583
  }
471
584
 
@@ -480,7 +593,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
480
593
  }
481
594
 
482
595
  for (const [index, picture] of nativeContext.pictures.entries()) {
483
- if (furniture.pictures.has(index)) continue;
596
+ if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index)) continue;
484
597
  const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
485
598
  const item = importPicture(entries, picture, slidePath, relationships, report);
486
599
  if (item) items.push(item);
@@ -691,12 +804,12 @@ function payloadFromSlideItem(item) {
691
804
  type: "list",
692
805
  items: item.paragraphs.map((paragraph) => (
693
806
  paragraph.level > 0
694
- ? { text: paragraph.text, level: paragraph.level }
695
- : paragraph.text
807
+ ? { text: paragraph.richText ?? paragraph.text, level: paragraph.level }
808
+ : (paragraph.richText ?? paragraph.text)
696
809
  ))
697
810
  };
698
811
  }
699
- return { type: "text", text: item.text };
812
+ return { type: "text", text: joinNativeParagraphs(item.paragraphs) };
700
813
  }
701
814
  if (item.kind === "unknown" && item.text) return { type: "text", text: item.text };
702
815
  return null;
@@ -713,18 +826,29 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
713
826
  if (!chartNode) return null;
714
827
  const series = asArray(chartNode.node["c:ser"]);
715
828
  if (series.length === 0) return null;
829
+ if (series.length > MAX_CHART_CACHE_POINTS) {
830
+ throw new OPFPptxError('invalid-chart-cache', 'Chart cache exceeds the 100,000-series import limit; reduce its series before importing.', {path: relationship.path});
831
+ }
716
832
 
717
- const labels = cachedValues(series[0]?.["c:cat"]);
718
- const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"]) || `Series ${index + 1}`);
719
- const values = series.map((entry) => cachedValues(entry?.["c:val"] ?? entry?.["c:yVal"]).map(numericValue));
720
- const rowCount = Math.max(labels.length, ...values.map((row) => row.length));
833
+ const budget = { cells: 0 };
834
+ const cachePath = (index, role) => `${relationship.path}#c:ser[${index}]/${role}`;
835
+ const labels = cachedValues(series[0]?.["c:cat"], cachePath(0, 'c:cat'), budget);
836
+ const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, 'c:tx'), budget) ?? `Series ${index + 1}`);
837
+ const values = series.map((entry, index) => {
838
+ const role = entry?.["c:val"] !== undefined ? 'c:val' : 'c:yVal';
839
+ return cachedValues(entry?.[role], cachePath(index, role), budget, numericCacheValue);
840
+ });
841
+ const rowCount = values.reduce((count, row) => Math.max(count, row.length), labels.length);
721
842
  if (rowCount === 0) return null;
843
+ if (rowCount * (series.length + 1) > MAX_CHART_CACHE_CELLS) {
844
+ throw new OPFPptxError('invalid-chart-cache', 'Chart cache exceeds the 1,000,000-cell import limit; reduce its rows or series before importing.', {path: relationship.path});
845
+ }
722
846
 
723
847
  const rows = [];
724
848
  for (let index = 0; index < rowCount; index += 1) {
725
849
  rows.push([
726
- labels[index] ?? `Item ${index + 1}`,
727
- ...values.map((row) => row[index] ?? 0)
850
+ labels[index] ?? null,
851
+ ...values.map((row) => row[index] ?? null)
728
852
  ]);
729
853
  }
730
854
 
@@ -754,39 +878,102 @@ function firstChartNode(plotArea) {
754
878
  return null;
755
879
  }
756
880
 
757
- function cachedValues(node) {
758
- const cache = node?.["c:strRef"]?.["c:strCache"]
759
- ?? node?.["c:numRef"]?.["c:numCache"]
760
- ?? node?.["c:multiLvlStrRef"]?.["c:multiLvlStrCache"]?.["c:lvl"]
761
- ?? node?.["c:numLit"]
762
- ?? node?.["c:strLit"];
763
- const points = asArray(cache?.["c:pt"]);
764
- if (points.length > 0) return points.map((point) => scalarText(point?.["c:v"]));
765
- const nestedPoints = asArray(cache)
766
- .flatMap((level) => asArray(level?.["c:pt"]))
767
- .map((point) => scalarText(point?.["c:v"]));
768
- return nestedPoints;
881
+ const MAX_CHART_CACHE_POINTS = 100_000;
882
+ const MAX_CHART_CACHE_CELLS = 1_000_000;
883
+
884
+ // Import complete decimal exponent tokens without the exporter's legacy
885
+ // character stripping. Other numeric strings deliberately retain that policy.
886
+ function numericCacheValue(value, path) {
887
+ if (value === null || value.trim() === '') return null;
888
+ const token = value.trim();
889
+ const exponent = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+))[eE][+-]?\d+$/.exec(token);
890
+ if (!exponent) return numericValue(value);
891
+ const parsed = Number(token);
892
+ if (!Number.isFinite(parsed) || (parsed === 0 && /[1-9]/.test(exponent[1]))) {
893
+ const reason = Number.isFinite(parsed) ? 'underflows to zero' : 'overflows';
894
+ throw new OPFPptxError('unsupported-chart-cache', `Scientific-notation chart value is outside the supported finite numeric range (${reason}); use a representable value before importing.`, {path});
895
+ }
896
+ return parsed;
897
+ }
898
+
899
+ // c:pt is sparse and its XML order does not determine the data row. Keep
900
+ // explicit empty strings distinct from missing points, and bound native
901
+ // metadata before allocating a dense OPF table. Only numeric roles convert values.
902
+ function cachedValues(node, path, budget, readValue) {
903
+ const invalid = (message, suffix = '') => {
904
+ throw new OPFPptxError('invalid-chart-cache', `Invalid chart cache: ${message}. Repair the chart data before importing.`, {path: path + suffix});
905
+ };
906
+ const single = (value, location) => {
907
+ if (value !== undefined && value !== '' && (!value || typeof value !== 'object' || Array.isArray(value))) {
908
+ invalid('cache container must be a single element', location);
909
+ }
910
+ };
911
+ single(node, '');
912
+ for (const reference of ['c:strRef', 'c:numRef', 'c:multiLvlStrRef']) single(node?.[reference], `/${reference}`);
913
+ const candidates = [
914
+ ['c:strRef/c:strCache', node?.['c:strRef']?.['c:strCache']],
915
+ ['c:numRef/c:numCache', node?.['c:numRef']?.['c:numCache']],
916
+ ['c:multiLvlStrRef/c:multiLvlStrCache', node?.['c:multiLvlStrRef']?.['c:multiLvlStrCache']],
917
+ ['c:numLit', node?.['c:numLit']],
918
+ ['c:strLit', node?.['c:strLit']],
919
+ ].filter(([, cache]) => cache !== undefined);
920
+ if (candidates.length === 0) return [];
921
+ if (candidates.length > 1) invalid('multiple competing cache sources');
922
+ const [kind, cache] = candidates[0];
923
+ path += `/${kind}`;
924
+ if (cache !== '' && (!cache || typeof cache !== 'object' || Array.isArray(cache))) invalid('cache must be a single element');
925
+ const integer = (raw, limit, location) => {
926
+ // XML Schema unsigned integers allow an optional sign and whitespace;
927
+ // their value must still be nonnegative, integral and bounded.
928
+ if (typeof raw !== 'string' || !/^[+-]?\d+$/.test(raw.trim())) invalid('expected an unsigned integer', location);
929
+ const value = Number(raw.trim());
930
+ if (!Number.isSafeInteger(value) || value < 0 || value > limit) invalid(`integer exceeds the supported range 0–${limit}`, location);
931
+ return value;
932
+ };
933
+ const count = cache?.['c:ptCount'] === undefined ? undefined
934
+ : integer(cache['c:ptCount']?.val, MAX_CHART_CACHE_POINTS, '/c:ptCount@val');
935
+ let data = cache;
936
+ if (kind.startsWith('c:multiLvlStrRef')) {
937
+ const levels = asArray(cache?.['c:lvl']);
938
+ if (levels.length > 1) {
939
+ throw new OPFPptxError('unsupported-chart-cache', 'Hierarchical chart category caches cannot be flattened without losing labels; use a single category level before importing.', {path});
940
+ }
941
+ data = levels[0];
942
+ }
943
+ const points = asArray(data?.['c:pt']);
944
+ if (points.length > MAX_CHART_CACHE_POINTS) invalid('too many points');
945
+ const indexed = new Map();
946
+ let extent = count ?? 0;
947
+ for (const [position, point] of points.entries()) {
948
+ const location = `/c:pt[${position}]@idx`;
949
+ const index = integer(point?.idx, MAX_CHART_CACHE_POINTS - 1, location);
950
+ if (count !== undefined && index >= count) invalid('point index is outside its declared count', location);
951
+ if (indexed.has(index)) invalid('duplicate point index', location);
952
+ if (Array.isArray(point?.['c:v'])) invalid('point has multiple values', `/c:pt[${position}]/c:v`);
953
+ indexed.set(index, point?.['c:v'] === undefined ? null : scalarText(point['c:v']));
954
+ extent = Math.max(extent, index + 1);
955
+ }
956
+ if (budget.cells + extent > MAX_CHART_CACHE_CELLS) invalid('combined caches exceed the 1,000,000-cell import limit');
957
+ budget.cells += extent;
958
+ const result = new Array(extent).fill(null);
959
+ for (const [index, value] of indexed) {
960
+ result[index] = readValue ? readValue(value, `${path}/c:pt[@idx="${index}"]/c:v`) : value;
961
+ }
962
+ return result;
769
963
  }
770
964
 
771
- function firstCachedValue(node) {
772
- return cachedValues(node).find(Boolean) ?? "";
965
+ function firstCachedValue(node, path, budget) {
966
+ return cachedValues(node, path, budget).find(value => value !== null);
773
967
  }
774
968
 
775
969
  function readSlideNotes(entries, relationships) {
776
970
  const notesRel = [...relationships.values()].find((relationship) => relationship.type.endsWith("/notesSlide"));
777
971
  if (!notesRel?.path || !entries[notesRel.path]) return "";
778
972
  const doc = parseRequiredXml(entries, notesRel.path);
779
- const shapes = asArray(doc["p:notes"]?.["p:cSld"]?.["p:spTree"]?.["p:sp"]);
780
- const bodyNotes = shapes
781
- .filter((shape) => shapePlaceholderType(shape) === "body")
782
- .map((shape) => textFromTextBody(shape["p:txBody"]))
783
- .filter(Boolean);
784
- return bodyNotes.join("\n").trim();
785
- }
786
-
787
-
788
- function textFromTextBody(txBody) {
789
- return readParagraphs(txBody).map((paragraph) => paragraph.text).filter(Boolean).join("\n").trim();
973
+ const shapes = nativeTextShapes(doc["p:notes"]?.["p:cSld"]?.["p:spTree"]);
974
+ const paragraphs = nativeShapeParagraphs(decodeText(entries[notesRel.path]), 'p:notes');
975
+ return shapes.flatMap((shape, index) => shapePlaceholderType(shape) === "body"
976
+ ? paragraphs[index].map(paragraph => paragraph.text) : []).join("\n");
790
977
  }
791
978
 
792
979
  function comparePositionedItems(left, right) {
@@ -885,6 +1072,10 @@ function resolvePresentationContext(presentation, options) {
885
1072
  design.fontScheme ?? theme?.fontScheme,
886
1073
  DEFAULTS.fontScheme
887
1074
  );
1075
+ // Shared rule (core resolveFontSchemeReference): an unresolved id falls back to the
1076
+ // DEFAULTS.fontScheme record as the base; resolveSlideContext reports it once per path.
1077
+ const fontSchemeId = referenceId(design.fontScheme ?? theme?.fontScheme);
1078
+ const unresolvedFontScheme = fontSchemeId && !findById(normalizeRecords(presentation.catalogs?.fontSchemes), fontSchemeId) && !findById(defaultCatalog("fontSchemes"), fontSchemeId) ? fontSchemeId : null;
888
1079
  const dimensions = resolveDimensions(design.dimensions ?? theme?.dimensions);
889
1080
  const background = resolveBackground(design.background ?? theme?.background, colorScheme);
890
1081
  const fonts = resolveFonts(fontScheme);
@@ -895,13 +1086,15 @@ function resolvePresentationContext(presentation, options) {
895
1086
  return {
896
1087
  seed: Number.isInteger(options.seed) ? options.seed : DEFAULT_SEED,
897
1088
  timestamp: options.timestamp ?? FIXED_TIMESTAMP,
898
- zipDate: options.zipDate ? new Date(options.zipDate) : FIXED_ZIP_DATE,
1089
+ zipDate: options.zipDate === undefined ? FIXED_ZIP_DATE : EXPLICIT_ZIP_SENTINEL,
1090
+ zipDateStamp: resolveZipDateStamp(options.zipDate),
899
1091
  compressionLevel: Number.isInteger(options.compressionLevel) ? options.compressionLevel : 6,
900
1092
  layoutName: "OPF_CANVAS",
901
1093
  dimensions,
902
1094
  colorScheme,
903
1095
  backgroundDefinition: design.background ?? theme?.background,
904
1096
  fonts,
1097
+ unresolvedFontScheme,
905
1098
  colors: {
906
1099
  background,
907
1100
  text: textColor,
@@ -918,6 +1111,55 @@ function exportColor(entry, context, fallback) {
918
1111
  return resolveExportColor(entry, colorContext(context, fallback));
919
1112
  }
920
1113
 
1114
+ // PptxGenJS color for a document color reference: a theme scheme value when the
1115
+ // reference names a scheme slot or role that the deck theme holds exactly,
1116
+ // otherwise the resolved literal RRGGBB. Alpha stays in the caller's transparency.
1117
+ function nativeColor(reference, hex, context) {
1118
+ return schemeColorValue(reference, hex, context, {vendor: true}) ?? normalizeHex(hex);
1119
+ }
1120
+
1121
+ // The theme part carries the deck-level scheme (slide overrides stay literal)
1122
+ // and, for an explicit catalog theme, that theme's name.
1123
+ function exportTheme(presentation, context) {
1124
+ const reference = presentation.design?.theme;
1125
+ const id = referenceId(reference);
1126
+ const theme = id ? resolveDesignRecord(presentation, "themes", reference, DEFAULTS.theme) : null;
1127
+ const scheme = context.colorScheme;
1128
+ return {
1129
+ themeColors: themeSlotColors(scheme),
1130
+ schemeName: scheme.name ?? scheme.id ?? "OpenPresentation",
1131
+ themeName: theme?.id === id ? theme.name ?? theme.id : undefined
1132
+ };
1133
+ }
1134
+
1135
+ function importThemeDesign(entries, presentationRoot, presentationRels) {
1136
+ const diagnostics = [], design = {};
1137
+ const report = (code, path, message) => diagnostics.push({code, path, message});
1138
+ const themePath = presentationThemePath(presentationRoot, presentationRels, path => parseRelationships(entries, path), entries);
1139
+ const theme = themePath ? parseOptionalXml(entries, themePath)?.["a:theme"] : undefined;
1140
+ const elements = theme?.["a:themeElements"];
1141
+ const clrScheme = elements?.["a:clrScheme"];
1142
+ if (!clrScheme || Array.isArray(clrScheme)) {
1143
+ report("unsupported-theme-colors", "design.colorScheme", "The PPTX has no readable theme color scheme, so design.colorScheme was not imported.");
1144
+ return {design, diagnostics};
1145
+ }
1146
+ const {colors, unreadable} = readThemeSlotColors(clrScheme);
1147
+ const records = defaultCatalog("colorSchemes");
1148
+ if (Object.keys(colors).length) design.colorScheme = recoverColorScheme({colors, unreadable, name: scalarText(clrScheme.name)}, records).value;
1149
+ if (unreadable.length) report("unsupported-theme-colors", "design.colorScheme", `Theme color slot(s) ${unreadable.join(", ")} do not resolve to an opaque sRGB color and were not imported.`);
1150
+ const latin = font => scalarText(elements?.["a:fontScheme"]?.[font]?.["a:latin"]?.typeface);
1151
+ const recoveredTheme = recoverTheme({themeName: scalarText(theme?.name), colors, majorFont: latin("a:majorFont"), minorFont: latin("a:minorFont")}, {
1152
+ themes: defaultCatalog("themes"),
1153
+ colorSchemes: records,
1154
+ fontFamilies: id => {
1155
+ const record = findById(defaultCatalog("fontSchemes"), id);
1156
+ return record ? resolveFontFamilies(record) : null;
1157
+ }
1158
+ });
1159
+ if (recoveredTheme.unverified) report("theme-unverified", "design.theme", `The native theme has the name of OPF catalog theme '${recoveredTheme.unverified}', but neither its color scheme nor its heading/body fonts match that theme, so design.theme was not set.`);
1160
+ return {design: recoveredTheme.id ? {theme: recoveredTheme.id, ...design} : design, diagnostics};
1161
+ }
1162
+
921
1163
  function configurePresentation(pptx, presentation, context) {
922
1164
  pptx.defineLayout({
923
1165
  name: context.layoutName,
@@ -938,21 +1180,51 @@ function configurePresentation(pptx, presentation, context) {
938
1180
 
939
1181
  async function addSlide(pptx, presentation, opfSlide, slideIndex, context, options) {
940
1182
  const slide = pptx.addSlide();
941
- const slideContext = resolveSlideContext(presentation, opfSlide, context, options);
1183
+ const slideContext = resolveSlideContext(presentation, opfSlide, context, options, slideIndex);
942
1184
  slide.background = { color: slideContext.colors.background };
943
- const backgroundFill = nativeBackgroundFill(slideContext.backgroundDefinition, {
1185
+ const backgroundDefinition = slideContext.backgroundDefinition;
1186
+ const backgroundPath = `${opfSlide.design?.background !== undefined ? `slides.${slideIndex}.` : ''}design.background`;
1187
+ const backgroundFill = schemeBackgroundFill(backgroundDefinition, slideContext) ?? nativeBackgroundFill(backgroundDefinition, {
944
1188
  width: slideContext.dimensions.widthInches, height: slideContext.dimensions.heightInches
945
- }, slideContext.colors.background);
1189
+ }, slideContext.colors.background, slideContext.colors.text, (reference, hex) => schemeColorValue(reference, hex, slideContext));
946
1190
  if (backgroundFill) context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, backgroundFill);
1191
+ if (backgroundDefinition?.type === 'pattern' && !nativePatternPreset(backgroundDefinition.pattern?.preset)) {
1192
+ options.onDiagnostic?.({code: 'unsupported-pattern', path: `${backgroundPath}.pattern.preset`, message: `Pattern ${backgroundDefinition.pattern?.preset} has no DrawingML preset; only its background color was exported.`});
1193
+ }
1194
+ if (backgroundDefinition?.type === 'image') {
1195
+ const imagePath = `${backgroundPath}.image`;
1196
+ const resolved = await resolveImage(backgroundDefinition.image, presentation, options, imagePath);
1197
+ if (resolved) {
1198
+ // PptxGenJS embeds the raster and its relationship; packaging replaces
1199
+ // its stretched fill with the fitted native picture fill.
1200
+ slide.background = { ...resolved };
1201
+ context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, { image: {
1202
+ fit: backgroundDefinition.image?.fit ?? 'cover', opacity: backgroundDefinition.opacity ?? 1, imageFill: slideContext.imageFill, path: imagePath,
1203
+ width: slideContext.dimensions.widthInches * 96, height: slideContext.dimensions.heightInches * 96, report: options.onDiagnostic
1204
+ } });
1205
+ } else {
1206
+ 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.'});
1207
+ }
1208
+ }
947
1209
  slide.color = slideContext.colors.text;
948
1210
  if (opfSlide.hidden === true) slide.hidden = true;
949
1211
 
950
1212
  const { widthInches, heightInches } = slideContext.dimensions;
951
1213
  const layout = resolveCatalogRecord(presentation, "layouts", opfSlide.layout, "blank") ?? {};
952
1214
  if (opfSlide.layout && layout.id !== opfSlide.layout) throw new OPFPptxError("catalog-resolution-failed", `Layout '${opfSlide.layout}' needs an inline or bundled catalog record.`, { path: `slides.${slideIndex}.layout` });
953
- const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, presentation, slideIndex, fonts: slideContext.fonts, contentAlignment:opfSlide.design?.contentAlignment??presentation.design?.contentAlignment, titleAlignment:opfSlide.design?.titleAlignment??presentation.design?.titleAlignment, textRasterPadding:options.textRasterPadding, contentBox:opfSlide.design?.contentBox??presentation.design?.contentBox, textMeasurement: options.textMeasurement });
1215
+ // design.titleAlignment/contentAlignment are Design properties (slide design
1216
+ // overrides the deck). Core only returns an accepted `placement.alignment`
1217
+ // with outline measurement, so the same effective values must also drive
1218
+ // text without placement, exactly as the renderer's preview does.
1219
+ const titleAlignment=opfSlide.design?.titleAlignment??presentation.design?.titleAlignment;
1220
+ const contentAlignment=opfSlide.design?.contentAlignment??presentation.design?.contentAlignment;
1221
+ const fieldAlignment=field=>field==='title'?titleAlignment:contentAlignment;
1222
+ // Core composition resolves one alignment per composed item for every engine
1223
+ // (FF-29); the design fallback keeps cores published before item.alignment working.
1224
+ const alignmentFor=item=>item.alignment??fieldAlignment(item.field);
1225
+ 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) });
954
1226
  for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
955
- await addFurniture(slide,presentation,opfSlide,geometry.furniture,slideContext,options,slideIndex);
1227
+ if (geometry.slideImage) await addSlideImage(slide, presentation, geometry.slideImage, slideIndex, slideContext, options);
956
1228
  for (const item of geometry.items) {
957
1229
  const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
958
1230
  if (item.frameBox) {
@@ -965,7 +1237,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
965
1237
  fill:paint(surface),line:{...paint(slideContext.colorScheme.accent5??`#${slideContext.colors.border}`),pt:.75},objectName:`OPF card ${item.path}`});
966
1238
  }
967
1239
  if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
968
- addMeasuredPayloadText(slide,item.value,item.box,slideContext,options,{path:item.path,fit:item.text,textStyle:item.textStyle,sourceText:item.field==='text'&&!!item.text.sourceLines,diagnosticsHandled:true,heading:['title','subtitle','tag'].includes(item.field)?item.field:undefined,color:item.field==='tag'?slideContext.colors.accent:slideContext.colors.text});
1240
+ addMeasuredPayloadText(slide,item.value,item.box,slideContext,options,{path:item.path,fit:item.text,textStyle:item.textStyle,sourceText:item.field==='text'&&!!item.text.sourceLines,align:alignmentFor(item),diagnosticsHandled:true,heading:['title','subtitle','tag'].includes(item.field)?item.field:undefined,color:item.field==='tag'?slideContext.colors.accent:slideContext.colors.text});
969
1241
  } else if (["title", "subtitle", "tag"].includes(item.field)) {
970
1242
  // Estimated-font headings use one native text box, which still needs a
971
1243
  // role tag. Role recovery must not depend on outline measurement support.
@@ -975,35 +1247,55 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
975
1247
  ...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),
976
1248
  ...nativeFontOptions(item.textStyle),
977
1249
  color: item.field === "tag" ? slideContext.colors.accent : slideContext.colors.text,
1250
+ align: alignmentFor(item),
978
1251
  objectName,
979
1252
  breakLine: false
980
1253
  });
981
1254
  } else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
982
- addMeasuredList(slide,item.text,slideContext);
1255
+ addMeasuredList(slide,item.text,slideContext,item.path);
983
1256
  } else if (item.field === "text" && item.text?.richLines) {
984
- const alignment=item.text.placement?.alignment??opfSlide.design?.contentAlignment??presentation.design?.contentAlignment??'left';
1257
+ const alignment=item.text.placement?.alignment??alignmentFor(item)??'left';
985
1258
  for(const [index,line] of item.text.richLines.entries()){
986
1259
  const runs=line.fragments.map(fragment=>{
987
1260
  const runColor=exportColor(fragment.run.color,slideContext,slideContext.colors.text);
988
- return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color:normalizeHex(runColor),underline:fragment.run.underline?{color:normalizeHex(runColor)}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/fragment.fontSize*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}};
1261
+ const color=nativeColor(fragment.run.color,runColor,slideContext);
1262
+ return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color,underline:fragment.run.underline?{style:'sng',color}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/fragment.fontSize*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}};
989
1263
  });
990
1264
  const placed=item.text.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
991
1265
  const area=placed?{...region,x:(placed.x+line.width*factor-item.box.width*factor)/96,y:placed.y/96,h:placed.height/96}:{...region,y:region.y+line.y/96,h:line.height/96};
992
1266
  if(runs.length)slide.addText(runs,{...textBoxOptions(area,slideContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
993
1267
  }
994
1268
  } else if (item.field === "text" && typeof item.value === "string") {
995
- slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle)});
1269
+ slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:alignmentFor(item)});
996
1270
  } else {
997
- await addPayload(slide, presentation, item.payload, region, item.path, { ...slideContext, composition: item.composition, contentAlignment: opfSlide.design?.contentAlignment ?? presentation.design?.contentAlignment ?? "left" }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
1271
+ await addPayload(slide, presentation, item.payload, region, item.path, { ...slideContext, composition: item.composition, contentAlignment: alignmentFor(item) ?? "left" }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
998
1272
  }
999
1273
  }
1274
+ // Core composes furniture above all content and opf-render paints it last, so
1275
+ // spTree order (PowerPoint's z-order) matches: header/footer parts come after
1276
+ // every content item, and an overlapping footer stays visible over content.
1277
+ await addFurniture(slide,presentation,opfSlide,geometry.furniture,slideContext,options,slideIndex);
1000
1278
 
1001
- if (opfSlide.notes) slide.addNotes(String(opfSlide.notes));
1279
+ if (opfSlide.notes) {
1280
+ const notes = String(opfSlide.notes);
1281
+ slide.addNotes(notes);
1282
+ if (notes.includes('\r')) context.notesWithCarriageReturns.set(`ppt/notesSlides/notesSlide${slideIndex + 1}.xml`, notes);
1283
+ }
1002
1284
  }
1003
1285
 
1004
- function resolveSlideContext(presentation, slide, baseContext, options) {
1286
+ function resolveSlideContext(presentation, slide, baseContext, options, slideIndex = 0) {
1005
1287
  const effective = { ...presentation, design: { ...presentation.design, ...slide.design } };
1006
1288
  const resolved = resolvePresentationContext(effective, options);
1289
+ if (resolved.unresolvedFontScheme) {
1290
+ const path = slide.design?.fontScheme !== undefined ? `slides.${slideIndex}.design.fontScheme`
1291
+ : presentation.design?.fontScheme !== undefined ? "design.fontScheme"
1292
+ : slide.design?.theme !== undefined ? `slides.${slideIndex}.design.theme` : "design.theme";
1293
+ if (!baseContext.reportedFontSchemes?.has(path)) {
1294
+ baseContext.reportedFontSchemes?.add(path);
1295
+ const id = resolved.unresolvedFontScheme;
1296
+ options.onDiagnostic?.({ code: "unresolved-font-scheme", path, id, fallback: DEFAULTS.fontScheme, message: `Font scheme '${id}' is not in the inline or bundled catalogs; using the default font scheme '${DEFAULTS.fontScheme}'.` });
1297
+ }
1298
+ }
1007
1299
  if (Math.abs(resolved.dimensions.widthInches - baseContext.dimensions.widthInches) > 1e-6
1008
1300
  || Math.abs(resolved.dimensions.heightInches - baseContext.dimensions.heightInches) > 1e-6) {
1009
1301
  throw new OPFPptxError("mixed-slide-dimensions", "PowerPoint requires one canvas size per presentation. Set dimensions on the deck or export this slide separately.");
@@ -1028,10 +1320,10 @@ async function addPayload(slide, presentation, payload, region, path, context, o
1028
1320
  await addImagePayload(slide, presentation, payload.image, region, path, context, options);
1029
1321
  break;
1030
1322
  case "video":
1031
- addPlaceholderPayload(slide, "Video", payload.video, region, context);
1323
+ addMediaPayload(slide, presentation, payload.video, region, path, context, options);
1032
1324
  break;
1033
1325
  case "chart":
1034
- addChartPayload(slide, payload.chart, region, context);
1326
+ addChartPayload(slide, payload.chart, region, context, options, path);
1035
1327
  break;
1036
1328
  case "table":
1037
1329
  addTablePayload(slide, payload.table, region, context, options, path);
@@ -1049,7 +1341,8 @@ async function addPayload(slide, presentation, payload, region, path, context, o
1049
1341
  addTimelinePayload(slide, payload.timeline, timelineLayout, context, options, path);
1050
1342
  break;
1051
1343
  default:
1052
- addPlaceholderPayload(slide, "Unsupported OPF payload", payload, region, context);
1344
+ options.onDiagnostic?.({code: "content-placeholder", path, reason: "unsupported-payload", message: "This content has no PowerPoint export; a placeholder frame stands in for it."});
1345
+ addPlaceholderPayload(slide, "Unsupported content", "This content has no PowerPoint export.", region, context);
1053
1346
  }
1054
1347
  }
1055
1348
 
@@ -1078,17 +1371,23 @@ function addTextPayload(slide, value, region, context) {
1078
1371
  function richLineRuns(line,color,context) {
1079
1372
  const fallback=color.replace(/^#/,'');
1080
1373
  return line.fragments.map(fragment=>{
1081
- const runColor=exportColor(fragment.run.color,context,fallback);
1082
- return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color:normalizeHex(runColor),underline:fragment.run.underline?{color:normalizeHex(runColor)}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/fragment.fontSize*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}};
1374
+ const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context);
1375
+ return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color,underline:fragment.run.underline?{style:'sng',color}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/fragment.fontSize*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}};
1083
1376
  });
1084
1377
  }
1085
- function addMeasuredList(slide,fit,context) {
1378
+ // Every native line of a list, marker paragraph or not, is named for the list's
1379
+ // composed path (`OPF list <path> line N`, N counting across the whole list) like
1380
+ // the other measured text shapes. A reader maps lines to their list by name, not
1381
+ // by geometry, so lines of an overflowing list that fall below its box still
1382
+ // belong to it.
1383
+ function addMeasuredList(slide,fit,context,path) {
1384
+ let lineNumber=0;
1086
1385
  for(const entry of fit.listEntries){
1087
1386
  const addLines=(text,box,color,withBullet)=>{
1088
1387
  text.richLines.forEach((line,index)=>{
1089
1388
  const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
1090
1389
  const region={x:(box.x-inset)/96,y:(box.y+line.y)/96,w:(box.width+inset)/96,h:line.height/96};
1091
- const objectName=first?`OPF list paragraph ${context.listMarkers.size+1}`:undefined;
1390
+ const objectName=`OPF list ${path} line ${lineNumber++}`;
1092
1391
  if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:normalizeHex(context.colors.text)});
1093
1392
  const paragraph=first?{bullet:{characterCode:entry.marker.text.codePointAt(0).toString(16).padStart(4,'0'),indent:entry.marker.indent*.75},indentLevel:level}:{bullet:false};
1094
1393
  const runs=richLineRuns(line,color,context);
@@ -1146,7 +1445,7 @@ function addListPayload(slide, items, region, context) {
1146
1445
  async function addImagePayload(slide, presentation, asset, region, path, context, options) {
1147
1446
  const resolved = await resolveImage(asset, presentation, options, path);
1148
1447
  if (!resolved) {
1149
- addPlaceholderPayload(slide, "Image", asset, region, context);
1448
+ addImagePlaceholder(slide, presentation, asset, region, path, context, options);
1150
1449
  return;
1151
1450
  }
1152
1451
  const objectName = `OPF image ${context.imagePlacements.size + 1}`;
@@ -1163,12 +1462,50 @@ async function addImagePayload(slide, presentation, asset, region, path, context
1163
1462
  return objectName;
1164
1463
  }
1165
1464
 
1166
- function addChartPayload(slide, chart, region, context) {
1465
+ // design.slideImage: one native picture at the shared frame, beneath content.
1466
+ // Crop/fit and treatments are written after PptxGenJS embeds the bytes.
1467
+ async function addSlideImage(slide, presentation, image, slideIndex, context, options) {
1468
+ const box = { x: image.box.x / 96, y: image.box.y / 96, w: image.box.width / 96, h: image.box.height / 96 };
1469
+ const resolved = await resolveImage(image.value, presentation, options, image.sourcePath);
1470
+ if (!resolved) {
1471
+ addImagePlaceholder(slide, presentation, image.value, box, image.sourcePath, context, options);
1472
+ return;
1473
+ }
1474
+ const objectName = slideImageName(`slides.${slideIndex}`);
1475
+ const configured = image.path === 'design.slideImage' ? presentation.design?.slideImage : presentation.slides[slideIndex].design?.slideImage;
1476
+ const { src: _source, ...treatment } = isPlainObject(configured) && 'position' in configured ? configured : {};
1477
+ const paint = (entry, fallback) => {
1478
+ const hex = normalizeHex(exportColor(entry, context, fallback), fallback);
1479
+ const raw = exportColor(entry, context, fallback).replace(/^#/, '');
1480
+ return { hex, alpha: /^[0-9a-f]{8}$/i.test(raw) ? parseInt(raw.slice(6), 16) / 255 : 1 };
1481
+ };
1482
+ // Native effects are resolved here, in the slide's color context.
1483
+ const effects = {
1484
+ shape: image.shape ?? null,
1485
+ border: image.border ? { ...paint(image.border.color, context.colors.border), width: image.border.width } : null,
1486
+ opacity: image.opacity ?? null,
1487
+ recolor: image.recolor?.type === 'grayscale' ? { type: 'grayscale' }
1488
+ : image.recolor?.type === 'duotone' ? { type: 'duotone', dark: paint(image.recolor.dark, '000000'), light: paint(image.recolor.light, 'FFFFFF') } : null,
1489
+ overlay: image.overlay ? { ...paint(image.overlay.color, context.colors.text), opacity: image.overlay.opacity, box: image.overlay.box, shape: image.overlay.shape } : null,
1490
+ };
1491
+ context.slideImages.set(objectName, { slide: `slides.${slideIndex}`, box, fill: image.fill, path: image.sourcePath, treatment: { ...treatment, position: image.position }, effects });
1492
+ slide.addImage({ ...resolved, objectName, ...box, altText: image.alt ?? assetAlt(image.value, presentation) });
1493
+ // The overlay scrim is a separate native shape directly above the picture.
1494
+ if (effects.overlay) {
1495
+ const overlay = effects.overlay.box;
1496
+ slide.addShape('rect', { x: overlay.x / 96, y: overlay.y / 96, w: overlay.width / 96, h: overlay.height / 96, objectName: slideImageOverlayName(`slides.${slideIndex}`), fill: { color: effects.overlay.hex } });
1497
+ }
1498
+ }
1499
+
1500
+ function addChartPayload(slide, chart, region, context, options = {}, path = "chart") {
1167
1501
  const chartData = toPptxChartData(chart);
1168
- if (!chartData) {
1169
- addPlaceholderPayload(slide, "Chart", chart, region, context);
1502
+ if (!chartData.series) {
1503
+ // Never lose a chart silently: the placeholder frame stands in for it, and a diagnostic names the reason.
1504
+ options.onDiagnostic?.({code: "chart-data-unplottable", path, message: chartData.message, reason: chartData.reason});
1505
+ addPlaceholderPayload(slide, "Chart", chartData.summary, region, context);
1170
1506
  return;
1171
1507
  }
1508
+ if (chartData.adapted) options.onDiagnostic?.({code: "chart-data-adapted", path, message: chartData.message, adaptation: chartData.adapted});
1172
1509
 
1173
1510
  // Keep the resolved palette surface (including alpha) explicit in native
1174
1511
  // chart/plot areas, so inherited labels are assessed against their own panel.
@@ -1178,7 +1515,8 @@ function addChartPayload(slide, chart, region, context) {
1178
1515
  const fill = {color:normalizeHex(panelFill),transparency};
1179
1516
  const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
1180
1517
  const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
1181
- context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chart.data.columns[0],labelColor});
1518
+ context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor});
1519
+ context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
1182
1520
  slide.addChart(chartData.type, chartData.series, {
1183
1521
  objectName,
1184
1522
  x: region.x,
@@ -1212,7 +1550,8 @@ function addTablePayload(slide, table, region, context, options, path) {
1212
1550
  const hasHeaders = Array.isArray(table?.columns) && table.columns.length > 0;
1213
1551
  const sourceRows = [...(hasHeaders ? [table.columns] : []), ...(table?.rows ?? [])];
1214
1552
  if (sourceRows.length === 0) {
1215
- addPlaceholderPayload(slide, "Table", table, region, context);
1553
+ options?.onDiagnostic?.({code: "content-placeholder", path, reason: "table-has-no-rows", message: "The table has no rows and no header; a placeholder frame stands in for it."});
1554
+ addPlaceholderPayload(slide, "Table", "The table has no rows.", region, context);
1216
1555
  return;
1217
1556
  }
1218
1557
 
@@ -1235,10 +1574,10 @@ function addTablePayload(slide, table, region, context, options, path) {
1235
1574
  const fragment = fragments.find(item => item.runIndex === index);
1236
1575
  const runStyle = fragment?.style ?? resolveTextStyle({...style,fontFamily:run.fontFamily ?? style.fontFamily,fontWeight:run.bold === undefined ? style.fontWeight : run.bold ? 700 : 400,italic:run.italic ?? style.italic}, options.textMeasurement);
1237
1576
  const rawColor = run.color !== undefined && run.color !== '' ? exportColor(run.color, context, baseColor) : baseColor;
1238
- const color = normalizeHex(rawColor), transparency = rawColor.length === 8 ? (1 - parseInt(rawColor.slice(6), 16) / 255) * 100 : 0;
1577
+ const color = nativeColor(run.color !== undefined && run.color !== '' ? run.color : cellStyle.color, rawColor, context), transparency = rawColor.length === 8 ? (1 - parseInt(rawColor.slice(6), 16) / 255) * 100 : 0;
1239
1578
  const runOptions = {
1240
1579
  ...nativeFontOptions(runStyle),fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
1241
- underline:run.underline ? {color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
1580
+ underline:run.underline ? {style:'sng',color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
1242
1581
  color,transparency,baseline:fragment?.baselineShift ? -fragment.baselineShift / fragment.fontSize * 2000 : undefined,
1243
1582
  hyperlink:run.link && /^(https?:|mailto:)/i.test(run.link) ? {url:run.link} : undefined,
1244
1583
  };
@@ -1265,11 +1604,11 @@ function addTablePayload(slide, table, region, context, options, path) {
1265
1604
  valign: cellStyle.verticalAlign ?? 'top',
1266
1605
  ...(cell.colSpan > 1 ? {colspan:cell.colSpan} : {}),
1267
1606
  ...(cell.rowSpan > 1 ? {rowspan:cell.rowSpan} : {}),
1268
- color: normalizeHex(baseColor),
1607
+ color: nativeColor(cellStyle.color, baseColor, context),
1269
1608
  // Rich runs carry resolved alpha. A translucent cell default would
1270
1609
  // overwrite an explicit opaque run because PptxGenJS inherits falsy 0.
1271
1610
  ...(cellStyle.color && !rich ? {transparency:alpha(baseColor)} : {}),
1272
- fill: { color: normalizeHex(baseFill), ...(cellStyle.fill ? {transparency:alpha(baseFill)} : {}) },
1611
+ fill: { color: nativeColor(cellStyle.fill, baseFill, context), ...(cellStyle.fill ? {transparency:alpha(baseFill)} : {}) },
1273
1612
  },
1274
1613
  };
1275
1614
  }));
@@ -1281,6 +1620,9 @@ function addTablePayload(slide, table, region, context, options, path) {
1281
1620
  x: region.x,
1282
1621
  y: region.y,
1283
1622
  w: region.w,
1623
+ // PowerPoint derives a table's height from its rows, and the preview draws
1624
+ // the rows (layout.height, never more than the composed box). The declared
1625
+ // frame height is the row total so the XML matches what both engines draw.
1284
1626
  h: layout.height / 96,
1285
1627
  rowH: layout.rows.map(row => row.box.height / 96),
1286
1628
  colW: Array(columnCount).fill(region.w / columnCount),
@@ -1311,20 +1653,34 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
1311
1653
  options.onDiagnostic?.(diagnostic);
1312
1654
  if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path, issues: [diagnostic]});
1313
1655
  }
1656
+ // Generated socials carry one link per explicit source line; wrapped lines share it.
1657
+ let sourceLineIndex = 0;
1314
1658
  for (const [index, line] of fit.lines.entries()) {
1659
+ const link = config.links?.[sourceLineIndex];
1660
+ if (fit.sourceLines?.[index]?.boundary === 'hard') sourceLineIndex += 1;
1315
1661
  if (!line&&!config.heading&&!config.sourceText&&!config.timeline&&!config.keepEmpty) continue;
1316
1662
  const alignment=fit.placement?.alignment??config.align??context.contentAlignment??'left',placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
1317
1663
  const sourceLine=fit.sourceLines?.[index],boundary=sourceLine?{boundary:sourceLine.boundary,separator:String(text).slice(sourceLine.end,sourceLine.nextStart)}:{};
1318
1664
  const objectName=config.heading?`OPF heading ${config.path} line ${index}`:config.timeline?`OPF timeline ${config.timeline.group} part ${config.timeline.part} line ${index}`:config.sourceText?`OPF text ${config.path} line ${index}`:config.objectName?`${config.objectName} line ${index}`:undefined;
1319
1665
  if(config.heading)context.headingTags.set(objectName,{v:1,group:config.path,field:config.heading,line:index,count:fit.lines.length,...boundary});
1320
1666
  else if(config.timeline)context.timelineTags.set(objectName,{v:1,role:'text',group:config.timeline.group,part:config.timeline.part,line:index,count:fit.lines.length,...boundary,...(config.timeline.part===0&&index===0?{anchor:config.timeline.anchor}:{})});
1321
- else if(config.furniture)context.furnitureTags.set(objectName,{v:1,role:'text',...config.furniture,line:index,count:fit.lines.length,...boundary});
1667
+ else if(config.furniture){
1668
+ context.furnitureTags.set(objectName,{v:1,role:'text',...config.furniture,line:index,count:fit.lines.length,...boundary});
1669
+ if(config.liveFields?.[index]?.length)context.furnitureFields.set(objectName,{text:line,fields:config.liveFields[index]});
1670
+ }
1671
+ else if(config.media&&context.provenanceMode!==false)context.mediaTags.set(objectName,{v:1,role:'caption',path:config.path,line:index,count:fit.lines.length,boundary:sourceLine?.boundary??'end',...(context.provenanceMode==='full'?{fingerprint:mediaTextFingerprint(line),...boundary}:{})});
1322
1672
  else if(config.sourceText)context.plainTextTags.set(objectName,{v:1,group:config.path,line:index,count:fit.lines.length,...boundary});
1323
1673
  const area=placed?{x:(placed.x+placed.width*factor-box.width*factor)/96,y:(placed.baseline-fit.fontSize)/96,w:box.width/96,h:placed.height/96}:{x:box.x/96,y:(box.y+index*fit.lineHeight)/96,w:box.width/96,h:fit.lineHeight/96};
1324
- slide.addText(line, {
1674
+ // A run-level link keeps the muted, non-underlined furniture look of the preview (hlinkClr=tx, u=none).
1675
+ const lineColor = normalizeHex(config.color ?? context.colors.text);
1676
+ slide.addText(link?.href && line ? [{text: line, options: {hyperlink: {url: link.href}, color: lineColor, underline: {style: 'none'}}}] : line, {
1325
1677
  ...textBoxOptions(area, context, fit.fontSize * .75),
1326
1678
  ...nativeFontOptions(style),
1327
- color: normalizeHex(config.color ?? context.colors.text), align: alignment,
1679
+ color: lineColor, align: alignment,
1680
+ // Reuse the frame's existing relationship to cover blank caption boxes.
1681
+ // PptxGenJS also inherits it into runs; keep their native text styling.
1682
+ hyperlink: config.hyperlink,
1683
+ underline: config.hyperlink ? {style: 'none'} : undefined,
1328
1684
  tabStops:sourceLine?.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'})),
1329
1685
  objectName,
1330
1686
  fit: 'none', wrap: false, lineSpacingMultiple: 1,
@@ -1339,6 +1695,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
1339
1695
  if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
1340
1696
  return;
1341
1697
  }
1698
+ const staticDates = new Map();
1342
1699
  for(const [index,part]of layout.parts.entries()){
1343
1700
  if(part.type==='image'){
1344
1701
  const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
@@ -1346,10 +1703,27 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
1346
1703
  if (objectName) context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:index});
1347
1704
  }else{
1348
1705
  if(!part.fit?.sourceLines)throw new OPFPptxError('missing-furniture-layout','Repeated text requires accepted source lines from core.',{path:part.path});
1349
- addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.colors.mutedText,keepEmpty:true,objectName:`OPF furniture ${slideIndex} part ${index}`,furniture:{group:String(slideIndex),part:index}});
1706
+ // Slide numbers and current dates become native PowerPoint fields; {total}
1707
+ // and fixed dates stay fixed text. A current date whose pattern has no
1708
+ // en-US field type is written as its laid-out text.
1709
+ const fields=[];
1710
+ for(const field of furniturePartFields(part)){
1711
+ const nativeType=nativeFieldType(field);
1712
+ if(nativeType)fields.push({...field,nativeType});
1713
+ else options.onDiagnostic?.({code:'furniture-date-fixed',path:part.path,message:`dateFormat '${field.format}' has no PowerPoint en-US date field; the current date is exported as fixed text that PowerPoint will not update.`});
1714
+ }
1715
+ const liveFields=fields.length?lineFields(part.text,fields,part.fit.sourceLines,part.fit.lines):undefined;
1716
+ for(const field of fields.filter(field => !lineFields(part.text,[field],part.fit.sourceLines,part.fit.lines).some(line => line.length))) {
1717
+ options.onDiagnostic?.({code:'furniture-field-fixed',path:part.path,message:`The ${field.type} field range ${field.start}..${field.end} does not fit one accepted text line. It is exported as static text that PowerPoint will not update; native compatibility remains a separate gate.`});
1718
+ if(context.provenanceMode === 'full') {
1719
+ const marker = staticDateFallback(part,field);
1720
+ if(marker) staticDates.set(index,marker);
1721
+ }
1722
+ }
1723
+ addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.colors.mutedText,keepEmpty:true,objectName:`OPF furniture ${slideIndex} part ${index}`,furniture:{group:String(slideIndex),part:index},liveFields,links:part.links});
1350
1724
  }
1351
1725
  }
1352
- const manifest = furnitureManifest(presentation, source, layout, slideIndex);
1726
+ const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates);
1353
1727
  if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
1354
1728
  }
1355
1729
 
@@ -1439,7 +1813,101 @@ function addTimelinePayload(slide, value, layout, context, options, path) {
1439
1813
  }
1440
1814
  }
1441
1815
 
1442
- function addPlaceholderPayload(slide, label, value, region, context) {
1816
+ // Mirror the renderer's media placeholder: a bordered surface, a centred 72px
1817
+ // play badge and a centred caption (title, then source). Native video embedding
1818
+ // is not attempted; the caption keeps the reference editable.
1819
+ function addMediaPayload(slide, presentation, value, region, path, context, options) {
1820
+ const box = pixelBox(region);
1821
+ const icon = {x: box.x + (box.width - 72) / 2, y: box.y + (box.height - 72) / 2};
1822
+ // The frame links to a web source (after asset dereferencing), so the
1823
+ // reference stays usable in PowerPoint; OPF_MEDIA_V1 keeps the OPF value.
1824
+ const resolved = dereferenceAsset(value, presentation, path), source = typeof resolved === "string" ? resolved : resolved?.src;
1825
+ const hyperlink = typeof source === "string" && /^https?:\/\//i.test(source) ? {url: source} : undefined;
1826
+ const names = {frame: `OPF media ${path} frame`, badge: `OPF media ${path} badge`, play: `OPF media ${path} play`};
1827
+ if (context.provenanceMode !== false) {
1828
+ context.mediaTags.set(names.frame, mediaFrameRecord(value, path, presentation, context.provenanceMode, options.onDiagnostic));
1829
+ context.mediaTags.set(names.badge, {v: 1, role: "badge", path});
1830
+ context.mediaTags.set(names.play, {v: 1, role: "play", path});
1831
+ }
1832
+ slide.addShape("rect", {x: region.x, y: region.y, w: region.w, h: region.h, fill: {color: context.colors.surface}, line: {color: context.colors.border, pt: 0.75}, hyperlink, objectName: names.frame});
1833
+ slide.addShape("ellipse", {x: icon.x / 96, y: icon.y / 96, w: 72 / 96, h: 72 / 96, fill: {color: context.colors.accent}, line: {transparency: 100}, hyperlink: hyperlink && {url: source}, objectName: names.badge});
1834
+ slide.addShape("triangle", {x: (icon.x + 28) / 96, y: (icon.y + 22) / 96, w: 28 / 96, h: 28 / 96, rotate: 90, fill: {color: "FFFFFF"}, line: {transparency: 100}, hyperlink: hyperlink && {url: source}, objectName: names.play});
1835
+ addMeasuredPayloadText(slide, mediaCaption(value), {...box, y: icon.y + 72 + 20, height: 50}, context, options,
1836
+ {path, fontSize: 18, fontFamily: context.fonts.body, fontWeight: 600, align: "center", color: context.colors.mutedText, objectName: `OPF media ${path} caption`, media: true, keepEmpty: true, hyperlink});
1837
+ }
1838
+
1839
+ // The asset an image block names, layered as the preview layers it: fields on the
1840
+ // block win over the registry entry it references, and an unknown reference keeps
1841
+ // the block's own fields.
1842
+ function placeholderAsset(asset, presentation) {
1843
+ const plain = value => typeof value === "string" ? { src: value } : isPlainObject(value) ? value : { src: "" };
1844
+ let current = plain(asset);
1845
+ const seen = new Set();
1846
+ while (typeof current.src === "string" && current.src.startsWith("asset:")) {
1847
+ const id = current.src.slice(6);
1848
+ if (seen.has(id) || !Object.hasOwn(presentation.assets ?? {}, id)) break;
1849
+ seen.add(id);
1850
+ const { src: _source, ...overrides } = current;
1851
+ current = { ...plain(presentation.assets[id]), ...overrides };
1852
+ }
1853
+ return current;
1854
+ }
1855
+
1856
+ // An image that cannot be embedded exports the preview's placeholder, not a second
1857
+ // design: a dashed panel with a centered "Image unavailable" over the asset's alt
1858
+ // text (else title, else "Image"), set bold at 20 px (shrinking to the composition
1859
+ // minimum) in the readable text colour for the panel, or a cross when the label
1860
+ // cannot fit even at that minimum. The panel carries the accessible name the
1861
+ // preview gives its group, "Image unavailable: <description>".
1862
+ function addImagePlaceholder(slide, presentation, asset, region, path, context, options) {
1863
+ const layered = placeholderAsset(asset, presentation);
1864
+ const description = String(layered.alt ?? layered.title ?? "Image");
1865
+ const label = `Image unavailable\n${description}`;
1866
+ // The preview rejects characters DrawingML/SVG XML cannot represent when it draws the label.
1867
+ // The cross and the accessible name would otherwise carry them into the slide XML, so check
1868
+ // up front and fail the same way whichever form the placeholder takes.
1869
+ const invalid = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF￾￿]/u.exec(label);
1870
+ 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 });
1871
+ const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
1872
+ const box = pixelBox(region);
1873
+ const padding = Math.min(24 * scale, box.width * .06, box.height * .1);
1874
+ const inner = { x: box.x + padding, y: box.y + padding, width: Math.max(1, box.width - padding * 2), height: Math.max(1, box.height - padding * 2) };
1875
+ const surface = normalizeHex(context.colors.surface);
1876
+ const textColor = normalizeHex(textColorForFill(`#${surface}`, `#${context.colors.text}`));
1877
+ const style = resolveTextStyle({ fontFamily: context.fonts.body, fontWeight: 600, path }, options.textMeasurement);
1878
+ const measurer = textWidthMeasurer(style, options.textMeasurement);
1879
+ const minimum = (context.composition?.minFontSize ?? 16) * scale;
1880
+ const fit = fitText(label, inner, 20 * scale, minimum, measurer);
1881
+ const objectName = `OPF image placeholder ${context.imagePlaceholders.size + 1}`;
1882
+ context.imagePlaceholders.set(objectName, `Image unavailable: ${description}`);
1883
+ slide.addShape("rect", {
1884
+ x: region.x, y: region.y, w: region.w, h: region.h,
1885
+ fill: { color: surface },
1886
+ line: { color: context.colors.border, width: .75, dashType: "dash" },
1887
+ objectName
1888
+ });
1889
+ if (!fit.overflow) {
1890
+ // The lines are centered vertically in the padded box. Fit against the shifted
1891
+ // box so any per-line placement carries the same offset.
1892
+ const offset = Math.max(0, (inner.height - fit.lines.length * fit.lineHeight) / 2);
1893
+ const centered = { ...inner, y: inner.y + offset };
1894
+ addMeasuredPayloadText(slide, label, centered, context, options, {
1895
+ path, fit: fitText(label, centered, 20 * scale, minimum, measurer), textStyle: style,
1896
+ diagnosticsHandled: true, align: "center", color: textColor
1897
+ });
1898
+ return;
1899
+ }
1900
+ // A status indicator, not shortened authored content: the full description stays
1901
+ // in the panel's accessible name.
1902
+ const size = Math.max(0, Math.min(inner.width, inner.height, 24 * scale));
1903
+ if (!size) return;
1904
+ const icon = { x: inner.x + (inner.width - size) / 2, y: inner.y + (inner.height - size) / 2 };
1905
+ const line = { color: textColor, width: Math.min(2 * scale, size / 8) * .75 };
1906
+ 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 });
1907
+ }
1908
+
1909
+ // A placeholder names what is missing in plain words. It never dumps the source value, so no data or URL lands in slide text.
1910
+ function addPlaceholderPayload(slide, label, description, region, context) {
1443
1911
  slide.addShape("rect", {
1444
1912
  x: region.x,
1445
1913
  y: region.y,
@@ -1448,7 +1916,7 @@ function addPlaceholderPayload(slide, label, value, region, context) {
1448
1916
  fill: { color: context.colors.surface, transparency: 10 },
1449
1917
  line: { color: context.colors.border, pt: 0.75 }
1450
1918
  });
1451
- slide.addText(`${label}\n${summarizeValue(value)}`, {
1919
+ slide.addText(`${label}\n${description}`, {
1452
1920
  x: region.x + 0.12,
1453
1921
  y: region.y + 0.12,
1454
1922
  w: Math.max(0.2, region.w - 0.24),
@@ -1463,6 +1931,47 @@ function addPlaceholderPayload(slide, label, value, region, context) {
1463
1931
  });
1464
1932
  }
1465
1933
 
1934
+ // FF-31: a measurement provider may preview a chosen family with another face
1935
+ // (Carlito for Aptos or Calibri, Gelasio for Georgia, a caller alias, a generic
1936
+ // fallback). That substitute changes measurement and drawing only. The package
1937
+ // always names the developer's chosen family. Measurement still uses the
1938
+ // substitute, because the provider resolves the chosen family again on every
1939
+ // measure/outline call, so layout is unchanged.
1940
+ const FACE_STYLE_WORDS = /^(?:(?:thin|hairline|extra ?light|ultra ?light|light|semi ?light|demi ?light|book|regular|normal|medium|semi ?bold|demi ?bold|bold|extra ?bold|ultra ?bold|black|heavy|extra ?black|ultra ?black|italic|oblique)\s*)+$/i;
1941
+ function sameTypeface(requested, resolved) {
1942
+ if (typeof resolved !== 'string') return false;
1943
+ const want = requested.trim().toLowerCase(), got = resolved.trim().toLowerCase();
1944
+ // A legacy four-style family such as "Roboto Medium" is the chosen typeface.
1945
+ return got === want || got.startsWith(`${want} `) && FACE_STYLE_WORDS.test(got.slice(want.length + 1));
1946
+ }
1947
+ function chosenFamilyMeasurement(measurement) {
1948
+ if (!measurement || typeof measurement.resolveStyle !== 'function') return measurement;
1949
+ const resolveFont = typeof measurement.resolveFont === 'function' ? style => measurement.resolveFont(style) : undefined;
1950
+ const wrapped = {
1951
+ measure: (text, size, style) => measurement.measure(text, size, style),
1952
+ resolveStyle: style => {
1953
+ const resolved = measurement.resolveStyle(style);
1954
+ const requested = style?.fontFamily;
1955
+ // Theme tokens are provider business; the exporter always passes concrete families.
1956
+ if (typeof requested !== 'string' || requested.startsWith('+')) return resolved;
1957
+ // opf-render reports whether a face came from the chosen family or a substitute;
1958
+ // other providers are judged by family name.
1959
+ const resolution = resolveFont?.(style);
1960
+ const faceFamily = resolved?.fontFace?.family;
1961
+ const substituted = typeof resolution?.substitute === 'boolean'
1962
+ ? resolution.substitute
1963
+ : !sameTypeface(requested, resolved?.fontFamily) || typeof faceFamily === 'string' && !sameTypeface(requested, faceFamily);
1964
+ if (!substituted) return resolved;
1965
+ const chosen = {...style};
1966
+ delete chosen.fontFace;
1967
+ return chosen;
1968
+ },
1969
+ };
1970
+ if (typeof measurement.outlineBounds === 'function') wrapped.outlineBounds = (text, size, style) => measurement.outlineBounds(text, size, style);
1971
+ if (resolveFont) wrapped.resolveFont = resolveFont;
1972
+ return wrapped;
1973
+ }
1974
+
1466
1975
  function nativeFontOptions(style) {
1467
1976
  const face=style.fontFace;
1468
1977
  if(face!==undefined) {
@@ -1509,9 +2018,9 @@ function textRuns(value, context, fallbackFontSize) {
1509
2018
  options: {
1510
2019
  bold: run?.bold,
1511
2020
  italic: run?.italic,
1512
- underline: run?.underline ? { color: normalizeHex(exportColor(run?.color, context, context.colors.text)) } : undefined,
2021
+ underline: run?.underline ? { style: "sng", color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context) } : undefined,
1513
2022
  strike: run?.strikethrough ? "sngStrike" : undefined,
1514
- color: normalizeHex(exportColor(run?.color, context, context.colors.text)),
2023
+ color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context),
1515
2024
  fontFace: run?.fontFamily ?? context.fonts.body,
1516
2025
  fontSize: run?.fontSize ?? fallbackFontSize,
1517
2026
  superscript: run?.superscript,
@@ -1522,10 +2031,52 @@ function textRuns(value, context, fallbackFontSize) {
1522
2031
  });
1523
2032
  }
1524
2033
 
2034
+ /**
2035
+ * Series for a native chart, or `{reason, message}` when the data cannot be plotted (the caller reports it).
2036
+ * A first column that holds categories is the usual shape. Chart types whose data is one column of values
2037
+ * (histogram, dot plot) have no category column, so their values are plotted directly: a histogram is binned
2038
+ * into equal-width bins and exported as a column chart of the counts, every other type plots the values against
2039
+ * their row numbers. `adapted` names that transformation so it is reported, never silent.
2040
+ */
1525
2041
  function toPptxChartData(chart) {
1526
2042
  const data = chart?.data;
1527
- if (!data || !Array.isArray(data.columns) || !Array.isArray(data.rows)) return null;
1528
- if (data.columns.length < 2 || data.rows.length === 0) return null;
2043
+ const unplottable = (reason, summary, message) => ({reason, summary, message: `${message} No native chart was exported; a placeholder frame stands in for it.`});
2044
+ if (!data || !Array.isArray(data.columns) || !Array.isArray(data.rows)) {
2045
+ return unplottable("data-not-inline", "Chart data is not inline, so it cannot be drawn here.", "The chart data is not inline columns and rows (for example an external data source). Supply inline columns and rows.");
2046
+ }
2047
+ if (data.rows.length === 0) return unplottable("no-rows", "The chart has no data rows.", "The chart data has no rows.");
2048
+ if (data.columns.length === 0) return unplottable("no-columns", "The chart has no data columns.", "The chart data has no columns.");
2049
+ const mapped = mapChartType(chart.type);
2050
+ if (data.columns.length === 1) {
2051
+ const heading = stringifyText(data.columns[0]);
2052
+ const cell = (row) => Array.isArray(row) ? row[0] : row;
2053
+ // The same parsing as multi-column charts (`numericValue`: "12%", "$5", "1,234"); a cell that holds no number is skipped, never plotted as 0.
2054
+ const points = data.rows.map((row, index) => ({row: index + 1, value: parsedNumber(cell(row))})).filter((point) => point.value !== null);
2055
+ if (points.length === 0) {
2056
+ return unplottable("single-column-not-numeric", "The chart's data column has no numbers.", `The only chart data column '${heading}' holds no numbers, and a chart needs values to plot.`);
2057
+ }
2058
+ const skipped = data.rows.length - points.length;
2059
+ const skippedNote = skipped ? ` (${skipped} non-numeric ${skipped === 1 ? "cell was" : "cells were"} skipped)` : "";
2060
+ if (String(chart.type ?? "").toLowerCase() === "histogram") {
2061
+ const bins = histogramBins(points.map((point) => point.value));
2062
+ return {
2063
+ type: "bar", barDir: "col", barGrouping: "clustered", heading: "Bin",
2064
+ series: [{name: "Frequency", labels: bins.map((bin) => bin.label), values: bins.map((bin) => bin.count)}],
2065
+ adapted: "histogram-binned",
2066
+ message: `The histogram's single data column '${heading}' (${points.length} values${skippedNote}) was binned into ${bins.length} equal-width bins and exported as a column chart of the counts; PowerPoint's own histogram chart is not exported, and the binned counts do not restore the raw values on re-import.`
2067
+ };
2068
+ }
2069
+ // Rows keep their own row numbers, so a skipped cell leaves a gap in the numbering.
2070
+ const labels = points.map((point) => String(point.row));
2071
+ const numbers = points.map((point) => point.value);
2072
+ const series = mapped.type === "scatter"
2073
+ ? [{name: "Row", labels, values: points.map((point) => point.row)}, {name: heading, labels, values: numbers}]
2074
+ : [{name: heading, labels, values: numbers}];
2075
+ return {
2076
+ ...mapped, series, heading: "Row", adapted: "row-numbers",
2077
+ message: `The chart's single data column '${heading}' has no category column, so its ${points.length} values${skippedNote} are plotted against their row numbers.`
2078
+ };
2079
+ }
1529
2080
 
1530
2081
  const labels = data.rows.map((row) => stringifyText(row?.[0]));
1531
2082
  const series = data.columns.slice(1).map((name, seriesIndex) => ({
@@ -1533,11 +2084,35 @@ function toPptxChartData(chart) {
1533
2084
  labels,
1534
2085
  values: data.rows.map((row) => numericValue(row?.[seriesIndex + 1]))
1535
2086
  }));
1536
- const mapped = mapChartType(chart.type);
1537
2087
 
1538
2088
  return { ...mapped, series };
1539
2089
  }
1540
2090
 
2091
+ /**
2092
+ * Equal-width bins (Sturges' count, at most 50) from the minimum to the maximum; every bin but the last is [low, high).
2093
+ * Arithmetic stays finite for any finite input (values of +-1e308 and denormals included): the bin edges are
2094
+ * computed by interpolating the edges rather than from a width, and a value is placed by comparing it with the edges.
2095
+ */
2096
+ function histogramBins(values) {
2097
+ const min = values.reduce((a, b) => Math.min(a, b)), max = values.reduce((a, b) => Math.max(a, b));
2098
+ const count = min === max ? 1 : Math.min(50, Math.ceil(Math.log2(values.length)) + 1);
2099
+ const edge = (index) => index === 0 ? min : index === count ? max : min / count * (count - index) + max / count * index;
2100
+ const bins = Array.from({length: count}, (_, index) => ({low: edge(index), high: edge(index + 1), count: 0}));
2101
+ // A value belongs to the last bin whose lower edge it reaches, so counts always agree with the labelled edges.
2102
+ for (const value of values) {
2103
+ let index = 0;
2104
+ while (index + 1 < count && value >= bins[index + 1].low) index++;
2105
+ bins[index].count++;
2106
+ }
2107
+ // Labels carry six significant digits, and more when that would make two bins read alike.
2108
+ for (let precision = 6; ; precision++) {
2109
+ const format = (value) => String(Number(value.toPrecision(precision)));
2110
+ const labels = bins.map((bin) => min === max ? format(min) : `${format(bin.low)}\u2013${format(bin.high)}`);
2111
+ if (new Set(labels).size === labels.length) return bins.map((bin, index) => ({...bin, label: labels[index]}));
2112
+ if (precision === 17) return bins.map((bin, index) => ({...bin, label: `${labels[index]} (bin ${index + 1})`}));
2113
+ }
2114
+ }
2115
+
1541
2116
  function mapChartType(type) {
1542
2117
  const normalized = String(type ?? "").toLowerCase();
1543
2118
  if (normalized.includes("pie")) return { type: "pie" };
@@ -1662,6 +2237,21 @@ function referenceId(reference) {
1662
2237
  return null;
1663
2238
  }
1664
2239
 
2240
+ const DEFAULT_SOURCE_PREFIX = "https://www.pptx.gallery/";
2241
+
2242
+ // Same order as opf-render's socialPlatformRecords so preview and export format
2243
+ // socials identically. Core applies inline document records first; then the
2244
+ // document catalog source (host-supplied options.catalogSources, or the bundled
2245
+ // catalog for pptx.gallery/pkg sources), injected options.catalogs, and the
2246
+ // bundled catalog (the engine default source also resolves to it).
2247
+ function socialPlatformRecords(presentation, options) {
2248
+ const kind = "socialPlatforms", source = presentation.catalogs?.[kind]?.source;
2249
+ const bySource = typeof source === "string" ? options.catalogSources?.[source] : undefined;
2250
+ const sourceRecords = bySource ? normalizeRecords(bySource)
2251
+ : typeof source === "string" && (source.startsWith(DEFAULT_SOURCE_PREFIX) || source.startsWith("pkg:@openpresentation/opf/")) ? defaultCatalog(kind) : [];
2252
+ return [...sourceRecords, ...normalizeRecords(options.catalogs?.[kind]), ...defaultCatalog(kind)];
2253
+ }
2254
+
1665
2255
  function defaultCatalog(kind) {
1666
2256
  return Array.isArray(bundledCatalogs[kind]) ? bundledCatalogs[kind] : [];
1667
2257
  }
@@ -1697,13 +2287,15 @@ function resolveBackground(value, colorScheme) {
1697
2287
  if (value.type === "theme" && value.slot) {
1698
2288
  return normalizeHex(colorScheme[value.slot] ?? colorScheme.light1 ?? "#FFFFFF", fallback);
1699
2289
  }
2290
+ // Like the SVG preview, text contrast follows a pattern's background color.
2291
+ if (value.type === "pattern") return normalizeHex(value.pattern?.backgroundColor ?? "#FFFFFF", fallback);
1700
2292
  if (value.backgroundColor) return normalizeHex(value.backgroundColor, fallback);
1701
2293
  }
1702
2294
  return normalizeHex(colorScheme.background ?? colorScheme.light1 ?? "#FFFFFF", fallback);
1703
2295
  }
1704
2296
 
1705
2297
  function resolveFonts(fontScheme) {
1706
- return {id:fontScheme.id,...resolveFontFamilies(fontScheme)};
2298
+ return {id:fontScheme.id,...resolveFontFamilies(fontScheme),scheme:{type:fontScheme.type,major:fontScheme.major,minor:fontScheme.minor}};
1707
2299
  }
1708
2300
 
1709
2301
 
@@ -1747,9 +2339,11 @@ async function normalizePptxZip(raw, context) {
1747
2339
  attachCodeTags(entries, context.codeTags);
1748
2340
  attachMetricTags(entries,context.metricTags);
1749
2341
  attachCardTags(entries,context.cardTags);
2342
+ attachMediaTags(entries,context.mediaTags);
1750
2343
  attachHeadingTags(entries,context.headingTags);
1751
2344
  attachPlainTextTags(entries,context.plainTextTags);
1752
2345
  attachTimelineTags(entries,context.timelineTags);
2346
+ attachFurnitureFields(entries,context.furnitureFields);
1753
2347
  attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests);
1754
2348
  for(const [part,bytes]of Object.entries(entries)){
1755
2349
  if(!/^ppt\/slides\/slide\d+\.xml$/.test(part))continue;
@@ -1766,15 +2360,21 @@ async function normalizePptxZip(raw, context) {
1766
2360
  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>`)));
1767
2361
  }
1768
2362
  }
2363
+ applyChartFonts(entries,context.chartFonts,parseRelationships);
2364
+ applyPitchFamilies(entries,context.fontPitch);
1769
2365
  const imageSources = new Map();
1770
2366
  for (const [part, bytes] of Object.entries(entries)) {
1771
2367
  if (!/^ppt\/slides\/slide\d+\.xml$/.test(part)) continue;
1772
2368
  const relationships = parseRelationships(entries, part);
1773
2369
  for (const [picture] of decodeText(bytes).matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
1774
- const placement = context.imagePlacements.get(picture.match(/name="(OPF image \d+)"/)?.[1]);
2370
+ const name = picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
2371
+ const placement = context.imagePlacements.get(name) ?? context.slideImages.get(name);
1775
2372
  const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
1776
2373
  if (placement) imageSources.set(relationships.get(id)?.path, placement.path);
1777
2374
  }
2375
+ const background = context.backgroundFills.get(part)?.image;
2376
+ const backgroundId = background && decodeText(bytes).match(/<p:bg>[\s\S]*?<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
2377
+ if (backgroundId) imageSources.set(relationships.get(backgroundId)?.path, background.path);
1778
2378
  }
1779
2379
  const imageMetadata = new Map();
1780
2380
  for (const [part, bytes] of Object.entries(entries)) {
@@ -1793,6 +2393,21 @@ async function normalizePptxZip(raw, context) {
1793
2393
  }
1794
2394
  imageMetadata.set(part, metadata);
1795
2395
  }
2396
+ // Slide images need the embedded dimensions; document provenance (FF-32)
2397
+ // later records the placed frames as slide geometry evidence.
2398
+ placeSlideImages(entries, context.slideImages, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
2399
+ throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path });
2400
+ });
2401
+ // Charts and notes follow the language and fonts of the slide they belong to.
2402
+ context.partSlides = new Map();
2403
+ for (const part of Object.keys(entries)) {
2404
+ const slide = /^ppt\/slides\/slide(\d+)\.xml$/.exec(part);
2405
+ if (!slide) continue;
2406
+ context.partSlides.set(part, Number(slide[1]) - 1);
2407
+ for (const relationship of parseRelationships(entries, part).values()) {
2408
+ if (/\/(?:chart|notesSlide)$/.test(relationship.type)) context.partSlides.set(relationship.path, Number(slide[1]) - 1);
2409
+ }
2410
+ }
1796
2411
  const output = {};
1797
2412
  const renameMaps = buildRenameMaps(Object.keys(entries));
1798
2413
  // The host may transform assets or supply a filename/MIME hint that no
@@ -1816,16 +2431,55 @@ async function normalizePptxZip(raw, context) {
1816
2431
  }];
1817
2432
  }
1818
2433
 
2434
+ finalizeFontsUsed(output);
2435
+ // Document references record evidence from the final normalized parts.
2436
+ if (context.documentProvenance) {
2437
+ const parts = Object.fromEntries(Object.entries(output).map(([path, [bytes]]) => [path, bytes]));
2438
+ try {
2439
+ attachDocumentProvenance(parts, context.documentProvenance);
2440
+ } catch (error) {
2441
+ throw new OPFPptxError("packaging-failed", "Document provenance tags could not be attached.", {cause: errorMessage(error)});
2442
+ }
2443
+ for (const [path, bytes] of Object.entries(parts)) output[path] = [bytes, {level: context.compressionLevel, mtime: context.zipDate}];
2444
+ }
2445
+
1819
2446
  // Sort after chart/worksheet renaming; source counters can cross digit widths.
1820
2447
  const sortedOutput = Object.fromEntries(Object.keys(output).sort().map(path => [path, output[path]]));
1821
- return zipSync(sortedOutput, {
2448
+ return stampGeneratedZip(zipSync(sortedOutput, {
1822
2449
  level: context.compressionLevel,
1823
2450
  mtime: context.zipDate
2451
+ }), context.zipDateStamp);
2452
+ }
2453
+
2454
+ function preserveCarriageReturns(xml, textElements) {
2455
+ // Only our generated text content is rewritten. Escaped literal entity text
2456
+ // remains escaped, and XML formatting outside these elements is untouched.
2457
+ return xml.replace(/(<([\w:]+)\b[^>]*>)([^<]*)(<\/\2>)/g, (element, open, name, text, close) =>
2458
+ textElements.has(name) ? open + text.replace(/\r/g, '&#13;') + close : element);
2459
+ }
2460
+
2461
+ const CORE_TEXT_ELEMENTS = new Set(['dc:title', 'dc:subject', 'dc:description', 'dc:creator']);
2462
+
2463
+ function preserveGeneratedNotes(xml, notes, path) {
2464
+ // PptxGenJS changes LF to CRLF. For authored CR, write the original text into
2465
+ // its one generated native notes body; nothing is retained in hidden tags.
2466
+ const text = notes.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
2467
+ .replace(/"/g, '&quot;').replace(/'/g, '&apos;').replace(/\r/g, '&#13;');
2468
+ let bodies = 0, texts = 0;
2469
+ const output = xml.replace(/<p:sp\b[^>]*>[\s\S]*?<\/p:sp>/g, shape => {
2470
+ if (!/<p:ph\b[^>]*\btype="body"/.test(shape)) return shape;
2471
+ bodies++;
2472
+ return shape.replace(/(<a:t\b[^>]*>)[^<]*(<\/a:t>)/g, (_, open, close) => {
2473
+ texts++;
2474
+ return open + text + close;
2475
+ });
1824
2476
  });
2477
+ if (bodies !== 1 || texts !== 1) throw new OPFPptxError('packaging-failed', 'Generated notes must have one body text element.', {path});
2478
+ return output;
1825
2479
  }
1826
2480
 
1827
2481
  function normalizeCoreProperties(xml, timestamp) {
1828
- return xml
2482
+ return preserveCarriageReturns(xml, CORE_TEXT_ELEMENTS)
1829
2483
  .replace(/<dcterms:created xsi:type="dcterms:W3CDTF">[^<]*<\/dcterms:created>/g, `<dcterms:created xsi:type="dcterms:W3CDTF">${timestamp}</dcterms:created>`)
1830
2484
  .replace(/<dcterms:modified xsi:type="dcterms:W3CDTF">[^<]*<\/dcterms:modified>/g, `<dcterms:modified xsi:type="dcterms:W3CDTF">${timestamp}</dcterms:modified>`);
1831
2485
  }
@@ -1840,6 +2494,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
1840
2494
  }
1841
2495
  if (isXmlPart(path)) {
1842
2496
  let xml=decodeText(bytes);
2497
+ if (context.notesWithCarriageReturns.has(path)) xml = preserveGeneratedNotes(xml, context.notesWithCarriageReturns.get(path), path);
1843
2498
  if (path === '[Content_Types].xml') {
1844
2499
  // PptxGenJS 4.0.1 emits one slide-master override per slide even
1845
2500
  // though it creates only the actual master parts. Omit phantom master
@@ -1851,9 +2506,22 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
1851
2506
  `<Override PartName="/${part}" ContentType="${metadata.mediaType}"/>`).join('');
1852
2507
  xml = xml.replace('</Types>', `${overrides}</Types>`);
1853
2508
  }
2509
+ if (/^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(path)) xml = themeMasterBulletFonts(xml);
2510
+ if (path === 'ppt/theme/theme1.xml') xml = writeThemeColors(xml, {colors: context.themeColors, schemeName: context.schemeName, themeName: context.themeName});
1854
2511
  if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) {
1855
2512
  const fill = context.backgroundFills.get(path);
1856
- if (fill) xml = xml.replace(/<p:bg>[\s\S]*?<\/p:bg>/, `<p:bg><p:bgPr>${fill}<a:effectLst/></p:bgPr></p:bg>`);
2513
+ if (typeof fill === 'string') xml = xml.replace(/<p:bg>[\s\S]*?<\/p:bg>/, `<p:bg><p:bgPr>${fill}<a:effectLst/></p:bgPr></p:bg>`);
2514
+ else if (fill?.image) {
2515
+ // Fit the exact raster PptxGenJS embedded for this slide background.
2516
+ const backgroundRelationships = parseRelationships(entries, path);
2517
+ xml = xml.replace(/<p:bg>[\s\S]*?<\/p:bg>/, background => {
2518
+ const id = background.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
2519
+ const metadata = imageMetadata.get(backgroundRelationships.get(id)?.path);
2520
+ if (!id || !metadata) throw new OPFPptxError("unsupported-image-dimensions", "Background image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path: fill.image.path });
2521
+ if ((metadata.orientation ?? 1) !== 1) fill.image.report?.({code: 'unsupported-background-image-orientation', path: fill.image.path, message: 'A slide background picture fill cannot rotate or mirror its image; the JPEG EXIF orientation is not applied in the native background.'});
2522
+ return `<p:bg><p:bgPr>${nativeImageBackgroundFill(id, metadata, fill.image)}<a:effectLst/></p:bgPr></p:bg>`;
2523
+ });
2524
+ }
1857
2525
  // PptxGenJS table IDs can collide with other objects on the same slide.
1858
2526
  // Preserve existing IDs and allocate unused IDs only for duplicates. This
1859
2527
  // export path creates no connector attachments or animation ID references.
@@ -1913,7 +2581,8 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
1913
2581
  if (!border) continue;
1914
2582
  const borderHex = exportColor(border.color, context, context.colors.border);
1915
2583
  const borderOpacity = borderHex.length === 8 ? parseInt(borderHex.slice(6), 16) / 255 : 1;
1916
- const fill = border.width === 0 ? '<a:noFill/>' : nativeBackgroundFill({type:'solid',color:'#' + borderHex.slice(0, 6), opacity: borderOpacity},{width:1,height:1}, context.colors.border);
2584
+ const borderScheme = borderOpacity === 1 ? schemeColorValue(border.color, borderHex, context) : undefined;
2585
+ const fill = border.width === 0 ? '<a:noFill/>' : borderScheme ? `<a:solidFill><a:schemeClr val="${borderScheme}"/></a:solidFill>` : nativeBackgroundFill({type:'solid',color:'#' + borderHex.slice(0, 6), opacity: borderOpacity},{width:1,height:1}, context.colors.border);
1917
2586
  const dash = {solid:'solid',dash:'dash',dot:'sysDot'}[border.dash ?? 'solid'];
1918
2587
  const line = `<a:${native} w="${Math.round(border.width * table.scale * 9525)}" cap="flat" cmpd="sng" algn="ctr">${fill}<a:prstDash val="${dash}"/></a:${native}>`;
1919
2588
  const existing = new RegExp(`<a:${native}\\b[^>]*>[\\s\\S]*?<\\/a:${native}>`);
@@ -1946,11 +2615,19 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
1946
2615
  // Native bullets otherwise inherit the first rich run's size, font and
1947
2616
  // color, which can differ from the measured list marker.
1948
2617
  xml=xml.replace(/<p:sp>([\s\S]*?)<\/p:sp>/g,(shape)=>{
1949
- const marker=context.listMarkers.get(shape.match(/name="(OPF list paragraph \d+)"/)?.[1]);
2618
+ const marker=context.listMarkers.get(shape.match(/name="(OPF list [^"]* line \d+)"/)?.[1]);
1950
2619
  if(!marker)return shape;
1951
2620
  const family=marker.fontFamily.replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[char]));
1952
2621
  return shape.replace(/<a:buSzPct val="100000"\/>/g,`<a:buClr><a:srgbClr val="${marker.color}"/></a:buClr><a:buSzPts val="${Math.round(marker.fontSize*100)}"/><a:buFont typeface="${family}"/>`);
1953
2622
  });
2623
+ // PptxGenJS gives shapes no alternative text. An unavailable image's panel
2624
+ // carries the accessible name the preview gives its group.
2625
+ xml=xml.replace(/<p:cNvPr id="(\d+)" name="(OPF image placeholder \d+)">/g,(node,id,name)=>{
2626
+ const description=context.imagePlaceholders.get(name);
2627
+ if(description===undefined)return node;
2628
+ const escapes={'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;','\r':'&#13;','\n':'&#10;','\t':'&#9;'};
2629
+ return `<p:cNvPr id="${id}" name="${name}" descr="${description.replace(/[&<>"'\r\n\t]/g,char=>escapes[char])}">`;
2630
+ });
1954
2631
  // PptxGenJS 4 emits pPr before each rich run. OOXML allows one pPr,
1955
2632
  // before all runs. Paragraph options belong to the first run.
1956
2633
  xml=xml.replace(/<a:p>([\s\S]*?)<\/a:p>/g,(_,body)=>{
@@ -1959,11 +2636,26 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
1959
2636
  return `<a:p>${properties}${content}</a:p>`;
1960
2637
  });
1961
2638
  }
2639
+ // theme1.xml: FF-24 colors and names above, then FF-07 fonts here; they touch disjoint elements.
2640
+ if (context.scriptFonts) xml = partScriptFonts(path, xml, context.scriptFonts, context.partSlides.get(path) ?? 0);
1962
2641
  return encodeText(normalizePartReferences(xml, renameMaps));
1963
2642
  }
1964
2643
  return bytes;
1965
2644
  }
1966
2645
 
2646
+ // The vendored PptxGenJS 4.0.1 master hard-codes Arial as the bullet font on
2647
+ // all nine bodyStyle levels, while each level's text already uses +mn-lt.
2648
+ // Point those bullets at the same theme minor (body) font so a document's
2649
+ // fontScheme also governs master bullets. a:buFont is CT_TextFont, like
2650
+ // a:latin, so the theme reference is schema-valid. Slide list markers keep
2651
+ // their explicit per-shape buFont; only the master bodyStyle is touched.
2652
+ const VENDOR_MASTER_BULLET_FONT = '<a:buFont typeface="Arial" pitchFamily="34" charset="0"/>';
2653
+ const THEME_MINOR_BULLET_FONT = '<a:buFont typeface="+mn-lt"/>';
2654
+ function themeMasterBulletFonts(xml) {
2655
+ return xml.replace(/<p:bodyStyle>[\s\S]*?<\/p:bodyStyle>/, bodyStyle =>
2656
+ bodyStyle.split(VENDOR_MASTER_BULLET_FONT).join(THEME_MINOR_BULLET_FONT));
2657
+ }
2658
+
1967
2659
  function isXmlPart(path) {
1968
2660
  return path.endsWith(".xml") || path.endsWith(".rels") || path === "[Content_Types].xml";
1969
2661
  }
@@ -2021,10 +2713,82 @@ function normalizeNestedZip(bytes, context) {
2021
2713
  mtime: context.zipDate
2022
2714
  }];
2023
2715
  }
2024
- return zipSync(output, {
2716
+ return stampGeneratedZip(zipSync(output, {
2025
2717
  level: context.compressionLevel,
2026
2718
  mtime: context.zipDate
2027
- });
2719
+ }), context.zipDateStamp);
2720
+ }
2721
+
2722
+ // Explicit ZIP dates use UTC calendar fields. fflate clones mtime and reads
2723
+ // local fields, so passing a Date cannot express this reliably (including DST
2724
+ // gaps and skipped civil days). Leave the established default path unchanged.
2725
+ function resolveZipDateStamp(value) {
2726
+ if (value === undefined) return null;
2727
+ const invalid = () => {
2728
+ throw new OPFPptxError('invalid-zip-date', 'zipDate must be a valid Date, finite epoch milliseconds, an ISO date, or an ISO datetime with Z/offset, within UTC years 1980–2099.', {path: 'options.zipDate'});
2729
+ };
2730
+ let time;
2731
+ if (typeof value === 'string') {
2732
+ const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:\d{2}))?$/.exec(value);
2733
+ if (!match) invalid();
2734
+ const [, year, month, day, hour = '00', minute = '00', second = '00', fraction = '', zone = 'Z'] = match;
2735
+ const parts = [year, month, day, hour, minute, second].map(Number);
2736
+ if (parts[1] < 1 || parts[1] > 12 || parts[2] < 1 || parts[3] > 23 || parts[4] > 59 || parts[5] > 59) invalid();
2737
+ const date = new Date(0);
2738
+ date.setUTCFullYear(parts[0], parts[1] - 1, parts[2]);
2739
+ date.setUTCHours(parts[3], parts[4], parts[5], Number(fraction.slice(0, 3).padEnd(3, '0')));
2740
+ if (date.getUTCFullYear() !== parts[0] || date.getUTCMonth() !== parts[1] - 1 || date.getUTCDate() !== parts[2]) invalid();
2741
+ const offsetHours = zone === 'Z' ? 0 : Number(zone.slice(1, 3));
2742
+ const offsetMinutes = zone === 'Z' ? 0 : Number(zone.slice(4, 6));
2743
+ if (offsetHours > 23 || offsetMinutes > 59) invalid();
2744
+ const offset = (offsetHours * 60 + offsetMinutes) * (zone[0] === '-' ? -1 : 1);
2745
+ time = date.getTime() - offset * 60000;
2746
+ } else if (typeof value === 'number') {
2747
+ time = value;
2748
+ } else {
2749
+ // The native brand check accepts cross-realm Dates without coercing objects.
2750
+ try { time = Date.prototype.getTime.call(value); } catch { invalid(); }
2751
+ }
2752
+ const date = new Date(time);
2753
+ const year = date.getUTCFullYear();
2754
+ if (!Number.isFinite(time) || !Number.isFinite(year) || year < 1980 || year > 2099) invalid();
2755
+ return (((year - 1980) << 25) | ((date.getUTCMonth() + 1) << 21) | (date.getUTCDate() << 16) |
2756
+ (date.getUTCHours() << 11) | (date.getUTCMinutes() << 5) | (date.getUTCSeconds() >> 1)) >>> 0;
2757
+ }
2758
+
2759
+ function stampGeneratedZip(bytes, stamp) {
2760
+ if (stamp === null) return bytes;
2761
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
2762
+ const fail = () => { throw new OPFPptxError('packaging-failed', 'Generated ZIP headers could not be timestamped safely.'); };
2763
+ const end = bytes.length - 22;
2764
+ if (end < 0 || view.getUint32(end, true) !== 0x06054b50 || view.getUint16(end + 4, true) !== 0 ||
2765
+ view.getUint16(end + 6, true) !== 0 || view.getUint16(end + 20, true) !== 0) fail();
2766
+ const count = view.getUint16(end + 10, true);
2767
+ const start = view.getUint32(end + 16, true);
2768
+ if (count !== view.getUint16(end + 8, true) || start + view.getUint32(end + 12, true) !== end) fail();
2769
+ const positions = [];
2770
+ let central = start, localEnd = 0;
2771
+ for (let i = 0; i < count; i++) {
2772
+ if (central + 46 > end || view.getUint32(central, true) !== 0x02014b50) fail();
2773
+ const nameSize = view.getUint16(central + 28, true);
2774
+ const next = central + 46 + nameSize + view.getUint16(central + 30, true) + view.getUint16(central + 32, true);
2775
+ const local = view.getUint32(central + 42, true);
2776
+ if (next > end || local !== localEnd || local + 30 > start || view.getUint32(local, true) !== 0x04034b50 ||
2777
+ view.getUint16(local + 26, true) !== nameSize || view.getUint16(central + 34, true) !== 0) fail();
2778
+ const content = local + 30 + nameSize + view.getUint16(local + 28, true);
2779
+ localEnd = content + view.getUint32(central + 20, true);
2780
+ if (content > start || localEnd > start || view.getUint32(local + 18, true) !== view.getUint32(central + 20, true) ||
2781
+ view.getUint32(local + 14, true) !== view.getUint32(central + 16, true) ||
2782
+ view.getUint32(local + 22, true) !== view.getUint32(central + 24, true) ||
2783
+ view.getUint16(local + 6, true) !== view.getUint16(central + 8, true) ||
2784
+ view.getUint16(local + 8, true) !== view.getUint16(central + 10, true)) fail();
2785
+ for (let j = 0; j < nameSize; j++) if (bytes[local + 30 + j] !== bytes[central + 46 + j]) fail();
2786
+ positions.push(local + 10, central + 12);
2787
+ central = next;
2788
+ }
2789
+ if (central !== end || localEnd !== start) fail();
2790
+ for (const position of positions) view.setUint32(position, stamp, true);
2791
+ return bytes;
2028
2792
  }
2029
2793
 
2030
2794
  function escapeRegExp(value) {
@@ -2063,16 +2827,15 @@ function stringifyText(value) {
2063
2827
  return String(value);
2064
2828
  }
2065
2829
 
2066
- function numericValue(value) {
2830
+ /** The number a chart cell holds ("12%", "$5" and "1,234" parse), or null when it holds none. */
2831
+ function parsedNumber(value) {
2067
2832
  if (typeof value === "number" && Number.isFinite(value)) return value;
2068
2833
  const parsed = Number.parseFloat(String(value ?? "").replace(/[^0-9.-]/g, ""));
2069
- return Number.isFinite(parsed) ? parsed : 0;
2834
+ return Number.isFinite(parsed) ? parsed : null;
2070
2835
  }
2071
2836
 
2072
- function summarizeValue(value) {
2073
- const text = stringifyText(value);
2074
- if (text.length > 160) return `${text.slice(0, 157)}...`;
2075
- return text;
2837
+ function numericValue(value) {
2838
+ return parsedNumber(value) ?? 0;
2076
2839
  }
2077
2840
 
2078
2841
  function normalizeAuthor(author) {