@openpresentation/opf-pptx 0.9.1 → 0.11.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,24 @@
1
+ import {nativeBodyReader, joinNativeParagraphs} from './body-text-import.js';
2
+ import {isFaceStyleSuffix} from './font-weights.js';
1
3
  import {importTableFrames} from './table-import.js';
4
+ import {applyChartFonts, applyPitchFamilies, finalizeFontsUsed, fontPitchFamilies} from './package-fonts.js';
2
5
  import {readChartCategoryHeading,writeChartCategoryHeading} from './chart-workbook.js';
3
6
  import {attachCodeTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
4
7
  import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
5
8
  import {attachCardTags,importCardFrames} from './card-provenance.js';
9
+ import {attachMediaTags,importMediaGroups,mediaCaption,mediaFrameRecord,mediaTextFingerprint} from './media-provenance.js';
6
10
  import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
7
11
  import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
8
12
  import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
9
- import {attachFurnitureTags, furnitureManifest, importFurniture} from './furniture-provenance.js';
13
+ import {attachFurnitureTags, furnitureManifest, importFurniture, staticDateFallback} from './furniture-provenance.js';
14
+ import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, restoreDocumentProvenance} from './document-provenance.js';
15
+ import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
10
16
  import {importImageOrientation} from './image-import.js';
11
- import {nativeBackgroundFill} from './background.js';
17
+ import {placeSlideImages, importSlideImage, slideImageName, slideImageOverlayName} from './slide-image-provenance.js';
18
+ import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
12
19
  import {importBackground} from './background-import.js';
20
+ import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
21
+ import {languageDiagnostics, observeLanguage, partScriptFonts, planScriptFonts, reconcileLanguage} from './script-fonts.js';
13
22
  import { webpToPng } from '#image-fallback';
14
23
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
15
24
  import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
@@ -22,6 +31,8 @@ import {
22
31
  validatePresentation
23
32
  } from "@openpresentation/opf";
24
33
 
34
+ export {checkPptxTypefaces, inventoryPptxTypefaces, packageFontsUsed, THEME_SCRIPT_SUPPLEMENTS} from './typeface-inventory.js';
35
+
25
36
  export const packageName = "@openpresentation/opf-pptx";
26
37
 
27
38
  export const releaseLane = Object.freeze({
@@ -57,6 +68,9 @@ const FIXED_TIMESTAMP = "1980-01-01T00:00:00Z";
57
68
  // local components: a UTC instant rolls back to 1979 west of UTC (below
58
69
  // fflate's 1980 floor) and would otherwise yield timezone-dependent bytes.
59
70
  const FIXED_ZIP_DATE = new Date(1980, 0, 1, 0, 0, 0);
71
+ // Explicit stamps overwrite these fields after ZIP generation. Keep the
72
+ // temporary instant well inside fflate's range even if the host changes TZ.
73
+ const EXPLICIT_ZIP_SENTINEL = new Date('2000-01-01T12:00:00Z');
60
74
  const DEFAULT_SEED = 0x4f504658;
61
75
  const CANONICAL_SCHEMA = "https://openpresentation.org/schema/opf/v1";
62
76
  const EMUS_PER_INCH = 914400;
@@ -69,6 +83,12 @@ const xmlParser = new XMLParser({
69
83
  parseTagValue: false,
70
84
  trimValues: false
71
85
  });
86
+ // Keep numeric character references (notably CR) in native core text. This is
87
+ // scoped to properties; the ordinary slide/relationship parser is unchanged.
88
+ const coreTextParser = new XMLParser({
89
+ ignoreAttributes: false, attributeNamePrefix: "", textNodeName: "#text",
90
+ parseAttributeValue: false, parseTagValue: false, trimValues: false, htmlEntities: true
91
+ });
72
92
 
73
93
  const ROOT_PAYLOAD_FIELDS = [
74
94
  "text",
@@ -148,6 +168,7 @@ const DIMENSION_PRESETS = Object.freeze({
148
168
  const DEFAULTS = Object.freeze({
149
169
  theme: "minimal",
150
170
  colorScheme: "cool-horizon",
171
+ // Shared last-resort font scheme (core DEFAULT_FONT_SCHEME): every engine uses aptos.
151
172
  fontScheme: "aptos"
152
173
  });
153
174
 
@@ -170,25 +191,45 @@ export async function toPptx(input, options = {}) {
170
191
  if (options.imageFormat !== undefined && !['compatible', 'preserve'].includes(options.imageFormat)) {
171
192
  throw new OPFPptxError('invalid-image-format', 'imageFormat must be compatible or preserve.', {path: 'options.imageFormat'});
172
193
  }
194
+ if (options.provenance !== undefined && !['full', 'references-only', false].includes(options.provenance)) {
195
+ throw new OPFPptxError('invalid-provenance-option', "provenance must be 'full', 'references-only' or false.", {path: 'options.provenance'});
196
+ }
173
197
  const presentation = parseInput(input);
174
198
  assertValidBoundary(presentation);
199
+ options = {...options, textMeasurement: chosenFamilyMeasurement(options.textMeasurement)};
175
200
 
176
201
  const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
177
202
  context.listMarkers = new Map();
203
+ context.imagePlaceholders = new Map();
178
204
  context.tableHeaders = new Map();
179
205
  context.tableCells = new Map();
180
206
  context.imagePlacements = new Map();
207
+ context.slideImages = new Map();
181
208
  context.backgroundFills = new Map();
209
+ context.notesWithCarriageReturns = new Map();
182
210
  context.cardTags = new Map();
211
+ context.mediaTags = new Map();
212
+ context.provenanceMode = options.provenance ?? "full";
183
213
  context.headingTags = new Map();
184
214
  context.plainTextTags = new Map();
185
215
  context.timelineTags = new Map();
186
216
  context.furnitureTags = new Map();
217
+ context.furnitureFields = new Map();
187
218
  context.furnitureManifests = new Map();
188
219
  context.codeTags = new Map();
189
220
  context.metricTags = new Map();
190
221
  context.chartHeadings = new Map();
222
+ context.chartFonts = new Map();
191
223
  context.imageFormat = options.imageFormat ?? "compatible";
224
+ Object.assign(context, exportTheme(presentation, context));
225
+ context.reportedFontSchemes = new Set();
226
+ // Document references and metadata tags (FF-32, docs/document-roundtrip.md).
227
+ context.documentProvenance = options.provenance === false ? null : documentProvenance(presentation, {
228
+ mode: options.provenance ?? "full",
229
+ isCatalogId: (kind, id) => !!(findById(normalizeRecords(presentation.catalogs?.[kind]), id) ?? findById(defaultCatalog(kind), id)),
230
+ report: diagnostic => options.onDiagnostic?.(diagnostic)
231
+ });
232
+ context.scriptFonts = planScriptFonts(presentation, options.onDiagnostic);
192
233
  const pptx = new PptxGenJS();
193
234
  configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
194
235
 
@@ -196,6 +237,10 @@ export async function toPptx(input, options = {}) {
196
237
  await addSlide(pptx, presentation, presentation.slides[index], index, context, options);
197
238
  }
198
239
 
240
+ // FF-08: pitchFamily per exported family, from each slide's resolved scheme.
241
+ context.fontPitch = fontPitchFamilies(presentation.slides.map((slide, index) => resolveSlideContext(presentation, slide, context, options, index).fonts),
242
+ [...normalizeRecords(presentation.catalogs?.fontSchemes), ...defaultCatalog("fontSchemes")]);
243
+
199
244
  let raw;
200
245
  try {
201
246
  raw = await withDeterministicRandom(context.seed, () => pptx.write({
@@ -227,15 +272,24 @@ export async function fromPptx(input, options = {}) {
227
272
 
228
273
  const core = readCoreProperties(entries);
229
274
  const dimensions = dimensionsFromPresentation(presentationRoot);
230
- const imported = {
275
+ let imported = {
231
276
  $schema: options.schema ?? CANONICAL_SCHEMA,
232
277
  name: core.title || options.fallbackName || "Imported PPTX",
233
278
  slides: []
234
279
  };
235
280
 
281
+ // FF-07: the language the runs carry. A stored FF-32 reference can still win below.
282
+ const observedLanguage = observeLanguage({
283
+ slides: slidePaths.map(path => decodeText(entries[path])),
284
+ theme: (path => path && entries[path] ? decodeText(entries[path]) : null)(presentationThemePath(presentationRoot, presentationRels, path => parseRelationships(entries, path), entries)),
285
+ catalogs: bundledCatalogs
286
+ });
287
+ if (observedLanguage.language !== undefined) imported.language = observedLanguage.language;
236
288
  if (core.description) imported.description = core.description;
237
289
  if (core.author) imported.author = core.author;
238
- if (dimensions) imported.design = { dimensions };
290
+ const themeDesign = importThemeDesign(entries, presentationRoot, presentationRels);
291
+ const design = {...themeDesign.design, ...(dimensions ? {dimensions} : {})};
292
+ if (Object.keys(design).length) imported.design = design;
239
293
 
240
294
  const furnitureContexts = slidePaths.map((slidePath, index) => {
241
295
  const root = parseRequiredXml(entries, slidePath)['p:sld'];
@@ -250,9 +304,42 @@ export async function fromPptx(input, options = {}) {
250
304
  if (Object.keys(furniture.design).length) imported.design = {...imported.design, ...furniture.design};
251
305
  if (furniture.organization) imported.organization = furniture.organization;
252
306
 
307
+ const mediaRegistry = Object.create(null);
253
308
  for (let index = 0; index < slidePaths.length; index += 1) {
309
+ furnitureContexts[index].mediaRegistry = mediaRegistry;
254
310
  imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options, furniture.slides[index], furnitureContexts[index]));
255
311
  }
312
+ // Conflicting media asset IDs fall back to their current native URLs during import.
313
+ 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};
314
+ // Report after the slides so slide diagnostics keep their established order.
315
+ // A theme name FF-24 could not verify is not reported when the stored reference restores the theme.
316
+ for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code !== "theme-unverified") options.onDiagnostic?.(diagnostic);
317
+
318
+ // Catalog references, layout ids and authoring metadata recorded at export
319
+ // (FF-32). Stored references win while the package still matches them;
320
+ // otherwise the observed values (including FF-24's theme recovery) stay and
321
+ // a diagnostic names the reference. A restored field that does not validate
322
+ // is dropped on its own.
323
+ const report = diagnostic => options.onDiagnostic?.(diagnostic);
324
+ // Per slide: {layout, structure: 'match' | 'changed' | 'untagged', record, catalogRecord}.
325
+ // Layout-structure recovery (FF-29) reads the stored OPF_SLIDE_V1 record from
326
+ // here instead of writing a second slide tag.
327
+ let slideProvenance = slidePaths.map(() => ({structure: "untagged"}));
328
+ try {
329
+ const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true,
330
+ // Host catalogs format stored socials exactly as export did (FF-34).
331
+ socialPlatformRecords: catalogs => socialPlatformRecords({catalogs}, options),
332
+ slides: slidePaths.map((path, index) => ({path, root: furnitureContexts[index].root, relationships: furnitureContexts[index].relationships}))}, report);
333
+ // The stored language wins while the runs still carry its tag (FF-07).
334
+ restored.groups = reconcileLanguage(restored.groups, observedLanguage, report);
335
+ imported = applyDocumentProvenance(imported, restored, validatePresentation, report);
336
+ slideProvenance = restored.slides;
337
+ } catch (error) {
338
+ report({code: "invalid-document-provenance", path: "", message: `${errorMessage(error)} Ordinary import keeps the values observed in the PPTX.`});
339
+ }
340
+ if (slideProvenance.length !== imported.slides.length) throw new OPFPptxError("invalid-import-opf", "Slide provenance does not match the imported slides.");
341
+ if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
342
+ languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
256
343
 
257
344
  const result = validatePresentation(imported);
258
345
  if (!result.valid) {
@@ -364,7 +451,7 @@ function resolveSlidePaths(entries, presentationRoot, relationships) {
364
451
  }
365
452
 
366
453
  function compareSlidePaths(left, right) {
367
- return slideNumber(left) - slideNumber(right) || left.localeCompare(right);
454
+ return slideNumber(left) - slideNumber(right) || (left < right ? -1 : left > right ? 1 : 0);
368
455
  }
369
456
 
370
457
  function slideNumber(path) {
@@ -372,12 +459,12 @@ function slideNumber(path) {
372
459
  }
373
460
 
374
461
  function readCoreProperties(entries) {
375
- const doc = parseOptionalXml(entries, "docProps/core.xml");
462
+ const doc = entries["docProps/core.xml"] ? parseRequiredXml(entries, "docProps/core.xml", coreTextParser) : null;
376
463
  const core = doc?.["cp:coreProperties"] ?? {};
377
464
  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()
465
+ title: scalarText(core["dc:title"]),
466
+ description: scalarText(core["dc:description"] ?? core["dc:subject"]),
467
+ author: scalarText(core["dc:creator"])
381
468
  };
382
469
  }
383
470
 
@@ -405,6 +492,16 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
405
492
  if (background) slide.design = {background};
406
493
  if (Object.keys(furniture.design).length) slide.design = {...slide.design, ...furniture.design};
407
494
  if (furniture.section !== undefined) slide.section = furniture.section;
495
+ const slideImagePath = `slides.${slideIndex}.design.slideImage`;
496
+ const slideImage = importSlideImage(nativeContext.pictures, nativeContext.shapes, relationships, entries, slideIndex,
497
+ picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
498
+ // The recorded crop/fit is re-derived from design.slideImage.
499
+ if (diagnostic.code !== 'unsupported-image-crop') options.onDiagnostic?.({...diagnostic, path: slideImagePath});
500
+ }),
501
+ diagnostic => options.onDiagnostic?.({...diagnostic, path: slideImagePath}));
502
+ if (slideImage.design) slide.design = {...slide.design, ...slideImage.design};
503
+ nativeContext.slideImagePictures = slideImage.consumed;
504
+ nativeContext.slideImageShapes = slideImage.consumedShapes;
408
505
 
409
506
  const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex, furniture, nativeContext)
410
507
  .sort(comparePositionedItems);
@@ -419,6 +516,15 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
419
516
  const subtitleItem = heading('subtitle')??takeSubtitleItem(items, titleItem, dimensions, inferHeadings);
420
517
  if (subtitleItem) slide.subtitle = subtitleItem.text;
421
518
 
519
+ // Heading inference keeps its existing scalar/native metrics. Only remaining
520
+ // ordinary body shapes gain current rich values; tagged recovery is separate.
521
+ for (const item of items) if (item.readNativeBody) {
522
+ const current = item.readNativeBody();
523
+ if (current) {
524
+ item.paragraphs = current.map((paragraph, index) => ({...item.paragraphs[index], ...paragraph}));
525
+ item.text = current.map(paragraph => paragraph.text).join('\n');
526
+ }
527
+ }
422
528
  const blocks = mergeAdjacentBulletShapes(items)
423
529
  .map((item) => payloadFromSlideItem(item))
424
530
  .filter(Boolean);
@@ -438,9 +544,14 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
438
544
  const shapes = nativeContext.shapes;
439
545
  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
546
  const paragraphs = nativeContext.paragraphs;
547
+ const readBody = nativeBodyReader(slidePath, {
548
+ part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
549
+ }, relationships, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.${diagnostic.path}`}));
441
550
  const code = importCodeGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.code`}));
442
551
  const metric = importMetricGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.metric`}));
443
552
  const cards = importCardFrames(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
553
+ const media = importMediaGroups(shapes, paragraphs, relationships, entries, slideIndex, diagnostic => options.onDiagnostic?.(diagnostic), nativeContext.mediaRegistry);
554
+ nativeContext.mediaAssets = media.assets;
444
555
  const headings=importHeadingGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
445
556
  const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
446
557
  const timelines=importTimelineGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
@@ -459,13 +570,16 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
459
570
  }
460
571
  for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
461
572
  for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
573
+ 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
574
  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));
575
+ if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index)) continue;
576
+ 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;
577
+ const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
578
+ const item = importShape(shape, dimensions, paragraphs[index], ordinaryBody || furniture.taggedText.has(index) || media.captionShapes.has(shape));
579
+ if (item && ordinaryBody) item.readNativeBody = () => readBody(index);
466
580
  // A damaged/edited furniture group falls back to current native text,
467
581
  // including cleared text boxes, without inventing a title or shape label.
468
- if (item && furniture.taggedText.has(index)) item.sourceText = true;
582
+ if (item && (furniture.taggedText.has(index) || media.captionShapes.has(shape))) item.sourceText = true;
469
583
  if (item) items.push(item);
470
584
  }
471
585
 
@@ -480,7 +594,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
480
594
  }
481
595
 
482
596
  for (const [index, picture] of nativeContext.pictures.entries()) {
483
- if (furniture.pictures.has(index)) continue;
597
+ if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index)) continue;
484
598
  const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
485
599
  const item = importPicture(entries, picture, slidePath, relationships, report);
486
600
  if (item) items.push(item);
@@ -691,12 +805,12 @@ function payloadFromSlideItem(item) {
691
805
  type: "list",
692
806
  items: item.paragraphs.map((paragraph) => (
693
807
  paragraph.level > 0
694
- ? { text: paragraph.text, level: paragraph.level }
695
- : paragraph.text
808
+ ? { text: paragraph.richText ?? paragraph.text, level: paragraph.level }
809
+ : (paragraph.richText ?? paragraph.text)
696
810
  ))
697
811
  };
698
812
  }
699
- return { type: "text", text: item.text };
813
+ return { type: "text", text: joinNativeParagraphs(item.paragraphs) };
700
814
  }
701
815
  if (item.kind === "unknown" && item.text) return { type: "text", text: item.text };
702
816
  return null;
@@ -713,18 +827,29 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
713
827
  if (!chartNode) return null;
714
828
  const series = asArray(chartNode.node["c:ser"]);
715
829
  if (series.length === 0) return null;
830
+ if (series.length > MAX_CHART_CACHE_POINTS) {
831
+ throw new OPFPptxError('invalid-chart-cache', 'Chart cache exceeds the 100,000-series import limit; reduce its series before importing.', {path: relationship.path});
832
+ }
716
833
 
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));
834
+ const budget = { cells: 0 };
835
+ const cachePath = (index, role) => `${relationship.path}#c:ser[${index}]/${role}`;
836
+ const labels = cachedValues(series[0]?.["c:cat"], cachePath(0, 'c:cat'), budget);
837
+ const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, 'c:tx'), budget) ?? `Series ${index + 1}`);
838
+ const values = series.map((entry, index) => {
839
+ const role = entry?.["c:val"] !== undefined ? 'c:val' : 'c:yVal';
840
+ return cachedValues(entry?.[role], cachePath(index, role), budget, numericCacheValue);
841
+ });
842
+ const rowCount = values.reduce((count, row) => Math.max(count, row.length), labels.length);
721
843
  if (rowCount === 0) return null;
844
+ if (rowCount * (series.length + 1) > MAX_CHART_CACHE_CELLS) {
845
+ 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});
846
+ }
722
847
 
723
848
  const rows = [];
724
849
  for (let index = 0; index < rowCount; index += 1) {
725
850
  rows.push([
726
- labels[index] ?? `Item ${index + 1}`,
727
- ...values.map((row) => row[index] ?? 0)
851
+ labels[index] ?? null,
852
+ ...values.map((row) => row[index] ?? null)
728
853
  ]);
729
854
  }
730
855
 
@@ -754,39 +879,102 @@ function firstChartNode(plotArea) {
754
879
  return null;
755
880
  }
756
881
 
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;
882
+ const MAX_CHART_CACHE_POINTS = 100_000;
883
+ const MAX_CHART_CACHE_CELLS = 1_000_000;
884
+
885
+ // Import complete decimal exponent tokens without the exporter's legacy
886
+ // character stripping. Other numeric strings deliberately retain that policy.
887
+ function numericCacheValue(value, path) {
888
+ if (value === null || value.trim() === '') return null;
889
+ const token = value.trim();
890
+ const exponent = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+))[eE][+-]?\d+$/.exec(token);
891
+ if (!exponent) return numericValue(value);
892
+ const parsed = Number(token);
893
+ if (!Number.isFinite(parsed) || (parsed === 0 && /[1-9]/.test(exponent[1]))) {
894
+ const reason = Number.isFinite(parsed) ? 'underflows to zero' : 'overflows';
895
+ 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});
896
+ }
897
+ return parsed;
898
+ }
899
+
900
+ // c:pt is sparse and its XML order does not determine the data row. Keep
901
+ // explicit empty strings distinct from missing points, and bound native
902
+ // metadata before allocating a dense OPF table. Only numeric roles convert values.
903
+ function cachedValues(node, path, budget, readValue) {
904
+ const invalid = (message, suffix = '') => {
905
+ throw new OPFPptxError('invalid-chart-cache', `Invalid chart cache: ${message}. Repair the chart data before importing.`, {path: path + suffix});
906
+ };
907
+ const single = (value, location) => {
908
+ if (value !== undefined && value !== '' && (!value || typeof value !== 'object' || Array.isArray(value))) {
909
+ invalid('cache container must be a single element', location);
910
+ }
911
+ };
912
+ single(node, '');
913
+ for (const reference of ['c:strRef', 'c:numRef', 'c:multiLvlStrRef']) single(node?.[reference], `/${reference}`);
914
+ const candidates = [
915
+ ['c:strRef/c:strCache', node?.['c:strRef']?.['c:strCache']],
916
+ ['c:numRef/c:numCache', node?.['c:numRef']?.['c:numCache']],
917
+ ['c:multiLvlStrRef/c:multiLvlStrCache', node?.['c:multiLvlStrRef']?.['c:multiLvlStrCache']],
918
+ ['c:numLit', node?.['c:numLit']],
919
+ ['c:strLit', node?.['c:strLit']],
920
+ ].filter(([, cache]) => cache !== undefined);
921
+ if (candidates.length === 0) return [];
922
+ if (candidates.length > 1) invalid('multiple competing cache sources');
923
+ const [kind, cache] = candidates[0];
924
+ path += `/${kind}`;
925
+ if (cache !== '' && (!cache || typeof cache !== 'object' || Array.isArray(cache))) invalid('cache must be a single element');
926
+ const integer = (raw, limit, location) => {
927
+ // XML Schema unsigned integers allow an optional sign and whitespace;
928
+ // their value must still be nonnegative, integral and bounded.
929
+ if (typeof raw !== 'string' || !/^[+-]?\d+$/.test(raw.trim())) invalid('expected an unsigned integer', location);
930
+ const value = Number(raw.trim());
931
+ if (!Number.isSafeInteger(value) || value < 0 || value > limit) invalid(`integer exceeds the supported range 0–${limit}`, location);
932
+ return value;
933
+ };
934
+ const count = cache?.['c:ptCount'] === undefined ? undefined
935
+ : integer(cache['c:ptCount']?.val, MAX_CHART_CACHE_POINTS, '/c:ptCount@val');
936
+ let data = cache;
937
+ if (kind.startsWith('c:multiLvlStrRef')) {
938
+ const levels = asArray(cache?.['c:lvl']);
939
+ if (levels.length > 1) {
940
+ throw new OPFPptxError('unsupported-chart-cache', 'Hierarchical chart category caches cannot be flattened without losing labels; use a single category level before importing.', {path});
941
+ }
942
+ data = levels[0];
943
+ }
944
+ const points = asArray(data?.['c:pt']);
945
+ if (points.length > MAX_CHART_CACHE_POINTS) invalid('too many points');
946
+ const indexed = new Map();
947
+ let extent = count ?? 0;
948
+ for (const [position, point] of points.entries()) {
949
+ const location = `/c:pt[${position}]@idx`;
950
+ const index = integer(point?.idx, MAX_CHART_CACHE_POINTS - 1, location);
951
+ if (count !== undefined && index >= count) invalid('point index is outside its declared count', location);
952
+ if (indexed.has(index)) invalid('duplicate point index', location);
953
+ if (Array.isArray(point?.['c:v'])) invalid('point has multiple values', `/c:pt[${position}]/c:v`);
954
+ indexed.set(index, point?.['c:v'] === undefined ? null : scalarText(point['c:v']));
955
+ extent = Math.max(extent, index + 1);
956
+ }
957
+ if (budget.cells + extent > MAX_CHART_CACHE_CELLS) invalid('combined caches exceed the 1,000,000-cell import limit');
958
+ budget.cells += extent;
959
+ const result = new Array(extent).fill(null);
960
+ for (const [index, value] of indexed) {
961
+ result[index] = readValue ? readValue(value, `${path}/c:pt[@idx="${index}"]/c:v`) : value;
962
+ }
963
+ return result;
769
964
  }
770
965
 
771
- function firstCachedValue(node) {
772
- return cachedValues(node).find(Boolean) ?? "";
966
+ function firstCachedValue(node, path, budget) {
967
+ return cachedValues(node, path, budget).find(value => value !== null);
773
968
  }
774
969
 
775
970
  function readSlideNotes(entries, relationships) {
776
971
  const notesRel = [...relationships.values()].find((relationship) => relationship.type.endsWith("/notesSlide"));
777
972
  if (!notesRel?.path || !entries[notesRel.path]) return "";
778
973
  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();
974
+ const shapes = nativeTextShapes(doc["p:notes"]?.["p:cSld"]?.["p:spTree"]);
975
+ const paragraphs = nativeShapeParagraphs(decodeText(entries[notesRel.path]), 'p:notes');
976
+ return shapes.flatMap((shape, index) => shapePlaceholderType(shape) === "body"
977
+ ? paragraphs[index].map(paragraph => paragraph.text) : []).join("\n");
790
978
  }
791
979
 
792
980
  function comparePositionedItems(left, right) {
@@ -885,6 +1073,10 @@ function resolvePresentationContext(presentation, options) {
885
1073
  design.fontScheme ?? theme?.fontScheme,
886
1074
  DEFAULTS.fontScheme
887
1075
  );
1076
+ // Shared rule (core resolveFontSchemeReference): an unresolved id falls back to the
1077
+ // DEFAULTS.fontScheme record as the base; resolveSlideContext reports it once per path.
1078
+ const fontSchemeId = referenceId(design.fontScheme ?? theme?.fontScheme);
1079
+ const unresolvedFontScheme = fontSchemeId && !findById(normalizeRecords(presentation.catalogs?.fontSchemes), fontSchemeId) && !findById(defaultCatalog("fontSchemes"), fontSchemeId) ? fontSchemeId : null;
888
1080
  const dimensions = resolveDimensions(design.dimensions ?? theme?.dimensions);
889
1081
  const background = resolveBackground(design.background ?? theme?.background, colorScheme);
890
1082
  const fonts = resolveFonts(fontScheme);
@@ -895,13 +1087,15 @@ function resolvePresentationContext(presentation, options) {
895
1087
  return {
896
1088
  seed: Number.isInteger(options.seed) ? options.seed : DEFAULT_SEED,
897
1089
  timestamp: options.timestamp ?? FIXED_TIMESTAMP,
898
- zipDate: options.zipDate ? new Date(options.zipDate) : FIXED_ZIP_DATE,
1090
+ zipDate: options.zipDate === undefined ? FIXED_ZIP_DATE : EXPLICIT_ZIP_SENTINEL,
1091
+ zipDateStamp: resolveZipDateStamp(options.zipDate),
899
1092
  compressionLevel: Number.isInteger(options.compressionLevel) ? options.compressionLevel : 6,
900
1093
  layoutName: "OPF_CANVAS",
901
1094
  dimensions,
902
1095
  colorScheme,
903
1096
  backgroundDefinition: design.background ?? theme?.background,
904
1097
  fonts,
1098
+ unresolvedFontScheme,
905
1099
  colors: {
906
1100
  background,
907
1101
  text: textColor,
@@ -918,6 +1112,55 @@ function exportColor(entry, context, fallback) {
918
1112
  return resolveExportColor(entry, colorContext(context, fallback));
919
1113
  }
920
1114
 
1115
+ // PptxGenJS color for a document color reference: a theme scheme value when the
1116
+ // reference names a scheme slot or role that the deck theme holds exactly,
1117
+ // otherwise the resolved literal RRGGBB. Alpha stays in the caller's transparency.
1118
+ function nativeColor(reference, hex, context) {
1119
+ return schemeColorValue(reference, hex, context, {vendor: true}) ?? normalizeHex(hex);
1120
+ }
1121
+
1122
+ // The theme part carries the deck-level scheme (slide overrides stay literal)
1123
+ // and, for an explicit catalog theme, that theme's name.
1124
+ function exportTheme(presentation, context) {
1125
+ const reference = presentation.design?.theme;
1126
+ const id = referenceId(reference);
1127
+ const theme = id ? resolveDesignRecord(presentation, "themes", reference, DEFAULTS.theme) : null;
1128
+ const scheme = context.colorScheme;
1129
+ return {
1130
+ themeColors: themeSlotColors(scheme),
1131
+ schemeName: scheme.name ?? scheme.id ?? "OpenPresentation",
1132
+ themeName: theme?.id === id ? theme.name ?? theme.id : undefined
1133
+ };
1134
+ }
1135
+
1136
+ function importThemeDesign(entries, presentationRoot, presentationRels) {
1137
+ const diagnostics = [], design = {};
1138
+ const report = (code, path, message) => diagnostics.push({code, path, message});
1139
+ const themePath = presentationThemePath(presentationRoot, presentationRels, path => parseRelationships(entries, path), entries);
1140
+ const theme = themePath ? parseOptionalXml(entries, themePath)?.["a:theme"] : undefined;
1141
+ const elements = theme?.["a:themeElements"];
1142
+ const clrScheme = elements?.["a:clrScheme"];
1143
+ if (!clrScheme || Array.isArray(clrScheme)) {
1144
+ report("unsupported-theme-colors", "design.colorScheme", "The PPTX has no readable theme color scheme, so design.colorScheme was not imported.");
1145
+ return {design, diagnostics};
1146
+ }
1147
+ const {colors, unreadable} = readThemeSlotColors(clrScheme);
1148
+ const records = defaultCatalog("colorSchemes");
1149
+ if (Object.keys(colors).length) design.colorScheme = recoverColorScheme({colors, unreadable, name: scalarText(clrScheme.name)}, records).value;
1150
+ 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.`);
1151
+ const latin = font => scalarText(elements?.["a:fontScheme"]?.[font]?.["a:latin"]?.typeface);
1152
+ const recoveredTheme = recoverTheme({themeName: scalarText(theme?.name), colors, majorFont: latin("a:majorFont"), minorFont: latin("a:minorFont")}, {
1153
+ themes: defaultCatalog("themes"),
1154
+ colorSchemes: records,
1155
+ fontFamilies: id => {
1156
+ const record = findById(defaultCatalog("fontSchemes"), id);
1157
+ return record ? resolveFontFamilies(record) : null;
1158
+ }
1159
+ });
1160
+ 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.`);
1161
+ return {design: recoveredTheme.id ? {theme: recoveredTheme.id, ...design} : design, diagnostics};
1162
+ }
1163
+
921
1164
  function configurePresentation(pptx, presentation, context) {
922
1165
  pptx.defineLayout({
923
1166
  name: context.layoutName,
@@ -938,21 +1181,51 @@ function configurePresentation(pptx, presentation, context) {
938
1181
 
939
1182
  async function addSlide(pptx, presentation, opfSlide, slideIndex, context, options) {
940
1183
  const slide = pptx.addSlide();
941
- const slideContext = resolveSlideContext(presentation, opfSlide, context, options);
1184
+ const slideContext = resolveSlideContext(presentation, opfSlide, context, options, slideIndex);
942
1185
  slide.background = { color: slideContext.colors.background };
943
- const backgroundFill = nativeBackgroundFill(slideContext.backgroundDefinition, {
1186
+ const backgroundDefinition = slideContext.backgroundDefinition;
1187
+ const backgroundPath = `${opfSlide.design?.background !== undefined ? `slides.${slideIndex}.` : ''}design.background`;
1188
+ const backgroundFill = schemeBackgroundFill(backgroundDefinition, slideContext) ?? nativeBackgroundFill(backgroundDefinition, {
944
1189
  width: slideContext.dimensions.widthInches, height: slideContext.dimensions.heightInches
945
- }, slideContext.colors.background);
1190
+ }, slideContext.colors.background, slideContext.colors.text, (reference, hex) => schemeColorValue(reference, hex, slideContext));
946
1191
  if (backgroundFill) context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, backgroundFill);
1192
+ if (backgroundDefinition?.type === 'pattern' && !nativePatternPreset(backgroundDefinition.pattern?.preset)) {
1193
+ options.onDiagnostic?.({code: 'unsupported-pattern', path: `${backgroundPath}.pattern.preset`, message: `Pattern ${backgroundDefinition.pattern?.preset} has no DrawingML preset; only its background color was exported.`});
1194
+ }
1195
+ if (backgroundDefinition?.type === 'image') {
1196
+ const imagePath = `${backgroundPath}.image`;
1197
+ const resolved = await resolveImage(backgroundDefinition.image, presentation, options, imagePath);
1198
+ if (resolved) {
1199
+ // PptxGenJS embeds the raster and its relationship; packaging replaces
1200
+ // its stretched fill with the fitted native picture fill.
1201
+ slide.background = { ...resolved };
1202
+ context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, { image: {
1203
+ fit: backgroundDefinition.image?.fit ?? 'cover', opacity: backgroundDefinition.opacity ?? 1, imageFill: slideContext.imageFill, path: imagePath,
1204
+ width: slideContext.dimensions.widthInches * 96, height: slideContext.dimensions.heightInches * 96, report: options.onDiagnostic
1205
+ } });
1206
+ } else {
1207
+ 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.'});
1208
+ }
1209
+ }
947
1210
  slide.color = slideContext.colors.text;
948
1211
  if (opfSlide.hidden === true) slide.hidden = true;
949
1212
 
950
1213
  const { widthInches, heightInches } = slideContext.dimensions;
951
1214
  const layout = resolveCatalogRecord(presentation, "layouts", opfSlide.layout, "blank") ?? {};
952
1215
  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 });
1216
+ // design.titleAlignment/contentAlignment are Design properties (slide design
1217
+ // overrides the deck). Core only returns an accepted `placement.alignment`
1218
+ // with outline measurement, so the same effective values must also drive
1219
+ // text without placement, exactly as the renderer's preview does.
1220
+ const titleAlignment=opfSlide.design?.titleAlignment??presentation.design?.titleAlignment;
1221
+ const contentAlignment=opfSlide.design?.contentAlignment??presentation.design?.contentAlignment;
1222
+ const fieldAlignment=field=>field==='title'?titleAlignment:contentAlignment;
1223
+ // Core composition resolves one alignment per composed item for every engine
1224
+ // (FF-29); the design fallback keeps cores published before item.alignment working.
1225
+ const alignmentFor=item=>item.alignment??fieldAlignment(item.field);
1226
+ 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
1227
  for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
955
- await addFurniture(slide,presentation,opfSlide,geometry.furniture,slideContext,options,slideIndex);
1228
+ if (geometry.slideImage) await addSlideImage(slide, presentation, geometry.slideImage, slideIndex, slideContext, options);
956
1229
  for (const item of geometry.items) {
957
1230
  const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
958
1231
  if (item.frameBox) {
@@ -962,10 +1235,10 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
962
1235
  const surface=slideContext.colorScheme[isDarkHex(slideContext.colors.background)?'dark2':'light2']??`#${slideContext.colors.surface}`;
963
1236
  slide.addShape('roundRect',{x:frame.x/96,y:frame.y/96,w:frame.width/96,h:frame.height/96,
964
1237
  rectRadius:8*Math.min(widthInches,heightInches)/720,
965
- fill:paint(surface),line:{...paint(slideContext.colorScheme.accent5??`#${slideContext.colors.border}`),pt:.75},objectName:`OPF card ${item.path}`});
1238
+ fill:paint(surface),line:{...paint(slideContext.colorScheme.accent5??`#${slideContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
966
1239
  }
967
1240
  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});
1241
+ 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
1242
  } else if (["title", "subtitle", "tag"].includes(item.field)) {
970
1243
  // Estimated-font headings use one native text box, which still needs a
971
1244
  // role tag. Role recovery must not depend on outline measurement support.
@@ -975,35 +1248,55 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
975
1248
  ...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),
976
1249
  ...nativeFontOptions(item.textStyle),
977
1250
  color: item.field === "tag" ? slideContext.colors.accent : slideContext.colors.text,
1251
+ align: alignmentFor(item),
978
1252
  objectName,
979
1253
  breakLine: false
980
1254
  });
981
1255
  } else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
982
- addMeasuredList(slide,item.text,slideContext);
1256
+ addMeasuredList(slide,item.text,slideContext,item.path);
983
1257
  } else if (item.field === "text" && item.text?.richLines) {
984
- const alignment=item.text.placement?.alignment??opfSlide.design?.contentAlignment??presentation.design?.contentAlignment??'left';
1258
+ const alignment=item.text.placement?.alignment??alignmentFor(item)??'left';
985
1259
  for(const [index,line] of item.text.richLines.entries()){
986
1260
  const runs=line.fragments.map(fragment=>{
987
1261
  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}};
1262
+ const color=nativeColor(fragment.run.color,runColor,slideContext);
1263
+ 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
1264
  });
990
1265
  const placed=item.text.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
991
1266
  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
1267
  if(runs.length)slide.addText(runs,{...textBoxOptions(area,slideContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
993
1268
  }
994
1269
  } 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)});
1270
+ slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:alignmentFor(item)});
996
1271
  } 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);
1272
+ 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
1273
  }
999
1274
  }
1275
+ // Core composes furniture above all content and opf-render paints it last, so
1276
+ // spTree order (PowerPoint's z-order) matches: header/footer parts come after
1277
+ // every content item, and an overlapping footer stays visible over content.
1278
+ await addFurniture(slide,presentation,opfSlide,geometry.furniture,slideContext,options,slideIndex);
1000
1279
 
1001
- if (opfSlide.notes) slide.addNotes(String(opfSlide.notes));
1280
+ if (opfSlide.notes) {
1281
+ const notes = String(opfSlide.notes);
1282
+ slide.addNotes(notes);
1283
+ if (notes.includes('\r')) context.notesWithCarriageReturns.set(`ppt/notesSlides/notesSlide${slideIndex + 1}.xml`, notes);
1284
+ }
1002
1285
  }
1003
1286
 
1004
- function resolveSlideContext(presentation, slide, baseContext, options) {
1287
+ function resolveSlideContext(presentation, slide, baseContext, options, slideIndex = 0) {
1005
1288
  const effective = { ...presentation, design: { ...presentation.design, ...slide.design } };
1006
1289
  const resolved = resolvePresentationContext(effective, options);
1290
+ if (resolved.unresolvedFontScheme) {
1291
+ const path = slide.design?.fontScheme !== undefined ? `slides.${slideIndex}.design.fontScheme`
1292
+ : presentation.design?.fontScheme !== undefined ? "design.fontScheme"
1293
+ : slide.design?.theme !== undefined ? `slides.${slideIndex}.design.theme` : "design.theme";
1294
+ if (!baseContext.reportedFontSchemes?.has(path)) {
1295
+ baseContext.reportedFontSchemes?.add(path);
1296
+ const id = resolved.unresolvedFontScheme;
1297
+ 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}'.` });
1298
+ }
1299
+ }
1007
1300
  if (Math.abs(resolved.dimensions.widthInches - baseContext.dimensions.widthInches) > 1e-6
1008
1301
  || Math.abs(resolved.dimensions.heightInches - baseContext.dimensions.heightInches) > 1e-6) {
1009
1302
  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 +1321,10 @@ async function addPayload(slide, presentation, payload, region, path, context, o
1028
1321
  await addImagePayload(slide, presentation, payload.image, region, path, context, options);
1029
1322
  break;
1030
1323
  case "video":
1031
- addPlaceholderPayload(slide, "Video", payload.video, region, context);
1324
+ addMediaPayload(slide, presentation, payload.video, region, path, context, options);
1032
1325
  break;
1033
1326
  case "chart":
1034
- addChartPayload(slide, payload.chart, region, context);
1327
+ addChartPayload(slide, payload.chart, region, context, options, path);
1035
1328
  break;
1036
1329
  case "table":
1037
1330
  addTablePayload(slide, payload.table, region, context, options, path);
@@ -1049,7 +1342,8 @@ async function addPayload(slide, presentation, payload, region, path, context, o
1049
1342
  addTimelinePayload(slide, payload.timeline, timelineLayout, context, options, path);
1050
1343
  break;
1051
1344
  default:
1052
- addPlaceholderPayload(slide, "Unsupported OPF payload", payload, region, context);
1345
+ options.onDiagnostic?.({code: "content-placeholder", path, reason: "unsupported-payload", message: "This content has no PowerPoint export; a placeholder frame stands in for it."});
1346
+ addPlaceholderPayload(slide, "Unsupported content", "This content has no PowerPoint export.", region, context);
1053
1347
  }
1054
1348
  }
1055
1349
 
@@ -1078,17 +1372,23 @@ function addTextPayload(slide, value, region, context) {
1078
1372
  function richLineRuns(line,color,context) {
1079
1373
  const fallback=color.replace(/^#/,'');
1080
1374
  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}};
1375
+ const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context);
1376
+ 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
1377
  });
1084
1378
  }
1085
- function addMeasuredList(slide,fit,context) {
1379
+ // Every native line of a list, marker paragraph or not, is named for the list's
1380
+ // composed path (`OPF list <path> line N`, N counting across the whole list) like
1381
+ // the other measured text shapes. A reader maps lines to their list by name, not
1382
+ // by geometry, so lines of an overflowing list that fall below its box still
1383
+ // belong to it.
1384
+ function addMeasuredList(slide,fit,context,path) {
1385
+ let lineNumber=0;
1086
1386
  for(const entry of fit.listEntries){
1087
1387
  const addLines=(text,box,color,withBullet)=>{
1088
1388
  text.richLines.forEach((line,index)=>{
1089
1389
  const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
1090
1390
  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;
1391
+ const objectName=`OPF list ${path} line ${lineNumber++}`;
1092
1392
  if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:normalizeHex(context.colors.text)});
1093
1393
  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
1394
  const runs=richLineRuns(line,color,context);
@@ -1146,7 +1446,7 @@ function addListPayload(slide, items, region, context) {
1146
1446
  async function addImagePayload(slide, presentation, asset, region, path, context, options) {
1147
1447
  const resolved = await resolveImage(asset, presentation, options, path);
1148
1448
  if (!resolved) {
1149
- addPlaceholderPayload(slide, "Image", asset, region, context);
1449
+ addImagePlaceholder(slide, presentation, asset, region, path, context, options);
1150
1450
  return;
1151
1451
  }
1152
1452
  const objectName = `OPF image ${context.imagePlacements.size + 1}`;
@@ -1163,12 +1463,50 @@ async function addImagePayload(slide, presentation, asset, region, path, context
1163
1463
  return objectName;
1164
1464
  }
1165
1465
 
1166
- function addChartPayload(slide, chart, region, context) {
1466
+ // design.slideImage: one native picture at the shared frame, beneath content.
1467
+ // Crop/fit and treatments are written after PptxGenJS embeds the bytes.
1468
+ async function addSlideImage(slide, presentation, image, slideIndex, context, options) {
1469
+ const box = { x: image.box.x / 96, y: image.box.y / 96, w: image.box.width / 96, h: image.box.height / 96 };
1470
+ const resolved = await resolveImage(image.value, presentation, options, image.sourcePath);
1471
+ if (!resolved) {
1472
+ addImagePlaceholder(slide, presentation, image.value, box, image.sourcePath, context, options);
1473
+ return;
1474
+ }
1475
+ const objectName = slideImageName(`slides.${slideIndex}`);
1476
+ const configured = image.path === 'design.slideImage' ? presentation.design?.slideImage : presentation.slides[slideIndex].design?.slideImage;
1477
+ const { src: _source, ...treatment } = isPlainObject(configured) && 'position' in configured ? configured : {};
1478
+ const paint = (entry, fallback) => {
1479
+ const hex = normalizeHex(exportColor(entry, context, fallback), fallback);
1480
+ const raw = exportColor(entry, context, fallback).replace(/^#/, '');
1481
+ return { hex, alpha: /^[0-9a-f]{8}$/i.test(raw) ? parseInt(raw.slice(6), 16) / 255 : 1 };
1482
+ };
1483
+ // Native effects are resolved here, in the slide's color context.
1484
+ const effects = {
1485
+ shape: image.shape ?? null,
1486
+ border: image.border ? { ...paint(image.border.color, context.colors.border), width: image.border.width } : null,
1487
+ opacity: image.opacity ?? null,
1488
+ recolor: image.recolor?.type === 'grayscale' ? { type: 'grayscale' }
1489
+ : image.recolor?.type === 'duotone' ? { type: 'duotone', dark: paint(image.recolor.dark, '000000'), light: paint(image.recolor.light, 'FFFFFF') } : null,
1490
+ overlay: image.overlay ? { ...paint(image.overlay.color, context.colors.text), opacity: image.overlay.opacity, box: image.overlay.box, shape: image.overlay.shape } : null,
1491
+ };
1492
+ context.slideImages.set(objectName, { slide: `slides.${slideIndex}`, box, fill: image.fill, path: image.sourcePath, treatment: { ...treatment, position: image.position }, effects });
1493
+ slide.addImage({ ...resolved, objectName, ...box, altText: image.alt ?? assetAlt(image.value, presentation) });
1494
+ // The overlay scrim is a separate native shape directly above the picture.
1495
+ if (effects.overlay) {
1496
+ const overlay = effects.overlay.box;
1497
+ 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 } });
1498
+ }
1499
+ }
1500
+
1501
+ function addChartPayload(slide, chart, region, context, options = {}, path = "chart") {
1167
1502
  const chartData = toPptxChartData(chart);
1168
- if (!chartData) {
1169
- addPlaceholderPayload(slide, "Chart", chart, region, context);
1503
+ if (!chartData.series) {
1504
+ // Never lose a chart silently: the placeholder frame stands in for it, and a diagnostic names the reason.
1505
+ options.onDiagnostic?.({code: "chart-data-unplottable", path, message: chartData.message, reason: chartData.reason});
1506
+ addPlaceholderPayload(slide, "Chart", chartData.summary, region, context);
1170
1507
  return;
1171
1508
  }
1509
+ if (chartData.adapted) options.onDiagnostic?.({code: "chart-data-adapted", path, message: chartData.message, adaptation: chartData.adapted});
1172
1510
 
1173
1511
  // Keep the resolved palette surface (including alpha) explicit in native
1174
1512
  // chart/plot areas, so inherited labels are assessed against their own panel.
@@ -1178,7 +1516,8 @@ function addChartPayload(slide, chart, region, context) {
1178
1516
  const fill = {color:normalizeHex(panelFill),transparency};
1179
1517
  const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
1180
1518
  const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
1181
- context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chart.data.columns[0],labelColor});
1519
+ context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor});
1520
+ context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
1182
1521
  slide.addChart(chartData.type, chartData.series, {
1183
1522
  objectName,
1184
1523
  x: region.x,
@@ -1212,7 +1551,8 @@ function addTablePayload(slide, table, region, context, options, path) {
1212
1551
  const hasHeaders = Array.isArray(table?.columns) && table.columns.length > 0;
1213
1552
  const sourceRows = [...(hasHeaders ? [table.columns] : []), ...(table?.rows ?? [])];
1214
1553
  if (sourceRows.length === 0) {
1215
- addPlaceholderPayload(slide, "Table", table, region, context);
1554
+ 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."});
1555
+ addPlaceholderPayload(slide, "Table", "The table has no rows.", region, context);
1216
1556
  return;
1217
1557
  }
1218
1558
 
@@ -1235,10 +1575,10 @@ function addTablePayload(slide, table, region, context, options, path) {
1235
1575
  const fragment = fragments.find(item => item.runIndex === index);
1236
1576
  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
1577
  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;
1578
+ 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
1579
  const runOptions = {
1240
1580
  ...nativeFontOptions(runStyle),fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
1241
- underline:run.underline ? {color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
1581
+ underline:run.underline ? {style:'sng',color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
1242
1582
  color,transparency,baseline:fragment?.baselineShift ? -fragment.baselineShift / fragment.fontSize * 2000 : undefined,
1243
1583
  hyperlink:run.link && /^(https?:|mailto:)/i.test(run.link) ? {url:run.link} : undefined,
1244
1584
  };
@@ -1265,11 +1605,11 @@ function addTablePayload(slide, table, region, context, options, path) {
1265
1605
  valign: cellStyle.verticalAlign ?? 'top',
1266
1606
  ...(cell.colSpan > 1 ? {colspan:cell.colSpan} : {}),
1267
1607
  ...(cell.rowSpan > 1 ? {rowspan:cell.rowSpan} : {}),
1268
- color: normalizeHex(baseColor),
1608
+ color: nativeColor(cellStyle.color, baseColor, context),
1269
1609
  // Rich runs carry resolved alpha. A translucent cell default would
1270
1610
  // overwrite an explicit opaque run because PptxGenJS inherits falsy 0.
1271
1611
  ...(cellStyle.color && !rich ? {transparency:alpha(baseColor)} : {}),
1272
- fill: { color: normalizeHex(baseFill), ...(cellStyle.fill ? {transparency:alpha(baseFill)} : {}) },
1612
+ fill: { color: nativeColor(cellStyle.fill, baseFill, context), ...(cellStyle.fill ? {transparency:alpha(baseFill)} : {}) },
1273
1613
  },
1274
1614
  };
1275
1615
  }));
@@ -1281,6 +1621,9 @@ function addTablePayload(slide, table, region, context, options, path) {
1281
1621
  x: region.x,
1282
1622
  y: region.y,
1283
1623
  w: region.w,
1624
+ // PowerPoint derives a table's height from its rows, and the preview draws
1625
+ // the rows (layout.height, never more than the composed box). The declared
1626
+ // frame height is the row total so the XML matches what both engines draw.
1284
1627
  h: layout.height / 96,
1285
1628
  rowH: layout.rows.map(row => row.box.height / 96),
1286
1629
  colW: Array(columnCount).fill(region.w / columnCount),
@@ -1311,20 +1654,34 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
1311
1654
  options.onDiagnostic?.(diagnostic);
1312
1655
  if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path, issues: [diagnostic]});
1313
1656
  }
1657
+ // Generated socials carry one link per explicit source line; wrapped lines share it.
1658
+ let sourceLineIndex = 0;
1314
1659
  for (const [index, line] of fit.lines.entries()) {
1660
+ const link = config.links?.[sourceLineIndex];
1661
+ if (fit.sourceLines?.[index]?.boundary === 'hard') sourceLineIndex += 1;
1315
1662
  if (!line&&!config.heading&&!config.sourceText&&!config.timeline&&!config.keepEmpty) continue;
1316
1663
  const alignment=fit.placement?.alignment??config.align??context.contentAlignment??'left',placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
1317
1664
  const sourceLine=fit.sourceLines?.[index],boundary=sourceLine?{boundary:sourceLine.boundary,separator:String(text).slice(sourceLine.end,sourceLine.nextStart)}:{};
1318
1665
  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
1666
  if(config.heading)context.headingTags.set(objectName,{v:1,group:config.path,field:config.heading,line:index,count:fit.lines.length,...boundary});
1320
1667
  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});
1668
+ else if(config.furniture){
1669
+ context.furnitureTags.set(objectName,{v:1,role:'text',...config.furniture,line:index,count:fit.lines.length,...boundary});
1670
+ if(config.liveFields?.[index]?.length)context.furnitureFields.set(objectName,{text:line,fields:config.liveFields[index]});
1671
+ }
1672
+ 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
1673
  else if(config.sourceText)context.plainTextTags.set(objectName,{v:1,group:config.path,line:index,count:fit.lines.length,...boundary});
1323
1674
  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, {
1675
+ // A run-level link keeps the muted, non-underlined furniture look of the preview (hlinkClr=tx, u=none).
1676
+ const lineColor = normalizeHex(config.color ?? context.colors.text);
1677
+ slide.addText(link?.href && line ? [{text: line, options: {hyperlink: {url: link.href}, color: lineColor, underline: {style: 'none'}}}] : line, {
1325
1678
  ...textBoxOptions(area, context, fit.fontSize * .75),
1326
1679
  ...nativeFontOptions(style),
1327
- color: normalizeHex(config.color ?? context.colors.text), align: alignment,
1680
+ color: lineColor, align: alignment,
1681
+ // Reuse the frame's existing relationship to cover blank caption boxes.
1682
+ // PptxGenJS also inherits it into runs; keep their native text styling.
1683
+ hyperlink: config.hyperlink,
1684
+ underline: config.hyperlink ? {style: 'none'} : undefined,
1328
1685
  tabStops:sourceLine?.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'})),
1329
1686
  objectName,
1330
1687
  fit: 'none', wrap: false, lineSpacingMultiple: 1,
@@ -1339,6 +1696,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
1339
1696
  if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
1340
1697
  return;
1341
1698
  }
1699
+ const staticDates = new Map();
1342
1700
  for(const [index,part]of layout.parts.entries()){
1343
1701
  if(part.type==='image'){
1344
1702
  const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
@@ -1346,10 +1704,27 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
1346
1704
  if (objectName) context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:index});
1347
1705
  }else{
1348
1706
  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}});
1707
+ // Slide numbers and current dates become native PowerPoint fields; {total}
1708
+ // and fixed dates stay fixed text. A current date whose pattern has no
1709
+ // en-US field type is written as its laid-out text.
1710
+ const fields=[];
1711
+ for(const field of furniturePartFields(part)){
1712
+ const nativeType=nativeFieldType(field);
1713
+ if(nativeType)fields.push({...field,nativeType});
1714
+ 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.`});
1715
+ }
1716
+ const liveFields=fields.length?lineFields(part.text,fields,part.fit.sourceLines,part.fit.lines):undefined;
1717
+ for(const field of fields.filter(field => !lineFields(part.text,[field],part.fit.sourceLines,part.fit.lines).some(line => line.length))) {
1718
+ 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.`});
1719
+ if(context.provenanceMode === 'full') {
1720
+ const marker = staticDateFallback(part,field);
1721
+ if(marker) staticDates.set(index,marker);
1722
+ }
1723
+ }
1724
+ 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
1725
  }
1351
1726
  }
1352
- const manifest = furnitureManifest(presentation, source, layout, slideIndex);
1727
+ const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates);
1353
1728
  if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
1354
1729
  }
1355
1730
 
@@ -1364,7 +1739,7 @@ function addCodePayload(slide, value, layout, region, context, path) {
1364
1739
  const group = String(context.codeTags.size + 1), panelName = `OPF code ${group} panel`;
1365
1740
  for (const part of layout.parts) if (!part.fit) throw new OPFPptxError('layout-overflow', 'Code content has no usable internal space; increase its cell size before exporting.', {path:part.path,issues:layout.diagnostics});
1366
1741
  context.codeTags.set(panelName,codeManifest(value,layout,group));
1367
- slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', pt: .75}, objectName:panelName});
1742
+ slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', width: .75}, objectName:panelName});
1368
1743
  for (const [partIndex,part] of layout.parts.entries()) {
1369
1744
  if (!part.fit) throw new OPFPptxError('layout-overflow', 'Code content has no usable internal space; increase its cell size before exporting.', {path:part.path,issues:layout.diagnostics});
1370
1745
  for (const [index,line] of part.fit.sourceLines.entries()) {
@@ -1427,7 +1802,7 @@ function addTimelinePayload(slide, value, layout, context, options, path) {
1427
1802
  const scale=Math.min(context.dimensions.widthInches,context.dimensions.heightInches)*96/720;
1428
1803
  const group=String(context.timelineTags.size),anchor=timelineManifest(value,layout),connectorName=`OPF timeline ${group} connector`;
1429
1804
  const {x1,y1,x2,y2}=layout.connector;
1430
- slide.addShape('line',{objectName:connectorName,x:x1/96,y:y1/96,w:(x2-x1)/96,h:(y2-y1)/96,line:{color:context.colors.border,pt:3*scale*.75}});
1805
+ slide.addShape('line',{objectName:connectorName,x:x1/96,y:y1/96,w:(x2-x1)/96,h:(y2-y1)/96,line:{color:context.colors.border,width:3*scale*.75}});
1431
1806
  context.timelineTags.set(connectorName,{v:1,group,role:'connector'});
1432
1807
  for(const marker of layout.markers){
1433
1808
  const objectName=`OPF timeline ${group} marker ${marker.eventIndex}`;
@@ -1439,16 +1814,110 @@ function addTimelinePayload(slide, value, layout, context, options, path) {
1439
1814
  }
1440
1815
  }
1441
1816
 
1442
- function addPlaceholderPayload(slide, label, value, region, context) {
1817
+ // Mirror the renderer's media placeholder: a bordered surface, a centred 72px
1818
+ // play badge and a centred caption (title, then source). Native video embedding
1819
+ // is not attempted; the caption keeps the reference editable.
1820
+ function addMediaPayload(slide, presentation, value, region, path, context, options) {
1821
+ const box = pixelBox(region);
1822
+ const icon = {x: box.x + (box.width - 72) / 2, y: box.y + (box.height - 72) / 2};
1823
+ // The frame links to a web source (after asset dereferencing), so the
1824
+ // reference stays usable in PowerPoint; OPF_MEDIA_V1 keeps the OPF value.
1825
+ const resolved = dereferenceAsset(value, presentation, path), source = typeof resolved === "string" ? resolved : resolved?.src;
1826
+ const hyperlink = typeof source === "string" && /^https?:\/\//i.test(source) ? {url: source} : undefined;
1827
+ const names = {frame: `OPF media ${path} frame`, badge: `OPF media ${path} badge`, play: `OPF media ${path} play`};
1828
+ if (context.provenanceMode !== false) {
1829
+ context.mediaTags.set(names.frame, mediaFrameRecord(value, path, presentation, context.provenanceMode, options.onDiagnostic));
1830
+ context.mediaTags.set(names.badge, {v: 1, role: "badge", path});
1831
+ context.mediaTags.set(names.play, {v: 1, role: "play", path});
1832
+ }
1833
+ 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, width: 0.75}, hyperlink, objectName: names.frame});
1834
+ 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});
1835
+ 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});
1836
+ addMeasuredPayloadText(slide, mediaCaption(value), {...box, y: icon.y + 72 + 20, height: 50}, context, options,
1837
+ {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});
1838
+ }
1839
+
1840
+ // The asset an image block names, layered as the preview layers it: fields on the
1841
+ // block win over the registry entry it references, and an unknown reference keeps
1842
+ // the block's own fields.
1843
+ function placeholderAsset(asset, presentation) {
1844
+ const plain = value => typeof value === "string" ? { src: value } : isPlainObject(value) ? value : { src: "" };
1845
+ let current = plain(asset);
1846
+ const seen = new Set();
1847
+ while (typeof current.src === "string" && current.src.startsWith("asset:")) {
1848
+ const id = current.src.slice(6);
1849
+ if (seen.has(id) || !Object.hasOwn(presentation.assets ?? {}, id)) break;
1850
+ seen.add(id);
1851
+ const { src: _source, ...overrides } = current;
1852
+ current = { ...plain(presentation.assets[id]), ...overrides };
1853
+ }
1854
+ return current;
1855
+ }
1856
+
1857
+ // An image that cannot be embedded exports the preview's placeholder, not a second
1858
+ // design: a dashed panel with a centered "Image unavailable" over the asset's alt
1859
+ // text (else title, else "Image"), set bold at 20 px (shrinking to the composition
1860
+ // minimum) in the readable text colour for the panel, or a cross when the label
1861
+ // cannot fit even at that minimum. The panel carries the accessible name the
1862
+ // preview gives its group, "Image unavailable: <description>".
1863
+ function addImagePlaceholder(slide, presentation, asset, region, path, context, options) {
1864
+ const layered = placeholderAsset(asset, presentation);
1865
+ const description = String(layered.alt ?? layered.title ?? "Image");
1866
+ const label = `Image unavailable\n${description}`;
1867
+ // The preview rejects characters DrawingML/SVG XML cannot represent when it draws the label.
1868
+ // The cross and the accessible name would otherwise carry them into the slide XML, so check
1869
+ // up front and fail the same way whichever form the placeholder takes.
1870
+ const invalid = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF￾￿]/u.exec(label);
1871
+ 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 });
1872
+ const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
1873
+ const box = pixelBox(region);
1874
+ const padding = Math.min(24 * scale, box.width * .06, box.height * .1);
1875
+ 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) };
1876
+ const surface = normalizeHex(context.colors.surface);
1877
+ const textColor = normalizeHex(textColorForFill(`#${surface}`, `#${context.colors.text}`));
1878
+ const style = resolveTextStyle({ fontFamily: context.fonts.body, fontWeight: 600, path }, options.textMeasurement);
1879
+ const measurer = textWidthMeasurer(style, options.textMeasurement);
1880
+ const minimum = (context.composition?.minFontSize ?? 16) * scale;
1881
+ const fit = fitText(label, inner, 20 * scale, minimum, measurer);
1882
+ const objectName = `OPF image placeholder ${context.imagePlaceholders.size + 1}`;
1883
+ context.imagePlaceholders.set(objectName, `Image unavailable: ${description}`);
1884
+ slide.addShape("rect", {
1885
+ x: region.x, y: region.y, w: region.w, h: region.h,
1886
+ fill: { color: surface },
1887
+ line: { color: context.colors.border, width: .75, dashType: "dash" },
1888
+ objectName
1889
+ });
1890
+ if (!fit.overflow) {
1891
+ // The lines are centered vertically in the padded box. Fit against the shifted
1892
+ // box so any per-line placement carries the same offset.
1893
+ const offset = Math.max(0, (inner.height - fit.lines.length * fit.lineHeight) / 2);
1894
+ const centered = { ...inner, y: inner.y + offset };
1895
+ addMeasuredPayloadText(slide, label, centered, context, options, {
1896
+ path, fit: fitText(label, centered, 20 * scale, minimum, measurer), textStyle: style,
1897
+ diagnosticsHandled: true, align: "center", color: textColor
1898
+ });
1899
+ return;
1900
+ }
1901
+ // A status indicator, not shortened authored content: the full description stays
1902
+ // in the panel's accessible name.
1903
+ const size = Math.max(0, Math.min(inner.width, inner.height, 24 * scale));
1904
+ if (!size) return;
1905
+ const icon = { x: inner.x + (inner.width - size) / 2, y: inner.y + (inner.height - size) / 2 };
1906
+ const line = { color: textColor, width: Math.min(2 * scale, size / 8) * .75 };
1907
+ 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 });
1908
+ }
1909
+
1910
+ // A placeholder names what is missing in plain words. It never dumps the source value, so no data or URL lands in slide text.
1911
+ function addPlaceholderPayload(slide, label, description, region, context) {
1443
1912
  slide.addShape("rect", {
1444
1913
  x: region.x,
1445
1914
  y: region.y,
1446
1915
  w: region.w,
1447
1916
  h: region.h,
1448
1917
  fill: { color: context.colors.surface, transparency: 10 },
1449
- line: { color: context.colors.border, pt: 0.75 }
1918
+ line: { color: context.colors.border, width: 0.75 }
1450
1919
  });
1451
- slide.addText(`${label}\n${summarizeValue(value)}`, {
1920
+ slide.addText(`${label}\n${description}`, {
1452
1921
  x: region.x + 0.12,
1453
1922
  y: region.y + 0.12,
1454
1923
  w: Math.max(0.2, region.w - 0.24),
@@ -1463,6 +1932,46 @@ function addPlaceholderPayload(slide, label, value, region, context) {
1463
1932
  });
1464
1933
  }
1465
1934
 
1935
+ // FF-31: a measurement provider may preview a chosen family with another face
1936
+ // (Carlito for Aptos or Calibri, Gelasio for Georgia, a caller alias, a generic
1937
+ // fallback). That substitute changes measurement and drawing only. The package
1938
+ // always names the developer's chosen family. Measurement still uses the
1939
+ // substitute, because the provider resolves the chosen family again on every
1940
+ // measure/outline call, so layout is unchanged.
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} `) && isFaceStyleSuffix(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) {