@openpresentation/opf-pptx 0.13.2 → 0.15.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
@@ -8,8 +8,9 @@ import {legacyVendorOutput} from './vendor-compat.js';
8
8
  import {readChartCategoryHeading,writeChartCategoryHeading,writeChartWorkbookFormats,repairChartWorkbookRanges} from './chart-workbook.js';
9
9
  import {applyChartNumberFormats,chartNumber,excelCode,formattedColumns,inlineChartData,inlineTableData,isDatasetRef,numericRanges,resolveChartData} from './chart-data.js';
10
10
  import {attachDataProvenance,chartDataRecord,chartEvidence,readDataTag,readDatasetsTag,restoreChartData,restoreTableData,tableDataRecord} from './data-provenance.js';
11
- import {CHARTEX_FALLBACK,resolveChartType,chartTypeFromNative,applyChartConstruct,NATIVE_CHART_ELEMENTS} from './chart-types.js';
11
+ import {CHARTEX_FALLBACK,resolveChartType,chartTypeFromNative,applyChartConstruct,comboFromNative,NATIVE_CHART_ELEMENTS} from './chart-types.js';
12
12
  import {attachChartexParts,chartFromChartex,CHARTEX_GRAPHIC_DATA_URI} from './chartex.js';
13
+ import {applyChartAlt,readFrameAlt} from './chart-alt.js';
13
14
  import {applyDataLabels,chartOptionsFromClassic,chartTargetFor,classicChartOptions,reportChartOptionDiagnostics,resolveChartOptionsFor} from './chart-options.js';
14
15
  import {attachCodeTags, attachTextTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
15
16
  import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
@@ -19,9 +20,10 @@ import {attachMediaTags,importMediaGroups,mediaCaption,mediaFrameRecord,mediaTex
19
20
  import {addCaption, addFootnotes} from './annotation-export.js';
20
21
  import {attachAnnotationTags, captionValue, importAnnotations, restoreCitations} from './annotation-provenance.js';
21
22
  import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
23
+ import {headingValue,joinRichLines} from './rich-heading.js';
22
24
  import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
23
25
  import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
24
- import {attachQuoteTags,quoteManifest,importQuoteGroups} from './quote-provenance.js';
26
+ import {attachQuoteTags,quoteManifest,importQuoteGroups,quotePhotoName} from './quote-provenance.js';
25
27
  import {attachFurnitureTags, furnitureManifest, importFurniture, manifestPartIndex, staticDateFallback} from './furniture-provenance.js';
26
28
  import {restoreRunColors} from './run-colors.js';
27
29
  import {joinWrappedText} from './content-topology.js';
@@ -31,35 +33,38 @@ import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType}
31
33
  import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, writeNativeMasters} from './native-furniture.js';
32
34
  import {importImageOrientation} from './image-import.js';
33
35
  import {extractSignals, normalizeSignalOptions, themeFactsFor} from './import-signals.js';
34
- import {placeSlideImages, importSlideImage, slideImageName, slideImageOverlayName} from './slide-image-provenance.js';
36
+ import {placeImages, importImages, imageName, imageOverlayName, backgroundImageName, backgroundOverlayName, IMAGE_TREATMENT_KEYS, BACKGROUND_IMAGE_KEYS} from './image-provenance.js';
35
37
  import {dedupeMedia} from './media-dedupe.js';
36
- import {placeWatermarks, importWatermark, watermarkName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
38
+ import {placeWatermarks, importWatermark, importTextWatermark, tagTextWatermarks, watermarkName, watermarkTextName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
37
39
  import {placeLogos, importLogo, importLogoPlaceholders, logoName, LOGO_TAG} from './logo-provenance.js';
38
40
  import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
39
41
  import {importBackground} from './background-import.js';
42
+ import {chartHighlightPlan, applyChartHighlight} from './chart-highlight.js';
40
43
  import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
41
- import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, planSlideThemes, reconcileLanguage, reportPerSlideNotesScriptFonts, stripRunScriptFonts, themeEastAsianFromLatin} from './script-fonts.js';
44
+ import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, planSlideThemes, reconcileLanguage, reportPerSlideNotesScriptFonts, runLanguageFonts, runLanguageTag, stripRunScriptFonts, themeEastAsianFromLatin} from './script-fonts.js';
42
45
  import { webpToPng, svgToPng, readLocalFile } from '#image-fallback';
43
46
  import { prepareSvg, svgDataUriBytes, svgRasterScale, svgBlipRelationship, attachSvgPictures } from './svg-image.js';
44
47
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
45
- import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
46
- // chartPaletteForFill ships with core RR-29 (opf#270); an older published core still loads and clamps each colour on its own.
47
- import * as opfComposition from "@openpresentation/opf/composition";
48
+ import {
49
+ catalogRecords, chartPaletteForFill, codeHighlightBands, codeHighlightColors, codeHighlightLines, codeLineNumbers, codeLineRuns, codeSyntaxPaletteForScheme, composeSlide,
50
+ ENGINE_DEFAULT_FONT_SCHEME, defaultSlideBackground, fitImage, fitText, layoutTable, layoutWatermark, metricTrendMark, resolveCanvasDimensions, resolveColorRoles, resolveFontFamilies,
51
+ resolveReference, resolveTextStyle, textColorForFill, textWidthMeasurer, timelineMarkerShapes, timelineTextColor, tokenizeCode
52
+ } from "@openpresentation/opf/composition";
48
53
  import { colorContext, resolveColorRefValue, resolveExportColor, resolveVariableColors } from "./color-ref.js";
49
54
  import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
50
55
  import { unzipSync, zipSync } from "fflate";
51
56
  import { XMLParser } from "fast-xml-parser";
52
57
  import {
53
- catalogs as bundledCatalogs,
54
- validatePresentation
58
+ hasContentVariables,
59
+ isTemplate,
60
+ OPFCatalogsOptionError,
61
+ resolveSlideContext as resolveCoreSlideContext,
62
+ resolveVariables,
63
+ validate
55
64
  } from "@openpresentation/opf";
56
- // Optional core exports (RR-07 syntax colours, trend marks) are read from the namespace so an older published core still loads.
57
-
58
- // Optional core exports are read from the namespace so an older published core still loads;
59
- // resolveVariables ships with core RR-32.
60
- import * as opfCore from "@openpresentation/opf";
65
+ import {checkFormat, isValidFormat} from "./format-check.js";
61
66
 
62
- export {checkPptxTypefaces, inventoryPptxTypefaces, packageFontsUsed, THEME_SCRIPT_SUPPLEMENTS} from './typeface-inventory.js';
67
+ export {checkTypefaces, inventoryTypefaces, packageFontsUsed, THEME_SCRIPT_SUPPLEMENTS} from './typeface-inventory.js';
63
68
  export {DEFAULT_SIGNAL_LIMITS, SIGNALS_VERSION, isMonospaceFamily} from './import-signals.js';
64
69
 
65
70
  export const packageName = "@openpresentation/opf-pptx";
@@ -87,7 +92,8 @@ export class OPFPptxError extends Error {
87
92
  this.name = "OPFPptxError";
88
93
  this.code = code;
89
94
  this.details = details;
90
- if (details.issues) this.issues = details.issues;
95
+ if (details.findings) this.findings = details.findings;
96
+ if (details.diagnostics) this.diagnostics = details.diagnostics;
91
97
  if (details.path) this.path = details.path;
92
98
  }
93
99
  }
@@ -191,16 +197,12 @@ const DIMENSION_PRESETS = Object.freeze({
191
197
  "4:3": Object.freeze({ widthInches: 10, heightInches: 7.5 }),
192
198
  "16:10": Object.freeze({ widthInches: 10, heightInches: 6.25 }),
193
199
  letter: Object.freeze({ widthInches: 11, heightInches: 8.5 }),
200
+ "1:1": Object.freeze({ widthInches: 7.5, heightInches: 7.5 }),
201
+ "4:5": Object.freeze({ widthInches: 7.5, heightInches: 9.375 }),
202
+ "9:16": Object.freeze({ widthInches: 7.5, heightInches: 40 / 3 }),
194
203
  a4: Object.freeze({ widthInches: 11.69, heightInches: 8.27 })
195
204
  });
196
205
 
197
- const DEFAULTS = Object.freeze({
198
- theme: "minimal",
199
- colorScheme: "cool-horizon",
200
- // Shared last-resort font scheme (core DEFAULT_FONT_SCHEME): every engine uses aptos.
201
- fontScheme: "aptos"
202
- });
203
-
204
206
  const CHART_COLORS = [
205
207
  "2874A6",
206
208
  "1B4F72",
@@ -216,6 +218,12 @@ const CHART_COLORS = [
216
218
  "64748B"
217
219
  ];
218
220
 
221
+ // The `catalogs` option is core's registered catalogs (`Catalog[]`). A value that is no array is core's
222
+ // OPFCatalogsOptionError (code `invalid-catalogs`) up front; core checks each entry where it resolves a reference.
223
+ function checkCatalogs(entry, catalogs) {
224
+ if (catalogs !== undefined && !Array.isArray(catalogs)) throw new OPFCatalogsOptionError(entry, `got ${catalogs === null ? 'null' : typeof catalogs}`);
225
+ }
226
+
219
227
  export async function toPptx(input, options = {}) {
220
228
  if (options.imageFormat !== undefined && !['compatible', 'preserve'].includes(options.imageFormat)) {
221
229
  throw new OPFPptxError('invalid-image-format', 'imageFormat must be compatible or preserve.', {path: 'options.imageFormat'});
@@ -223,20 +231,30 @@ export async function toPptx(input, options = {}) {
223
231
  if (options.provenance !== undefined && !['full', 'references-only', false].includes(options.provenance)) {
224
232
  throw new OPFPptxError('invalid-provenance-option', "provenance must be 'full', 'references-only' or false.", {path: 'options.provenance'});
225
233
  }
234
+ // OPF 0.15 (FA-23): the host's registered catalogs (core `Catalog[]`, matched by `source`, never fetched) reach every core
235
+ // call that resolves a reference. Strict export (`strictReferences`, default `strictAssets`) fails on a reference that
236
+ // resolves nowhere instead of composing automatically or drawing with the engine default.
237
+ checkCatalogs('toPptx', options.catalogs);
238
+ options = {...options, strictReferences: options.strictReferences ?? options.strictAssets === true};
226
239
  const presentation = resolveTemplateInput(parseInput(input), options);
227
240
  assertValidBoundary(presentation);
228
- options = {...options, textMeasurement: chosenFamilyMeasurement(options.textMeasurement), svgRasters: new Map()};
241
+ // `options.fonts` is the fonts handle (the renderer's loadFonts()); the exporter reads its measurement, wrapped so the package names the
242
+ // chosen family (FF-31). Internally `options.textMeasurement` is that wrapped measurement; it is not a public option.
243
+ options = {...options, textMeasurement: chosenFamilyMeasurement(options.fonts?.textMeasurement), svgRasters: new Map()};
229
244
 
230
245
  const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
231
246
  context.listMarkers = new Map();
232
247
  context.imagePlaceholders = new Map();
233
248
  context.tableHeaders = new Map();
234
249
  context.tableCells = new Map();
235
- context.imagePlacements = new Map();
250
+ // FA-23: every exported image picture (image blocks, the alt-text form of an image background, timeline pictures) and
251
+ // every image overlay shape, by object name (src/image-provenance.js).
252
+ context.images = new Map();
253
+ context.imageOverlays = new Map();
236
254
  context.svgPictures = new Map();
237
255
  context.pictureText = new Map();
238
- context.slideImages = new Map();
239
256
  context.watermarks = new Map();
257
+ context.textWatermarks = new Map();
240
258
  context.logos = new Map();
241
259
  context.logoPlaceholderTags = new Map();
242
260
  context.bulletImages = new Map();
@@ -249,6 +267,7 @@ export async function toPptx(input, options = {}) {
249
267
  context.plainTextTags = new Map();
250
268
  context.timelineTags = new Map();
251
269
  context.quoteTags = new Map();
270
+ context.quotePhotos = new Map();
252
271
  context.furnitureTags = new Map();
253
272
  context.furnitureLogoTags = new Map();
254
273
  context.furnitureFields = new Map();
@@ -261,6 +280,7 @@ export async function toPptx(input, options = {}) {
261
280
  context.metricTags = new Map();
262
281
  context.metricDescriptions = new Map();
263
282
  context.chartHeadings = new Map();
283
+ context.chartAlts = new Map();
264
284
  context.chartFonts = new Map();
265
285
  context.chartPalettes = new Map();
266
286
  context.chartex = new Map();
@@ -277,15 +297,19 @@ export async function toPptx(input, options = {}) {
277
297
  throw new OPFPptxError('invalid-chartex-mode', 'chartex must be auto, native or fallback.', {path: 'options.chartex'});
278
298
  }
279
299
  context.chartexMode = options.chartex ?? 'auto';
280
- Object.assign(context, exportTheme(presentation, context));
281
- context.reportedFontSchemes = new Set();
300
+ Object.assign(context, exportTheme(presentation, context, options));
301
+ context.reportedReferences = new Set();
282
302
  // Document references and metadata tags (FF-32, docs/document-roundtrip.md).
283
303
  context.documentProvenance = options.provenance === false ? null : documentProvenance(presentation, {
284
304
  mode: options.provenance ?? "full",
285
- isCatalogId: (kind, id) => !!(findById(normalizeRecords(presentation.catalogs?.[kind]), id) ?? findById(defaultCatalog(kind), id)),
305
+ // Core's provenance of each slide's resolved layout (resolveSlideContext().resolved.provenance.layout) with its record.
306
+ layoutOf: index => {
307
+ const {resolved} = coreContext(presentation, index, options);
308
+ return resolved.provenance?.layout && resolved.layout ? {...resolved.provenance.layout, record: resolved.layout} : undefined;
309
+ },
286
310
  report: diagnostic => options.onDiagnostic?.(diagnostic)
287
311
  });
288
- context.scriptFonts = planScriptFonts(presentation, options.onDiagnostic);
312
+ context.scriptFonts = planScriptFonts(presentation, options.onDiagnostic, {catalogs: options.catalogs});
289
313
  // Slide section labels become PowerPoint's native section list (src/sections.js).
290
314
  // A label is an XML attribute: the characters text runs reject are rejected here too.
291
315
  context.sections = presentation.slides.map((slide, index) => {
@@ -297,15 +321,16 @@ export async function toPptx(input, options = {}) {
297
321
  context.masterBackground = masterBackground(context);
298
322
  context.linkSentinels = linkSentinels(presentation);
299
323
  const pptx = new PptxGenJS();
300
- configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
324
+ configurePresentation(pptx, presentation, {...context,fonts:exportSlideContext(presentation,0,context,options).fonts});
301
325
 
302
326
  for (let index = 0; index < presentation.slides.length; index += 1) {
303
327
  await addSlide(pptx, presentation, presentation.slides[index], index, context, options);
304
328
  }
305
329
 
306
- // FF-08: pitchFamily per exported family, from each slide's resolved scheme.
307
- context.fontPitch = fontPitchFamilies(presentation.slides.map((slide, index) => resolveSlideContext(presentation, slide, context, options, index).fonts),
308
- [...normalizeRecords(presentation.catalogs?.fontSchemes), ...defaultCatalog("fontSchemes")]);
330
+ // FF-08: pitchFamily per exported family, from each slide's resolved scheme, then the font schemes the document embeds
331
+ // and the registered catalogs its groups name (core catalogRecords).
332
+ context.fontPitch = fontPitchFamilies(presentation.slides.map((slide, index) => exportSlideContext(presentation, index, context, options).fonts),
333
+ catalogRecords(presentation, "fontSchemes", {catalogs: options.catalogs}).map(entry => entry.record));
309
334
 
310
335
  let raw;
311
336
  try {
@@ -325,6 +350,9 @@ export async function toPptx(input, options = {}) {
325
350
  export async function fromPptx(input, options = {}) {
326
351
  // Opt-in raw shape signals (import-signals.js). Validated first so a bad option fails before any work.
327
352
  const signalLimits = normalizeSignalOptions(options.signals, (code, message, details) => new OPFPptxError(code, message, details));
353
+ // OPF 0.15 (FA-23): catalogs the host registered (core `Catalog[]`, never fetched). Theme and colour-scheme recovery and
354
+ // stored layout ids resolve against them; with none, a colour scheme imports inline and no theme id is recovered.
355
+ checkCatalogs('fromPptx', options.catalogs);
328
356
  const entries = readPptxZip(input);
329
357
  const presentationDoc = parseRequiredXml(entries, "ppt/presentation.xml");
330
358
  const presentationRoot = presentationDoc["p:presentation"];
@@ -358,12 +386,12 @@ export async function fromPptx(input, options = {}) {
358
386
  const theme = relatedPart(relatedPart(relatedPart(path, 'slideLayout'), 'slideMaster'), 'theme');
359
387
  return theme ? {path: theme, xml: decodeText(entries[theme])} : null;
360
388
  }),
361
- catalogs: bundledCatalogs
389
+ catalogs: options.catalogs
362
390
  });
363
391
  if (observedLanguage.language !== undefined) imported.language = observedLanguage.language;
364
392
  if (core.description) imported.description = core.description;
365
393
  if (core.author) imported.author = splitAuthors(core.author);
366
- const themeDesign = importThemeDesign(entries, presentationRoot, presentationRels);
394
+ const themeDesign = importThemeDesign(entries, presentationRoot, presentationRels, options.catalogs);
367
395
  const design = {...themeDesign.design, ...(dimensions ? {dimensions} : {})};
368
396
  if (Object.keys(design).length) imported.design = design;
369
397
 
@@ -384,6 +412,7 @@ export async function fromPptx(input, options = {}) {
384
412
  const furniture = importFurniture(furnitureContexts, entries, options.onDiagnostic);
385
413
  if (Object.keys(furniture.design).length) imported.design = {...imported.design, ...furniture.design};
386
414
  if (furniture.organization) imported.organization = furniture.organization;
415
+ if (furniture.speaker) imported.speaker = furniture.speaker;
387
416
 
388
417
  // RR-54: the datasets recorded at export (OPF_DATASETS_V1). Chart and table frames restore their dataset references against
389
418
  // them, so the document carries them before document provenance validates the restored document.
@@ -391,7 +420,7 @@ export async function fromPptx(input, options = {}) {
391
420
  const mediaRegistry = Object.create(null);
392
421
  for (let index = 0; index < slidePaths.length; index += 1) {
393
422
  furnitureContexts[index].mediaRegistry = mediaRegistry;
394
- imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, {...options, rtlDeck: observedRtl(observedLanguage), dataProvenance: {datasets}}, furniture.slides[index], furnitureContexts[index]));
423
+ imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, {...options, rtlDeck: observedRtl(observedLanguage), deckLang: observedLanguage.lang, codeFamily: Object.keys(entries).some(path => /^ppt\/tags\/opf/i.test(path)) ? importedCodeFamily(imported, options.catalogs) : undefined, dataProvenance: {datasets}}, furniture.slides[index], furnitureContexts[index]));
395
424
  }
396
425
  if (datasets) imported.datasets = datasets;
397
426
  // Native sections (PowerPoint's own section list, `Default Section` = none)
@@ -421,14 +450,14 @@ export async function fromPptx(input, options = {}) {
421
450
  let slideProvenance = slidePaths.map(() => ({structure: "untagged"}));
422
451
  let restoredGroups = [];
423
452
  try {
424
- const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true,
425
- // Host catalogs format stored socials exactly as export did (FF-34).
426
- socialPlatformRecords: catalogs => socialPlatformRecords({catalogs}, options),
453
+ const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true, speakerConflict: furniture.speakerConflict === true,
454
+ // Stored layout ids also resolve in the host's registered catalogs (FA-23).
455
+ catalogs: options.catalogs,
427
456
  nativeSections: slideSections,
428
457
  slides: slidePaths.map((path, index) => ({path, root: furnitureContexts[index].root, relationships: furnitureContexts[index].relationships, contentBounds: furnitureContexts[index].contentBounds}))}, report);
429
458
  // The stored language wins while the runs still carry its tag (FF-07).
430
459
  restored.groups = reconcileLanguage(restored.groups, observedLanguage, report);
431
- imported = applyDocumentProvenance(imported, restored, validatePresentation, report);
460
+ imported = applyDocumentProvenance(imported, restored, checkFormat, report);
432
461
  slideProvenance = restored.slides;
433
462
  restoredGroups = restored.groups;
434
463
  } catch (error) {
@@ -443,11 +472,11 @@ export async function fromPptx(input, options = {}) {
443
472
  if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
444
473
  languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
445
474
 
446
- const result = validatePresentation(imported);
447
- if (!result.valid) {
475
+ const validation = checkFormat(imported);
476
+ if (!validation.valid) {
448
477
  throw new OPFPptxError("invalid-import-opf", "Imported PPTX did not produce valid OPF.", {
449
- issues: result.errors,
450
- result
478
+ findings: validation.findings.filter(finding => finding.severity === "error"),
479
+ report: validation
451
480
  });
452
481
  }
453
482
 
@@ -458,7 +487,7 @@ export async function fromPptx(input, options = {}) {
458
487
  const archive = {part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]};
459
488
  const signals = extractSignals({archive, presentationRoot, slidePaths, themeFacts: themeFactsFor(slidePaths[0], archive), limits: signalLimits,
460
489
  recorded: furnitureContexts.map(context => context.signalSources), slideProvenance, contentPaths});
461
- return {document: imported, signals};
490
+ return {presentation: imported, signals};
462
491
  }
463
492
 
464
493
  // RR-08: run colours named by a ColorRef (variable, scheme slot, role) come back as that name where the document's own
@@ -623,6 +652,17 @@ function readCoreProperties(entries) {
623
652
  };
624
653
  }
625
654
 
655
+ // FA-13: the code family of the imported design's font scheme (a run in it is an inline code run). The scheme's own `code` role,
656
+ // else that of the record the reference resolves to (in the imported document or the host's registered catalogs), else the
657
+ // engine default's; heading and body families are never code. Only a package the exporter wrote (it carries OPF tag parts) is
658
+ // read this way; a deck from another tool keeps its runs' font families as they are.
659
+ function importedCodeFamily(imported, catalogs) {
660
+ const reference = imported.design?.fontScheme;
661
+ const id = referenceId(reference);
662
+ const found = id ? resolveReference(imported, "fontSchemes", id, {catalogs: catalogs ?? []}) : undefined;
663
+ return resolveFontFamilies({...(found ? found.record : ENGINE_DEFAULT_FONT_SCHEME), ...(isPlainObject(reference) ? reference : {})}).code;
664
+ }
665
+
626
666
  function dimensionsFromPresentation(presentationRoot) {
627
667
  const size = presentationRoot["p:sldSz"];
628
668
  const width = emuToInches(size?.cx);
@@ -644,21 +684,34 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
644
684
  const background = importBackground(slidePath, resolveCanvasDimensions(dimensions), {
645
685
  part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
646
686
  }, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.design.background`}));
647
- if (background) slide.design = {background};
687
+ // FA-23: tagged image pictures and overlays (OPF_IMAGE_V1, OPF_IMAGE_OVERLAY_V1). An unchanged full-slide back picture is
688
+ // the slide's image background (its native fill is then only the canvas colour); an unchanged overlay of a native image
689
+ // background fill joins that background; an unchanged image block picture returns its treatment and placement.
690
+ const images = importImages({pictures: nativeContext.pictures, shapes: nativeContext.shapes, relationships, entries, slideIndex,
691
+ readPicture: picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
692
+ // The recorded crop, fit and focus are re-derived from the restored background.
693
+ if (diagnostic.code !== 'unsupported-image-crop') options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.design.background`});
694
+ }),
695
+ valid: (kind, value) => isValidFormat({slides: [kind === 'background' ? {design: {background: value}} : {blocks: [value]}]}),
696
+ report: diagnostic => options.onDiagnostic?.(diagnostic)});
697
+ const imageBackground = images.background ?? (background?.type === 'image' && images.backgroundOverlay ? {...background, overlay: images.backgroundOverlay} : background);
698
+ if (images.backgroundOverlay && imageBackground === background) {
699
+ images.consumedShapes.delete(images.backgroundOverlayShape);
700
+ options.onDiagnostic?.({code: 'invalid-image-provenance', path: `slides.${slideIndex}.design.background.overlay`, message: 'The tagged OPF background overlay no longer sits on an image background; it was imported as an ordinary shape.'});
701
+ }
702
+ if (imageBackground) slide.design = {background: imageBackground};
648
703
  if (Object.keys(furniture.design).length) slide.design = {...slide.design, ...furniture.design};
649
704
  if (furniture.section !== undefined) slide.section = furniture.section;
650
- const slideImagePath = `slides.${slideIndex}.design.slideImage`;
651
- const slideImage = importSlideImage(nativeContext.pictures, nativeContext.shapes, relationships, entries, slideIndex,
652
- picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
653
- // The recorded crop/fit is re-derived from design.slideImage.
654
- if (diagnostic.code !== 'unsupported-image-crop') options.onDiagnostic?.({...diagnostic, path: slideImagePath});
655
- }),
656
- diagnostic => options.onDiagnostic?.({...diagnostic, path: slideImagePath}));
657
- if (slideImage.design) slide.design = {...slide.design, ...slideImage.design};
658
- if (slideImage.image) slide.image = slideImage.image;
659
- nativeContext.slideImagePictures = slideImage.consumed;
660
- nativeContext.slideImageShapes = slideImage.consumedShapes;
705
+ nativeContext.imageBlocks = images.blocks;
706
+ nativeContext.imagePictures = images.consumed;
707
+ // An overlay of a background that was not recovered stays an ordinary shape.
708
+ nativeContext.imageShapes = images.consumedShapes;
661
709
  const watermarkPath = `slides.${slideIndex}.design.watermark`;
710
+ // FA-13: a text watermark is a tagged native text box; it is not content and never imports as a text block.
711
+ const textWatermark = importTextWatermark(nativeContext.shapes, nativeContext.paragraphs.map(shapeParagraphs => shapeParagraphs.map(paragraph => ({text: paragraph.text}))), relationships, entries, slideIndex,
712
+ diagnostic => options.onDiagnostic?.({...diagnostic, path: watermarkPath}));
713
+ if (textWatermark.design) slide.design = {...slide.design, ...textWatermark.design};
714
+ nativeContext.watermarkShapes = textWatermark.consumed;
662
715
  const watermark = importWatermark(nativeContext.pictures, relationships, entries, slideIndex,
663
716
  picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
664
717
  // The recorded fit is re-derived from design.watermark.
@@ -684,12 +737,22 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
684
737
  // fill an absent role by geometry; explicit native placeholders still apply.
685
738
  const inferHeadings = !items.some(item => item.heading||item.sourceText);
686
739
  const heading = field => {const index=items.findIndex(item=>item.heading===field);return index<0?null:items.splice(index,1)[0];};
740
+ // FA-10: a heading is its plain text, or TextRun[] when its native runs are formatted differently from one another (the way
741
+ // body text reads current native runs). A tagged line group brings its own value; one native shape (a title placeholder, or the
742
+ // shape the importer took for the title) is read through the native body reader.
743
+ const headingText = item => {
744
+ const key = item.sources?.length === 1 ? /^sp:(\d+)$/.exec(item.sources[0]) : null;
745
+ const parts = item.richText === undefined && key ? joinRichLines([{index: Number(key[1])}], nativeContext.readBody, () => '') : undefined;
746
+ const value = item.richText ?? (parts ? headingValue(parts) : undefined);
747
+ // Only when it reads as the same words as the plain path: the native text is authoritative.
748
+ return Array.isArray(value) && value.map(run => typeof run === 'string' ? run : run.text).join('') === item.text ? value : item.text;
749
+ };
687
750
  const tagItem=heading('tag');
688
- if(tagItem)slide.tag=tagItem.text;
751
+ if(tagItem)slide.tag=headingText(tagItem);
689
752
  const titleItem = heading('title')??takeTitleItem(items, dimensions, inferHeadings);
690
- if (titleItem) slide.title = titleItem.text;
753
+ if (titleItem) slide.title = headingText(titleItem);
691
754
  const subtitleItem = heading('subtitle')??takeSubtitleItem(items, titleItem, dimensions, inferHeadings);
692
- if (subtitleItem) slide.subtitle = subtitleItem.text;
755
+ if (subtitleItem) slide.subtitle = headingText(subtitleItem);
693
756
 
694
757
  // Heading inference keeps its existing scalar/native metrics. Only remaining
695
758
  // ordinary body shapes gain current rich values; tagged recovery is separate.
@@ -760,16 +823,21 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
760
823
  nativeContext.signalRoles = roles;
761
824
  const readBody = nativeBodyReader(slidePath, {
762
825
  part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
763
- }, relationships, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.${diagnostic.path}`}));
826
+ }, relationships, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.${diagnostic.path}`}), {deckLang: options.deckLang, codeFamily: options.codeFamily});
764
827
  const code = importCodeGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.code`}));
765
828
  const metric = importMetricGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.metric`}));
766
829
  const cards = importCardFrames(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
767
830
  const media = importMediaGroups(shapes, paragraphs, relationships, entries, slideIndex, diagnostic => options.onDiagnostic?.(diagnostic), nativeContext.mediaRegistry);
768
831
  nativeContext.mediaAssets = media.assets;
769
- const headings=importHeadingGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
832
+ nativeContext.readBody=readBody;
833
+ const headings=importHeadingGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}),readBody);
770
834
  const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
771
835
  const timelines=importTimelineGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
772
- const quotes=importQuoteGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.quote`}));
836
+ const quotes=importQuoteGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.quote`}),nativeContext.pictures,
837
+ picture=>importPicture(entries,picture,slidePath,relationships,diagnostic=>{
838
+ // The recorded crop is re-derived from the circular frame of the quote photo.
839
+ if(diagnostic.code!=='unsupported-image-crop')options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.quote.photo`});
840
+ }),readBody);
773
841
  // RR-34: tagged caption and footnote lines are not content; captions re-attach to their media block and notes rebuild cite/footnote.
774
842
  const annotations=importAnnotations({shapes,paragraphs,relationships,entries,slideIndex,readBody,report:diagnostic=>options.onDiagnostic?.(diagnostic)});
775
843
  nativeContext.annotations=annotations;
@@ -778,7 +846,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
778
846
  if(item){
779
847
  const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
780
848
  if(bounds.length){const x=Math.min(...bounds.map(b=>b.x)),y=Math.min(...bounds.map(b=>b.y));item.bounds={x,y,w:Math.max(...bounds.map(b=>b.x+b.w))-x,h:Math.max(...bounds.map(b=>b.y+b.h))-y};}
781
- items.push({...item,heading:group.field,sourceText:!group.field,sources:sourcesOf(group.shapes)});
849
+ items.push({...item,heading:group.field,sourceText:!group.field,sources:sourcesOf(group.shapes),...(group.richText!==undefined?{richText:group.richText}:{})});
782
850
  }
783
851
  }
784
852
  for(const group of timelines.items){
@@ -787,18 +855,18 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
787
855
  items.push({kind:'timeline',sourceText:true,bounds:union,payload:group.payload,sources:sourcesOf(group.shapes)});
788
856
  }
789
857
  for(const group of quotes.items){
790
- const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
858
+ const bounds=[...group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])),...group.pictures.map(picture=>shapeBounds(picture['p:spPr']?.['a:xfrm']))].filter(Boolean);
791
859
  let union;for(const bound of bounds){if(!union)union={...bound};else{const x=Math.min(union.x,bound.x),y=Math.min(union.y,bound.y);union={x,y,w:Math.max(union.x+union.w,bound.x+bound.w)-x,h:Math.max(union.y+union.h,bound.y+bound.h)-y};}}
792
- items.push({kind:'quote',bounds:union,payload:group.payload,sources:sourcesOf(group.shapes)});
860
+ items.push({kind:'quote',bounds:union,payload:group.payload,sources:[...sourcesOf(group.shapes),...group.pictures.map(picture=>`pic:${nativeContext.pictures.indexOf(picture)}`)]});
793
861
  }
794
862
  for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload,sources:sourcesOf([item.shape])});
795
863
  for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload,sources:sourcesOf([item.shape])});
796
864
  for (const item of media.items) items.push({kind:'media',sourceText:item.payload.type==='text',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload,sources:sourcesOf([item.shape])});
797
865
  for (const [index,shape] of shapes.entries()) {
798
- const role = furniture.text.has(index) ? 'furniture' : nativeContext.slideImageShapes?.has(index) ? 'slide-image' : nativeContext.logoPlaceholderShapes?.has(index) ? 'logo' : cards.has(shape) ? 'card-frame'
866
+ const role = furniture.text.has(index) ? 'furniture' : nativeContext.imageShapes?.has(index) ? 'image-overlay' : nativeContext.watermarkShapes?.has(index) ? 'watermark' : nativeContext.logoPlaceholderShapes?.has(index) ? 'logo' : cards.has(shape) ? 'card-frame'
799
867
  : code.consumed.has(shape) ? 'code' : metric.consumed.has(shape) ? 'metric' : media.consumed.has(shape) ? 'media' : timelines.consumed.has(shape) ? 'timeline' : quotes.consumed.has(shape) ? 'quote' : undefined;
800
868
  if (role) roles.set(shapeKeys.get(shape), role);
801
- if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index)) continue;
869
+ if (furniture.text.has(index) || nativeContext.imageShapes?.has(index) || nativeContext.watermarkShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index)) continue;
802
870
  if (annotations.consumed.has(shape)) { roles.set(shapeKeys.get(shape), 'annotation'); continue; }
803
871
  if (code.consumed.has(shape)||metric.consumed.has(shape)||cards.has(shape)||media.consumed.has(shape)||headings.consumed.has(shape)||plainText.consumed.has(shape)||timelines.consumed.has(shape)||quotes.consumed.has(shape)) continue;
804
872
  const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
@@ -834,10 +902,13 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
834
902
  }
835
903
 
836
904
  for (const [index, picture] of nativeContext.pictures.entries()) {
837
- const pictureRole = furniture.pictures.has(index) ? 'furniture' : nativeContext.slideImagePictures?.has(index) ? 'slide-image' : nativeContext.watermarkPictures?.has(index) ? 'watermark' : nativeContext.logoPictures?.has(index) ? 'logo' : undefined;
905
+ const pictureRole = furniture.pictures.has(index) ? 'furniture' : nativeContext.imagePictures?.has(index) ? 'background' : nativeContext.watermarkPictures?.has(index) ? 'watermark' : nativeContext.logoPictures?.has(index) ? 'logo' : quotes.consumedPictures.has(picture) ? 'quote' : undefined;
838
906
  if (pictureRole) { roles.set(`pic:${index}`, pictureRole); continue; }
839
- const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
907
+ // A tagged, unchanged image block brings back its own fields (fit, focus, treatment, placement); its crop is theirs.
908
+ const treatment = nativeContext.imageBlocks?.get(index);
909
+ const report = diagnostic => { if (!(treatment && diagnostic.code === 'unsupported-image-crop')) options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`}); };
840
910
  const item = importPicture(entries, picture, slidePath, relationships, report);
911
+ if (item?.payload && treatment) item.payload = {...item.payload, ...structuredClone(treatment)};
841
912
  if (item) items.push({...item, sources: [`pic:${index}`]});
842
913
  }
843
914
 
@@ -916,15 +987,18 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
916
987
  : chartFromRelationship(entries, slidePath, relationships, chartRelId, report);
917
988
  const stored = imported && record('chart');
918
989
  const part = relationships.get(chartRelId)?.path;
919
- const chart = stored && part && entries[part]
990
+ const restored = stored && part && entries[part]
920
991
  ? restore('chart', imported, () => restoreChartData(imported, stored, chartEvidence(decodeText(entries[part])), {datasets: data.datasets, report: diagnostic => data.report?.('chart', diagnostic)}))
921
992
  : imported;
993
+ const chart = restored?.type === 'combo' ? elideComboDefault(restored, data.datasets) : restored;
994
+ // FA-09: the frame's descr (or PowerPoint's decorative marker) is the chart's text alternative.
995
+ const alt = chart ? readFrameAlt(frame["p:nvGraphicFramePr"]?.["p:cNvPr"]) : undefined;
922
996
  return {
923
997
  kind: "chart",
924
998
  bounds,
925
999
  name,
926
1000
  payload: chart
927
- ? { type: "chart", chart }
1001
+ ? { type: "chart", chart: alt === undefined ? chart : { ...chart, alt } }
928
1002
  : { type: "text", text: `PowerPoint chart: ${name || chartRelId}` }
929
1003
  };
930
1004
  }
@@ -1211,9 +1285,12 @@ function chartFromRelationship(entries, slidePath, relationships, relId, report)
1211
1285
  const plotArea = doc["c:chartSpace"]?.["c:chart"]?.["c:plotArea"];
1212
1286
  if (!plotArea) return null;
1213
1287
 
1214
- const chartNode = firstChartNode(plotArea);
1288
+ // FA-15: a clustered column group with one or two line groups is a combo chart; its series are read in c:order.
1289
+ const combo = comboFromNative(plotArea);
1290
+ const comboSeries = combo ? comboSeriesOf(combo) : undefined;
1291
+ const chartNode = combo ? {node: combo.bar, type: 'combo'} : firstChartNode(plotArea);
1215
1292
  if (!chartNode) return null;
1216
- const series = asArray(chartNode.node["c:ser"]);
1293
+ const series = comboSeries ? comboSeries.map(entry => entry.ser) : asArray(chartNode.node["c:ser"]);
1217
1294
  if (series.length === 0) return null;
1218
1295
  if (series.length > MAX_CHART_CACHE_POINTS) {
1219
1296
  throw new OPFPptxError('invalid-chart-cache', 'Chart cache exceeds the 100,000-series import limit; reduce its series before importing.', {path: relationship.path});
@@ -1226,7 +1303,8 @@ function chartFromRelationship(entries, slidePath, relationships, relId, report)
1226
1303
  const xCode = chartNode.type === 'scatter' ? cacheFormatCode(series[0]?.["c:xVal"]) : undefined;
1227
1304
  // RR-35: axis titles, legend position and data labels read back into the chart's option fields.
1228
1305
  const withOptions = chart => {
1229
- const {options, notes} = chartOptionsFromClassic(doc["c:chartSpace"], {chartNode: chartNode.node, target: chartTargetFor(chartNode.type), seriesCount: series.length, circular: chartNode.type === 'pie' || chartNode.type === 'doughnut', scatter: chartNode.type === 'scatter', seriesFormats: valueCodes});
1306
+ const {options, notes} = chartOptionsFromClassic(doc["c:chartSpace"], {chartNode: chartNode.node, target: chartTargetFor(chartNode.type), seriesCount: series.length, circular: chartNode.type === 'pie' || chartNode.type === 'doughnut', scatter: chartNode.type === 'scatter', seriesFormats: valueCodes,
1307
+ ...(combo ? {combo: {lineNode: combo.lines[0]?.node, secondaryValueAxis: secondaryValueAxisOf(plotArea, combo)}} : {})});
1230
1308
  for (const note of notes) report?.(note);
1231
1309
  if (budget.rejected?.length) report?.({code: 'chart-value-not-numeric', option: 'data', message: `${budget.rejected.length} cached chart ${budget.rejected.length === 1 ? 'value is' : 'values are'} not a number (first at ${budget.rejected[0]}) and ${budget.rejected.length === 1 ? 'imports as a gap' : 'import as gaps'}, never as a guessed value.`});
1232
1310
  return chart && Object.keys(options).length ? {...chart, ...options} : chart;
@@ -1257,10 +1335,57 @@ function chartFromRelationship(entries, slidePath, relationships, relId, report)
1257
1335
  data: {
1258
1336
  columns: formattedColumns([readChartCategoryHeading(entries,relationship.path) ?? "Category", ...names], [undefined, ...valueCodes], report),
1259
1337
  rows
1260
- }
1338
+ },
1339
+ ...(comboSeries ? comboFields(comboSeries, names, report) : {})
1261
1340
  });
1262
1341
  }
1263
1342
 
1343
+ // FA-15: the series of a combo plot area with their role and axis, in c:order (document order breaks ties).
1344
+ function comboSeriesOf(combo) {
1345
+ const entries = [
1346
+ ...asArray(combo.bar["c:ser"]).map(ser => ({ser, role: 'bar', secondary: false})),
1347
+ ...combo.lines.flatMap(group => asArray(group.node["c:ser"]).map(ser => ({ser, role: 'line', secondary: group.secondary}))),
1348
+ ];
1349
+ const order = (entry, index) => {
1350
+ const value = Number(entry.ser?.["c:order"]?.val);
1351
+ return Number.isFinite(value) ? value : index;
1352
+ };
1353
+ return entries.map((entry, index) => ({...entry, key: order(entry, index), index})).sort((a, b) => a.key - b.key || a.index - b.index);
1354
+ }
1355
+
1356
+ // The c:valAx a secondary line group plots against (its axis that the column group does not use), or undefined.
1357
+ function secondaryValueAxisOf(plotArea, combo) {
1358
+ const group = combo.lines.find(entry => entry.secondary);
1359
+ if (!group) return undefined;
1360
+ const ids = new Set(asArray(group.node["c:axId"]).map(axis => String(axis?.val)));
1361
+ return asArray(plotArea["c:valAx"]).find(axis => ids.has(String(axis?.["c:axId"]?.val)));
1362
+ }
1363
+
1364
+ // `line` and `secondaryAxis` of an imported combo chart by series name (elideComboDefault drops a `line` that is the default).
1365
+ // Combo lines always draw markers in OPF; a native line without markers is reported, never silently changed.
1366
+ function comboFields(comboSeries, names, report) {
1367
+ const line = names.filter((_, index) => comboSeries[index].role === 'line');
1368
+ const secondaryAxis = names.filter((_, index) => comboSeries[index].secondary);
1369
+ const unmarked = names.filter((_, index) => comboSeries[index].role === 'line' && comboSeries[index].ser?.["c:marker"]?.["c:symbol"]?.val === 'none');
1370
+ if (unmarked.length) report?.({option: 'line', message: `The combo chart's line ${unmarked.length === 1 ? 'series' : 'series'} ${unmarked.map(name => `'${name}'`).join(', ')} ${unmarked.length === 1 ? 'has' : 'have'} no markers; OPF combo lines always draw markers, so ${unmarked.length === 1 ? 'it imports' : 'they import'} with markers.`});
1371
+ if (new Set(line).size !== line.length || new Set(names).size !== names.length) {
1372
+ report?.({option: 'line', message: 'Two series of the combo chart share a name, so which of them is a line cannot be said by name; the chart imports with its default line (the last series).'});
1373
+ return {};
1374
+ }
1375
+ return {...(line.length ? {line} : {}), ...(secondaryAxis.length ? {secondaryAxis} : {})};
1376
+ }
1377
+
1378
+ // An imported combo chart writes `line` only when it differs from the default (the last plotted series), judged on the final
1379
+ // data (a restored data record can order the columns differently from the native series).
1380
+ function elideComboDefault(chart, datasets) {
1381
+ if (!Array.isArray(chart.line)) return chart;
1382
+ const {line, ...rest} = chart;
1383
+ const resolved = resolveChartData(rest, {datasets: datasets ?? {}});
1384
+ if (!resolved.ok || !Array.isArray(resolved.combo)) return chart;
1385
+ const defaults = resolved.columns.slice(1).filter((_, index) => resolved.combo[index]?.role === 'line');
1386
+ return defaults.length === line.length && defaults.every(name => line.includes(name)) ? rest : chart;
1387
+ }
1388
+
1264
1389
  // The format code of a numeric cache (c:numRef/c:numCache or c:numLit), or undefined.
1265
1390
  function cacheFormatCode(node) {
1266
1391
  const cache = node?.["c:numRef"]?.["c:numCache"] ?? node?.["c:numLit"];
@@ -1505,60 +1630,72 @@ function parseInput(input) {
1505
1630
  // required variable is refused. Decks without content variables are returned untouched. The package stores the resolved
1506
1631
  // deck, not the template form, so fromPptx returns the filled deck.
1507
1632
  function resolveTemplateInput(presentation, options) {
1508
- if (typeof opfCore.resolveVariables !== "function" || !isPlainObject(presentation)) return presentation;
1633
+ if (!isPlainObject(presentation)) return presentation;
1509
1634
  const values = options.variables;
1510
1635
  if (values !== undefined && !isPlainObject(values)) {
1511
1636
  throw new OPFPptxError("invalid-variables", "The variables option must be an object keyed by variable id.", {path: "options.variables"});
1512
1637
  }
1513
- const template = opfCore.isTemplate(presentation);
1514
- if (!template && !opfCore.hasContentVariables(presentation) && !(values && Object.keys(values).length)) return presentation;
1515
- const result = opfCore.resolveVariables(presentation, values ?? {}, {examples: template});
1638
+ const template = isTemplate(presentation);
1639
+ if (!template && !hasContentVariables(presentation) && !(values && Object.keys(values).length)) return presentation;
1640
+ const result = resolveVariables(presentation, values ?? {}, {examples: template});
1516
1641
  const errors = result.diagnostics.filter(entry => entry.severity === "error");
1517
1642
  if (errors.length) {
1518
- throw new OPFPptxError(errors.some(entry => entry.code === "variable-unfilled") ? "unfilled-variables" : "invalid-variables", errors[0].message, {issues: errors, path: errors[0].path});
1643
+ throw new OPFPptxError(errors.some(entry => entry.code === "variable-unfilled") ? "unfilled-variables" : "invalid-variables", errors[0].message, {findings: errors, path: errors[0].path});
1519
1644
  }
1520
1645
  for (const entry of result.diagnostics) {
1521
- if (entry.code === "variable-example-used") options.onDiagnostic?.({code: "variable-example-used", path: entry.path, message: entry.message, id: entry.id});
1646
+ if (entry.code === "variable-example-used" || entry.code === "variable-builtin-missing") options.onDiagnostic?.({code: entry.code, path: entry.path, message: entry.message, id: entry.id});
1522
1647
  }
1523
1648
  return result.presentation;
1524
1649
  }
1525
1650
 
1526
1651
  function assertValidBoundary(presentation) {
1527
- const result = validatePresentation(presentation);
1528
- if (!result.valid) {
1652
+ const report = checkFormat(presentation);
1653
+ if (!report.valid) {
1529
1654
  throw new OPFPptxError("invalid-opf", "OPF validation failed.", {
1530
- issues: result.errors,
1531
- result
1655
+ findings: report.findings.filter(finding => finding.severity === "error"),
1656
+ report
1532
1657
  });
1533
1658
  }
1534
1659
  }
1535
1660
 
1536
- function resolvePresentationContext(presentation, options) {
1661
+ // RR-55: what a slide (or, for the deck level, an empty slide) resolves to in core, the one chain every engine shares: canvas,
1662
+ // layout, font families, alignment and content box as composeSlide's options, plus the theme, colour scheme and font scheme
1663
+ // records they came from. `diagnostics` names references that matched no record.
1664
+ // Core's slide context with the host's registered catalogs. Under strict export core throws OPFUnresolvedReferenceError for a
1665
+ // reference that resolves nowhere; the export fails with `unresolved-reference` naming the first one (its path and every diagnostic).
1666
+ function coreContext(presentation, index, options) {
1667
+ try {
1668
+ return resolveCoreSlideContext(presentation, index, {fonts: options.textMeasurement ? {textMeasurement: options.textMeasurement} : undefined, date: options.date,
1669
+ ...(options.catalogs !== undefined ? {catalogs: options.catalogs} : {}), strictReferences: options.strictReferences === true});
1670
+ } catch (error) {
1671
+ if (error?.name !== "OPFUnresolvedReferenceError" || !Array.isArray(error.diagnostics)) throw error;
1672
+ const [first] = error.diagnostics;
1673
+ throw new OPFPptxError("unresolved-reference", `Strict export: ${error.message}`, {path: first?.path, diagnostics: error.diagnostics});
1674
+ }
1675
+ }
1676
+
1677
+ // The families composition and the package use: core's, with the measurement provider's own resolution of the chosen family
1678
+ // (FF-31) applied to the three roles the exporter writes.
1679
+ function exportFonts(core, textMeasurement) {
1680
+ const {fontFamilies} = core.options, scheme = core.resolved.fontScheme;
1681
+ const fonts = {id: scheme.id, ...fontFamilies, scheme: {type: scheme.type, major: scheme.major, minor: scheme.minor}};
1682
+ for (const role of ["heading", "body", "code"]) fonts[role] = resolveTextStyle({fontFamily: fonts[role], fontWeight: role === "heading" ? 700 : 400}, textMeasurement).fontFamily;
1683
+ return fonts;
1684
+ }
1685
+
1686
+ function resolvePresentationContext(presentation, options, core = coreContext({...presentation, slides: [{}]}, 0, {...options, textMeasurement: undefined})) {
1537
1687
  const design = presentation.design ?? {};
1538
- const theme = resolveDesignRecord(presentation, "themes", design.theme, DEFAULTS.theme);
1539
- const colorScheme = resolveDesignRecord(
1540
- presentation,
1541
- "colorSchemes",
1542
- design.colorScheme ?? theme?.colorScheme,
1543
- DEFAULTS.colorScheme
1544
- );
1545
- const fontScheme = resolveDesignRecord(
1546
- presentation,
1547
- "fontSchemes",
1548
- design.fontScheme ?? theme?.fontScheme,
1549
- DEFAULTS.fontScheme
1550
- );
1551
- // Shared rule (core resolveFontSchemeReference): an unresolved id falls back to the
1552
- // DEFAULTS.fontScheme record as the base; resolveSlideContext reports it once per path.
1553
- const fontSchemeId = referenceId(design.fontScheme ?? theme?.fontScheme);
1554
- const unresolvedFontScheme = fontSchemeId && !findById(normalizeRecords(presentation.catalogs?.fontSchemes), fontSchemeId) && !findById(defaultCatalog("fontSchemes"), fontSchemeId) ? fontSchemeId : null;
1555
- const dimensions = resolveDimensions(design.dimensions ?? theme?.dimensions);
1688
+ // `core` defaults to the deck level: the resolution of a slide that sets nothing of its own.
1689
+ // The theme and colour scheme records come from core, which falls back to the engine defaults (reporting unresolved-reference)
1690
+ // for a reference that resolves nowhere.
1691
+ const {theme, colorScheme} = core.resolved;
1692
+ const dimensions = {widthInches: core.options.width / 96, heightInches: core.options.height / 96};
1556
1693
  const variables = resolveVariableColors(presentation.variables);
1557
1694
  const background = resolveBackground(design.background ?? theme?.background, colorScheme, variables);
1558
- const fonts = resolveFonts(fontScheme);
1559
- for (const role of ["heading","body","code"]) fonts[role] = resolveTextStyle({fontFamily:fonts[role],fontWeight:role === "heading" ? 700 : 400},options.textMeasurement).fontFamily;
1560
- const textColor = readableTextColor(background, colorScheme);
1561
- const darkBackground = isDarkHex(background);
1695
+ const fonts = exportFonts(core, options.textMeasurement);
1696
+ // One resolution for every color role (core resolveColorRoles), shared with the SVG preview and the audit.
1697
+ const roles = resolveColorRoles(colorScheme, {background: `#${background}`});
1698
+ const textColor = normalizeHex(roles.text);
1562
1699
 
1563
1700
  return {
1564
1701
  seed: Number.isInteger(options.seed) ? options.seed : DEFAULT_SEED,
@@ -1568,16 +1705,19 @@ function resolvePresentationContext(presentation, options) {
1568
1705
  compressionLevel: Number.isInteger(options.compressionLevel) ? options.compressionLevel : 6,
1569
1706
  layoutName: "OPF_CANVAS",
1570
1707
  dimensions,
1708
+ theme,
1571
1709
  colorScheme,
1572
1710
  backgroundDefinition: design.background ?? theme?.background,
1573
1711
  fonts,
1574
- unresolvedFontScheme,
1712
+ // The color roles a ColorRef names (primary, secondary, accent, background, surface, text, textSecondary, hyperlink).
1713
+ roles,
1575
1714
  colors: {
1576
1715
  background,
1577
1716
  text: textColor,
1578
- mutedText: normalizeHex(colorScheme.textSecondary ?? (darkBackground ? colorScheme.light2 : colorScheme.dark2) ?? "#475569"),
1579
- accent: normalizeHex(colorScheme.primary ?? colorScheme.accent1 ?? "#2874A6"),
1580
- surface: normalizeHex(colorScheme.surface ?? (darkBackground ? colorScheme.dark2 : colorScheme.light2) ?? "#F8FAFC"),
1717
+ mutedText: normalizeHex(roles.textSecondary),
1718
+ // The primary color, which the exporter draws accent fills (table headers, tag, media badge) in. The `accent` ColorRef role is roles.accent.
1719
+ accent: normalizeHex(roles.primary),
1720
+ surface: normalizeHex(roles.surface),
1581
1721
  border: normalizeHex(colorScheme.accent5 ?? "#CBD5E1")
1582
1722
  },
1583
1723
  variables
@@ -1618,11 +1758,31 @@ function contrastRatio(first, second) {
1618
1758
  const a = relativeLuminance(first), b = relativeLuminance(second);
1619
1759
  return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
1620
1760
  }
1761
+ // The tag's color as a literal (the one tagColor writes, resolved).
1762
+ function tagHex(context) {
1763
+ return contrastRatio(context.colors.accent, context.colors.background) < TAG_MIN_CONTRAST ? context.colors.text : context.colors.accent;
1764
+ }
1765
+
1766
+ // The defaults a rich heading or quote body is exported with on every run (weight, color, size, family). The tag records them so that
1767
+ // import can tell the heading's own look from run formatting the author chose.
1768
+ function richBase(fit, style, color) {
1769
+ const font = nativeFontOptions(style);
1770
+ return {bold: font.bold === true, color: `#${normalizeHex(color)}`, fontSize: Math.round(fit.fontSize * .75 * 100) / 100, fontFamily: font.fontFace};
1771
+ }
1772
+
1621
1773
  function tagColor(context) {
1622
1774
  if (contrastRatio(context.colors.accent, context.colors.background) < TAG_MIN_CONTRAST) return context.textColor;
1623
1775
  return nativeColor('primary', context.colors.accent, context);
1624
1776
  }
1625
1777
 
1778
+ // A link run with no color of its own is written in the theme hyperlink color (a:schemeClr hlink where the deck theme
1779
+ // holds that exact color, else the literal), underlined by PowerPoint, as the preview draws it. A run color is kept.
1780
+ function linkColor(run, color, context) {
1781
+ const link = typeof run?.link === 'string' && /^(https?:|mailto:|tel:)/i.test(run.link);
1782
+ if (!link || !(run.color === undefined || run.color === null || run.color === '')) return color;
1783
+ return nativeColor('hyperlink', normalizeHex(context.roles.hyperlink), context);
1784
+ }
1785
+
1626
1786
  // FF-24c: two literals no document color uses stand in for hlink and folHlink
1627
1787
  // (see nativeColor). When the document uses every candidate, those colors stay literal.
1628
1788
  const LINK_SENTINELS = [['FE01A0', 'FE01A1'], ['FE02B0', 'FE02B1'], ['FE03C0', 'FE03C1']];
@@ -1658,20 +1818,28 @@ function masterBackground(context) {
1658
1818
  }
1659
1819
 
1660
1820
  // The theme part carries the deck-level scheme (slide overrides stay literal)
1661
- // and, for an explicit catalog theme, that theme's name.
1662
- function exportTheme(presentation, context) {
1821
+ // and, for a theme reference that resolves (embedded or in a registered catalog), that theme's name, which import
1822
+ // recovery matches against the registered catalogs (FF-24).
1823
+ function exportTheme(presentation, context, options) {
1663
1824
  const reference = presentation.design?.theme;
1664
- const id = referenceId(reference);
1665
- const theme = id ? resolveDesignRecord(presentation, "themes", reference, DEFAULTS.theme) : null;
1825
+ const theme = typeof reference === "string" ? resolveReference(presentation, "themes", reference, {catalogs: options.catalogs}) : undefined;
1666
1826
  const scheme = context.colorScheme;
1667
1827
  return {
1668
1828
  themeColors: themeSlotColors(scheme),
1669
1829
  schemeName: scheme.name ?? scheme.id ?? "OpenPresentation",
1670
- themeName: theme?.id === id ? theme.name ?? theme.id : undefined
1830
+ themeName: theme ? theme.record.name ?? theme.id : undefined
1671
1831
  };
1672
1832
  }
1673
1833
 
1674
- function importThemeDesign(entries, presentationRoot, presentationRels) {
1834
+ // FF-24 recovery against the host's registered catalogs (FA-23). Only the first registered catalog, the host default, can be
1835
+ // named by a bare id in a document that declares no catalogs, so only its records are matched: an exact match imports as
1836
+ // that bare id. Everything else (no catalog registered, a scheme from another registered catalog, no exact match) imports
1837
+ // as an inline colour scheme, and no theme id is recovered.
1838
+ function hostDefaultRecords(kind, catalogs) {
1839
+ return catalogRecords({}, kind, {catalogs: catalogs ?? []}).filter(entry => entry.group === "default").map(entry => ({...entry.record, id: entry.id}));
1840
+ }
1841
+
1842
+ function importThemeDesign(entries, presentationRoot, presentationRels, catalogs) {
1675
1843
  const diagnostics = [], design = {};
1676
1844
  const report = (code, path, message) => diagnostics.push({code, path, message});
1677
1845
  const themePath = presentationThemePath(presentationRoot, presentationRels, path => parseRelationships(entries, path), entries);
@@ -1683,16 +1851,22 @@ function importThemeDesign(entries, presentationRoot, presentationRels) {
1683
1851
  return {design, diagnostics};
1684
1852
  }
1685
1853
  const {colors, unreadable} = readThemeSlotColors(clrScheme);
1686
- const records = defaultCatalog("colorSchemes");
1854
+ const records = hostDefaultRecords("colorSchemes", catalogs);
1687
1855
  if (Object.keys(colors).length) design.colorScheme = recoverColorScheme({colors, unreadable, name: scalarText(clrScheme.name)}, records).value;
1688
1856
  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.`);
1689
1857
  const latin = font => scalarText(elements?.["a:fontScheme"]?.[font]?.["a:latin"]?.typeface);
1858
+ // A theme's own colour and font scheme references resolve in the theme's group first (core resolveReference).
1859
+ const themeReference = (kind, reference) => {
1860
+ const id = referenceId(reference);
1861
+ const found = id ? resolveReference({}, kind, id, {catalogs: catalogs ?? [], group: "default"}) : undefined;
1862
+ return found ? {...found.record, ...(isPlainObject(reference) ? reference : {})} : null;
1863
+ };
1690
1864
  const recoveredTheme = recoverTheme({themeName: scalarText(theme?.name), colors, majorFont: latin("a:majorFont"), minorFont: latin("a:minorFont")}, {
1691
- themes: defaultCatalog("themes"),
1692
- colorSchemes: records,
1693
- fontFamilies: id => {
1694
- const record = findById(defaultCatalog("fontSchemes"), id);
1695
- return record ? resolveFontFamilies(record) : null;
1865
+ themes: hostDefaultRecords("themes", catalogs),
1866
+ colorScheme: record => themeReference("colorSchemes", record.colorScheme),
1867
+ fontFamilies: record => {
1868
+ const scheme = themeReference("fontSchemes", record.fontScheme);
1869
+ return scheme ? resolveFontFamilies(scheme) : null;
1696
1870
  }
1697
1871
  });
1698
1872
  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.`);
@@ -1719,59 +1893,45 @@ function configurePresentation(pptx, presentation, context) {
1719
1893
 
1720
1894
  async function addSlide(pptx, presentation, opfSlide, slideIndex, context, options) {
1721
1895
  const slide = pptx.addSlide();
1722
- const slideContext = resolveSlideContext(presentation, opfSlide, context, options, slideIndex);
1896
+ const slideContext = exportSlideContext(presentation, slideIndex, context, options);
1897
+ const { widthInches, heightInches } = slideContext.dimensions;
1898
+ // A slide with no layout, or one whose layout resolves nowhere, is composed with no layout record (automatic composition),
1899
+ // as in the preview; core reports the reference as unresolved-reference.
1900
+ // Core's resolved options (canvas, layout, families, alignment, content box, darkBackground), with the exporter's own additions:
1901
+ // the families after the measurement provider's resolution of the chosen family and the raster padding. Core formats socials
1902
+ // handles from its own SOCIAL_PLATFORMS vocabulary.
1903
+ const composeOptions = { ...slideContext.core.options, fontFamilies: slideContext.composeFonts, textRasterPadding:options.textRasterPadding };
1904
+ // Core resolves every shared design key once (slide design, deck design, then the layout record's design): the text alignment of
1905
+ // each item (item.alignment), and each image item's own fit and treatment (item.image).
1906
+ const geometry = composeSlide(opfSlide, composeOptions);
1907
+ // The default footer band (date left, text center, number right): where a footer added natively lands (RR-11).
1908
+ if (slideIndex === 0) context.defaultFooterOptions = composeOptions;
1723
1909
  slide.background = { color: slideContext.colors.background };
1724
- const backgroundDefinition = slideContext.backgroundDefinition;
1910
+ // An image background is core's geometry.backgroundImage (slide, deck, then theme background); every other kind is the
1911
+ // effective design or theme background definition.
1912
+ const backgroundDefinition = geometry.backgroundImage ? undefined : slideContext.backgroundDefinition;
1725
1913
  const backgroundPath = `${opfSlide.design?.background !== undefined ? `slides.${slideIndex}.` : ''}design.background`;
1726
- const backgroundFill = schemeBackgroundFill(backgroundDefinition, slideContext) ?? nativeBackgroundFill(backgroundDefinition, {
1914
+ const backgroundFill = backgroundDefinition === undefined ? null : schemeBackgroundFill(backgroundDefinition, slideContext) ?? nativeBackgroundFill(backgroundDefinition, {
1727
1915
  width: slideContext.dimensions.widthInches, height: slideContext.dimensions.heightInches
1728
1916
  }, slideContext.colors.background, slideContext.colors.text, (reference, hex) => schemeColorValue(reference, hex, slideContext), reference => resolveBackgroundColorRef(reference, slideContext.colorScheme, slideContext.variables));
1729
1917
  if (backgroundFill) context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, backgroundFill);
1730
1918
  if (backgroundDefinition?.type === 'pattern' && !nativePatternPreset(backgroundDefinition.pattern?.preset)) {
1731
1919
  options.onDiagnostic?.({code: 'unsupported-pattern', path: `${backgroundPath}.pattern.preset`, message: `Pattern ${backgroundDefinition.pattern?.preset} has no DrawingML preset; only its background color was exported.`});
1732
1920
  }
1733
- if (backgroundDefinition?.type === 'image') {
1734
- const imagePath = `${backgroundPath}.image`;
1735
- // An SVG background is its PNG raster at the slide's size: a slide background picture fill carries no SVG.
1736
- const outcome = { box: { w: slideContext.dimensions.widthInches, h: slideContext.dimensions.heightInches }, cover: (backgroundDefinition.image?.fit ?? 'cover') !== 'contain' };
1737
- const resolved = await resolveImage(backgroundDefinition.image, presentation, options, imagePath, outcome);
1738
- if (resolved) {
1739
- // PptxGenJS embeds the raster and its relationship; packaging replaces
1740
- // its stretched fill with the fitted native picture fill.
1741
- slide.background = { ...resolved };
1742
- context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, { image: {
1743
- fit: backgroundDefinition.image?.fit ?? 'cover', opacity: backgroundDefinition.opacity ?? 1, imageFill: slideContext.imageFill, path: imagePath,
1744
- width: slideContext.dimensions.widthInches * 96, height: slideContext.dimensions.heightInches * 96, report: options.onDiagnostic
1745
- } });
1746
- } else if (!outcome.reported) {
1747
- 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.'});
1748
- }
1749
- }
1750
1921
  slide.color = slideContext.textColor;
1751
1922
  if (opfSlide.hidden === true) slide.hidden = true;
1752
1923
 
1753
- const { widthInches, heightInches } = slideContext.dimensions;
1754
- const layout = resolveCatalogRecord(presentation, "layouts", opfSlide.layout, "blank") ?? {};
1755
- 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` });
1756
- // design.titleAlignment/contentAlignment are Design properties (slide design
1757
- // overrides the deck). Core only returns an accepted `placement.alignment`
1758
- // with outline measurement, so the same effective values must also drive
1759
- // text without placement, exactly as the renderer's preview does.
1760
- const titleAlignment=opfSlide.design?.titleAlignment??presentation.design?.titleAlignment;
1761
- const contentAlignment=opfSlide.design?.contentAlignment??presentation.design?.contentAlignment;
1762
- const fieldAlignment=field=>field==='title'?titleAlignment:contentAlignment;
1763
- // Core composition resolves one alignment per composed item for every engine
1764
- // (FF-29); the design fallback keeps cores published before item.alignment working.
1765
- const alignmentFor=item=>item.alignment??fieldAlignment(item.field);
1766
- const composeOptions = { width: widthInches * 96, height: heightInches * 96, layout, presentation, slideIndex, fonts: slideContext.fonts, contentAlignment, titleAlignment, textRasterPadding:options.textRasterPadding, contentBox:opfSlide.design?.contentBox??presentation.design?.contentBox, darkBackground: isDarkHex(slideContext.colors.background), textMeasurement: options.textMeasurement, date: options.date, socialPlatforms: socialPlatformRecords(presentation, options) };
1767
- const geometry = composeSlide(opfSlide, composeOptions);
1768
- // The default footer band (date left, text center, number right): where a footer added natively lands (RR-11).
1769
- if (slideIndex === 0) context.defaultFooterOptions = composeOptions;
1770
1924
  for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
1771
1925
  // The content topology (groups, regions, root form, block ids) with the leaf boxes this geometry draws.
1772
1926
  recordContentTopology(context.documentProvenance, opfSlide, slideIndex, geometry.items);
1773
- // The preview paints background, slide image (and its overlay), then design.watermark, then content.
1774
- if (geometry.slideImage) await addSlideImage(slide, presentation, geometry.slideImage, slideIndex, slideContext, options);
1927
+ // Paint order, as in the preview (FA-22 contract): canvas colour, the background picture and its overlay, design.watermark,
1928
+ // the logo, then the items in core's order: the headings, the placed image blocks at their bands, then the flowed body.
1929
+ if (geometry.backgroundImage) await addBackgroundImage(slide, presentation, geometry.backgroundImage, slideIndex, slideContext, context, options);
1930
+ // RR-34: a captioned image's caption band follows its picture as tagged text boxes linked to the picture by name.
1931
+ const drawImage = async (item, itemContext) => {
1932
+ const name = await addImageItem(slide, presentation, item, slideIndex, itemContext, options);
1933
+ if (item.caption) addCaption(slide, item, name, itemContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: item.caption.path}));
1934
+ };
1775
1935
  await addWatermark(slide, presentation, opfSlide, slideIndex, slideContext, context, options);
1776
1936
  // Cover and section slides: the deck logo core composed at the top-left of the free area, after the watermark and before content.
1777
1937
  if (geometry.logo) await addLogo(slide, presentation, geometry.logo, slideIndex, slideContext, context, options);
@@ -1783,14 +1943,14 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1783
1943
  const frame=item.frameBox;
1784
1944
  context.cardTags.set(`OPF card ${item.path}`,item.path);
1785
1945
  const paint=value=>({color:normalizeHex(value),transparency:/^#[0-9a-f]{8}$/i.test(value)?(1-parseInt(value.slice(7),16)/255)*100:0});
1786
- const surface=itemContext.colorScheme[isDarkHex(itemContext.colors.background)?'dark2':'light2']??`#${itemContext.colors.surface}`;
1946
+ const surface=itemContext.colorScheme.surface??itemContext.colorScheme[itemContext.roles.dark?'dark2':'light2']??`#${itemContext.colors.surface}`;
1787
1947
  slide.addShape('roundRect',{x:frame.x/96,y:frame.y/96,w:frame.width/96,h:frame.height/96,
1788
1948
  rectRadius:8*Math.min(widthInches,heightInches)/720,
1789
1949
  fill:paint(surface),line:{...paint(itemContext.colorScheme.accent5??`#${itemContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
1790
1950
  }
1791
1951
  if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
1792
- addMeasuredPayloadText(slide,item.value,item.box,itemContext,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'?tagColor(itemContext):itemContext.textColor});
1793
- } else if (["title", "subtitle", "tag"].includes(item.field)) {
1952
+ addMeasuredPayloadText(slide,item.value,item.box,itemContext,options,{path:item.path,fit:item.text,textStyle:item.textStyle,sourceText:item.field==='text'&&!!item.text.sourceLines,align:item.alignment,diagnosticsHandled:true,heading:['title','subtitle','tag'].includes(item.field)?item.field:undefined,color:item.field==='tag'?tagColor(itemContext):itemContext.textColor});
1953
+ } else if (["title", "subtitle", "tag"].includes(item.field) && !item.text?.richLines) {
1794
1954
  // Estimated-font headings use one native text box, which still needs a
1795
1955
  // role tag. Role recovery must not depend on outline measurement support.
1796
1956
  const objectName = `OPF heading ${item.path} line 0`;
@@ -1799,36 +1959,34 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1799
1959
  ...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),
1800
1960
  ...nativeFontOptions(item.textStyle),
1801
1961
  color: item.field === "tag" ? tagColor(itemContext) : itemContext.textColor,
1802
- align: physicalAlignment(alignmentFor(item), item.text.directions?.[0]),
1962
+ align: physicalAlignment(item.alignment, item.text.directions?.[0]),
1803
1963
  ...(item.text.directions?.[0] === 'rtl' ? {rtlMode: true} : {}),
1804
1964
  objectName,
1805
1965
  breakLine: false
1806
1966
  });
1807
1967
  } else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
1808
1968
  await addMeasuredList(slide,item.text,itemContext,item.path,item.bulletImage,presentation,slideIndex,options,item.payload?.numbering!==undefined);
1809
- } else if (item.field === "text" && item.text?.richLines) {
1810
- const logicalAlignment=item.text.placement?.alignment??alignmentFor(item)??'left';
1811
- for(const [index,line] of item.text.richLines.entries()){
1812
- const runs=line.fragments.map(fragment=>{
1813
- const runColor=exportColor(fragment.run.color,itemContext,itemContext.colors.text);
1814
- const color=nativeColor(fragment.run.color,runColor,itemContext,itemContext.textColor);
1815
- return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:(fragment.nominalSize??fragment.fontSize)*.75,color,underline:fragment.run.underline?{style:'sng',color}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/(fragment.nominalSize??fragment.fontSize)*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}};
1816
- });
1817
- // Logical alignment (RR-05): a right-to-left paragraph starts at the right edge; every wrapped line shares its paragraph's direction.
1818
- const placed=item.text.placement?.lines[index],alignment=placed?.alignment??physicalAlignment(logicalAlignment,item.text.directions?.[index]),factor=alignment==='right'?1:alignment==='center'?.5:0;
1819
- 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};
1820
- // PptxGenJS reads the paragraph direction from the first run's options, not from the shape options.
1821
- if(item.text.directions?.[index]==='rtl')for(const run of runs)run.options.rtlMode=true;
1822
- if(runs.length)slide.addText(runs,{...textBoxOptions(area,itemContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
1823
- }
1969
+ } else if (["text", "title", "subtitle", "tag"].includes(item.field) && item.text?.richLines) {
1970
+ // FA-10: a TextRun[] heading draws and exports like rich body text; its lines are named and tagged as a heading (the body's are not).
1971
+ const heading = item.field !== 'text';
1972
+ addRichFitLines(slide, item.text, item.box, itemContext, {
1973
+ value: item.value, alignment: item.text.placement?.alignment ?? item.alignment ?? 'left',
1974
+ fallback: item.field === 'tag' ? tagColor(itemContext) : itemContext.textColor,
1975
+ ...(heading ? {
1976
+ objectName: index => `OPF heading ${item.path} line ${index}`,
1977
+ tag: (name, index, count, boundary) => context.headingTags.set(name, {v: 1, group: item.path, field: item.field, line: index, count, ...boundary, base: richBase(item.text, item.textStyle, item.field === 'tag' ? tagHex(itemContext) : itemContext.colors.text)})
1978
+ } : {})
1979
+ });
1824
1980
  } else if (item.field === "text" && typeof item.value === "string") {
1825
- slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:physicalAlignment(alignmentFor(item), item.text.directions?.[0])});
1981
+ slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:physicalAlignment(item.alignment, item.text.directions?.[0])});
1982
+ } else if (item.field === "image" && item.image) {
1983
+ await drawImage(item, itemContext);
1826
1984
  } else {
1827
1985
  // RR-34: a captioned item draws its media in item.box; the caption band follows as tagged text boxes linked to the media shape by name.
1828
- const mediaNames = item.caption ? new Set([...context.imagePlacements.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()]) : undefined;
1829
- await addPayload(slide, presentation, item.payload, region, item.path, { ...itemContext, composition: item.composition, contentAlignment: alignmentFor(item) ?? "left", direction: geometry.direction }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
1986
+ const mediaNames = item.caption ? new Set([...context.images.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()]) : undefined;
1987
+ await addPayload(slide, presentation, item.payload, region, item.path, { ...itemContext, composition: item.composition, contentAlignment: item.alignment ?? "left", direction: geometry.direction }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
1830
1988
  if (item.caption) {
1831
- const mediaName = item.field === 'video' ? `OPF media ${item.path} frame` : [...context.imagePlacements.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()].find(name => !mediaNames.has(name));
1989
+ const mediaName = item.field === 'video' ? `OPF media ${item.path} frame` : [...context.images.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()].find(name => !mediaNames.has(name));
1832
1990
  addCaption(slide, item, mediaName, itemContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: item.caption.path}));
1833
1991
  }
1834
1992
  }
@@ -1847,24 +2005,24 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1847
2005
  }
1848
2006
  }
1849
2007
 
1850
- function resolveSlideContext(presentation, slide, baseContext, options, slideIndex = 0) {
2008
+ // One slide's export context: core's resolveSlideContext (`core`: composeSlide's options, the records and the diagnostics) and
2009
+ // the exporter's colours and measurement-resolved families derived from it.
2010
+ function exportSlideContext(presentation, slideIndex, baseContext, options) {
2011
+ const slide = presentation.slides[slideIndex];
1851
2012
  const effective = { ...presentation, design: { ...presentation.design, ...slide.design } };
1852
- const resolved = resolvePresentationContext(effective, options);
1853
- if (resolved.unresolvedFontScheme) {
1854
- const path = slide.design?.fontScheme !== undefined ? `slides.${slideIndex}.design.fontScheme`
1855
- : presentation.design?.fontScheme !== undefined ? "design.fontScheme"
1856
- : slide.design?.theme !== undefined ? `slides.${slideIndex}.design.theme` : "design.theme";
1857
- if (!baseContext.reportedFontSchemes?.has(path)) {
1858
- baseContext.reportedFontSchemes?.add(path);
1859
- const id = resolved.unresolvedFontScheme;
1860
- 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}'.` });
1861
- }
2013
+ const core = coreContext(presentation, slideIndex, options);
2014
+ const resolved = resolvePresentationContext(effective, options, core);
2015
+ // A reference that matched no record is reported once per place it is written.
2016
+ for (const diagnostic of core.diagnostics) {
2017
+ if (baseContext.reportedReferences?.has(diagnostic.path)) continue;
2018
+ baseContext.reportedReferences?.add(diagnostic.path);
2019
+ options.onDiagnostic?.(diagnostic);
1862
2020
  }
1863
2021
  if (Math.abs(resolved.dimensions.widthInches - baseContext.dimensions.widthInches) > 1e-6
1864
2022
  || Math.abs(resolved.dimensions.heightInches - baseContext.dimensions.heightInches) > 1e-6) {
1865
2023
  throw new OPFPptxError("mixed-slide-dimensions", "PowerPoint requires one canvas size per presentation. Set dimensions on the deck or export this slide separately.");
1866
2024
  }
1867
- const slideContext = { ...baseContext, backgroundDefinition: resolved.backgroundDefinition, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors, variables: resolved.variables, imageFill: effective.design.imageFill ?? "fit" };
2025
+ const slideContext = { ...baseContext, core, composeFonts: {...core.options.fontFamilies, heading: resolved.fonts.heading, body: resolved.fonts.body, code: resolved.fonts.code}, backgroundDefinition: resolved.backgroundDefinition, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors, roles: resolved.roles, variables: resolved.variables };
1868
2026
  // A slide-level color scheme (directly or through a slide theme) pins every
1869
2027
  // named color on that slide to literal sRGB; see schemeColorValue().
1870
2028
  const slideScheme = themeSlotColors(resolved.colorScheme), deckScheme = baseContext.themeColors ?? {};
@@ -1909,7 +2067,7 @@ async function addPayload(slide, presentation, payload, region, path, context, o
1909
2067
  addMetricPayload(slide, payload.metric,metricLayout,context,path,options);
1910
2068
  break;
1911
2069
  case "quote":
1912
- addQuotePayload(slide, payload.quote, quoteLayout, context, options, path);
2070
+ await addQuotePayload(slide, presentation, payload.quote, quoteLayout, context, options, path);
1913
2071
  break;
1914
2072
  case "timeline":
1915
2073
  addTimelinePayload(slide, payload.timeline, timelineLayout, context, options, path);
@@ -1946,7 +2104,7 @@ function richLineRuns(line,color,native,context) {
1946
2104
  const fallback=color.replace(/^#/,'');
1947
2105
  return line.fragments.map(fragment=>{
1948
2106
  const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context,native);
1949
- return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:(fragment.nominalSize??fragment.fontSize)*.75,color,underline:fragment.run.underline?{style:'sng',color}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/(fragment.nominalSize??fragment.fontSize)*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}};
2107
+ return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:(fragment.nominalSize??fragment.fontSize)*.75,color:linkColor(fragment.run,color,context),underline:fragment.run.underline?{style:'sng',color:linkColor(fragment.run,color,context)}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/(fragment.nominalSize??fragment.fontSize)*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:|tel:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined,...langOptions(context,fragment.style.lang)}};
1950
2108
  });
1951
2109
  }
1952
2110
  // Every native line of a list, marker paragraph or not, is named for the list's
@@ -2044,27 +2202,131 @@ function addListPayload(slide, items, region, context) {
2044
2202
  slide.addText(runs, textBoxOptions(region, context, 15));
2045
2203
  }
2046
2204
 
2205
+ // A content picture outside an image block (a timeline event image): one native picture contained in its region.
2047
2206
  async function addImagePayload(slide, presentation, asset, region, path, context, options) {
2048
- const outcome = { box: region, cover: context.imageFill === 'crop' };
2207
+ const name = await addImagePicture(slide, presentation, asset, region, path, context, options, {fit: 'contain'});
2208
+ if (!name) addImagePlaceholder(slide, presentation, asset, region, path, context, options);
2209
+ return name;
2210
+ }
2211
+
2212
+ // One native image picture at `box` (inches), fitted at packaging (src/image-provenance.js placeImages). `treatment` is
2213
+ // what OPF_IMAGE_V1 stores (the authored block fields); without it the picture is untagged. Returns the picture's object
2214
+ // name, or undefined when the source could not be embedded (the caller draws the unavailable-image panel).
2215
+ async function addImagePicture(slide, presentation, asset, box, path, context, options, {fit, focus, effects = null, shrink = true, treatment, alt, role = 'block', objectName} = {}) {
2216
+ const outcome = { box, cover: fit === 'cover' };
2049
2217
  const resolved = await resolveImage(asset, presentation, options, path, outcome);
2050
- if (!resolved) {
2051
- addImagePlaceholder(slide, presentation, asset, region, path, context, options);
2052
- return;
2218
+ if (!resolved) return undefined;
2219
+ const name = objectName ?? imageName(1 + [...context.images.keys()].filter(key => /^OPF image \d+$/.test(key)).length);
2220
+ // PowerPoint applies opacity (a:alphaModFix), grayscale (a:grayscl) and the border (a:ln) to an SVG picture (native check
2221
+ // 2026-10-01). A duotone recolor and a non-rectangular mask are not confirmed on an SVG picture, and a tiled picture fill
2222
+ // cannot carry an SVG: with any of them the SVG stays its PNG raster, so the effect applies as in the preview.
2223
+ if (outcome.svg) {
2224
+ const treated = [effects?.recolor?.type === 'duotone' && 'duotone recolor', effects?.shape && effects.shape.preset !== 'rect' && 'shape', fit === 'tile' && 'tile fit'].filter(Boolean);
2225
+ if (treated.length) options.onDiagnostic?.({ code: 'svg-image-rasterized', path, message: `An SVG image with ${treated.join(', ')} exports as its PNG raster, so the effect applies as in the preview; PowerPoint has not been confirmed to apply it to an SVG picture.` });
2226
+ else context.svgPictures.set(name, outcome.svg);
2053
2227
  }
2054
- const objectName = `OPF image ${context.imagePlacements.size + 1}`;
2055
- context.imagePlacements.set(objectName, { region, mode: context.imageFill, path });
2056
- if (outcome.svg) context.svgPictures.set(objectName, outcome.svg);
2057
- context.pictureText.set(objectName, pictureText(assetAlt(asset, presentation), asset, presentation));
2058
- slide.addImage({
2059
- ...resolved,
2060
- objectName,
2061
- x: region.x,
2062
- y: region.y,
2063
- w: region.w,
2064
- h: region.h,
2065
- altText: assetAlt(asset, presentation)
2228
+ const text = alt !== undefined ? alt : assetAlt(asset, presentation);
2229
+ context.images.set(name, { slide: context.slidePath, role, path, box, fit, focus, shrink, effects, ...(treatment !== undefined ? {treatment} : {}) });
2230
+ context.pictureText.set(name, pictureText(text, asset, presentation));
2231
+ slide.addImage({ ...resolved, objectName: name, x: box.x, y: box.y, w: box.w, h: box.h, altText: text });
2232
+ return name;
2233
+ }
2234
+
2235
+ // The native effects of an image treatment, resolved in the slide's colour context: core's mask (item.image.shape), the
2236
+ // border line, opacity and recolor.
2237
+ function imageEffects(image, context) {
2238
+ const paint = (entry, fallback) => {
2239
+ const raw = exportColor(entry, context, fallback).replace(/^#/, '');
2240
+ return { hex: normalizeHex(raw, fallback), alpha: /^[0-9a-f]{8}$/i.test(raw) ? parseInt(raw.slice(6), 16) / 255 : 1 };
2241
+ };
2242
+ return {
2243
+ shape: image.shape ?? null,
2244
+ border: image.border ? { ...paint(image.border.color, context.colors.border), width: image.border.width } : null,
2245
+ opacity: image.opacity ?? null,
2246
+ recolor: image.recolor?.type === 'grayscale' ? { type: 'grayscale' }
2247
+ : image.recolor?.type === 'duotone' ? { type: 'duotone', dark: paint(image.recolor.dark, '000000'), light: paint(image.recolor.light, 'FFFFFF') } : null,
2248
+ paint
2249
+ };
2250
+ }
2251
+
2252
+ // An overlay (core ComposedOverlay) as one native shape directly above its picture: the frame's shape, or an edge band.
2253
+ function addImageOverlay(slide, overlay, authored, name, owner, context, effects) {
2254
+ const {hex, alpha} = effects.paint(overlay.color, context.colors.text);
2255
+ context.imageOverlays.set(name, { slide: context.slidePath, owner, box: overlay.box, shape: overlay.shape, hex, alpha, opacity: overlay.opacity, overlay: authored });
2256
+ slide.addShape('rect', { x: overlay.box.x / 96, y: overlay.box.y / 96, w: overlay.box.width / 96, h: overlay.box.height / 96, objectName: name, fill: { color: hex } });
2257
+ }
2258
+
2259
+ // The authored value at an OPF path the composition names (`slides.N.blocks.I`, `slides.N.design.background`,
2260
+ // `design.background`); `theme` is the resolved theme record's background.
2261
+ function authoredAt(presentation, path, slideContext) {
2262
+ if (path === 'theme') return slideContext.core?.resolved?.theme?.background;
2263
+ let value = presentation;
2264
+ for (const key of path.split('.')) value = value?.[/^\d+$/.test(key) ? Number(key) : key];
2265
+ return value;
2266
+ }
2267
+
2268
+ // An image block (core ComposedItem.image): the picture at core's frame with its fit, focus and treatment, then its
2269
+ // overlay directly above. An unchanged picture and overlay import back as the same block (OPF_IMAGE_V1).
2270
+ async function addImageItem(slide, presentation, item, slideIndex, context, options) {
2271
+ const image = item.image;
2272
+ const box = { x: image.box.x / 96, y: image.box.y / 96, w: image.box.width / 96, h: image.box.height / 96 };
2273
+ const block = isPlainObject(item.payload) ? item.payload : {};
2274
+ const treatment = Object.fromEntries(IMAGE_TREATMENT_KEYS.filter(key => key !== 'overlay' && block[key] !== undefined).map(key => [key, structuredClone(block[key])]));
2275
+ const effects = imageEffects(image, context);
2276
+ const treated = Boolean(effects.border || effects.opacity !== null || effects.recolor || image.overlay || (effects.shape && effects.shape.preset !== 'rect'));
2277
+ const tagged = Object.keys(treatment).length > 0 || block.overlay !== undefined;
2278
+ // A plain contained picture in the flow is framed by the picture itself (0.14 content images); a treated or placed one
2279
+ // keeps core's frame (0.14 slide images), so its mask, line and overlay apply to that frame.
2280
+ const name = await addImagePicture(slide, presentation, item.value, box, item.path, { ...context, slidePath: `slides.${slideIndex}` }, options, {
2281
+ fit: image.fit, focus: image.focus, effects: treated ? effects : null, shrink: !treated && !image.placement, treatment: tagged ? treatment : undefined
2066
2282
  });
2067
- return objectName;
2283
+ if (!name) {
2284
+ addImagePlaceholder(slide, presentation, item.value, box, item.path, context, options);
2285
+ return `OPF image placeholder ${context.imagePlaceholders.size}`;
2286
+ }
2287
+ if (image.overlay) addImageOverlay(slide, image.overlay, structuredClone(block.overlay), imageOverlayName(name), name, { ...context, slidePath: `slides.${slideIndex}` }, effects);
2288
+ return name;
2289
+ }
2290
+
2291
+ // An image background (core SlideComposition.backgroundImage). With alt text it is a full-slide picture at the back of
2292
+ // the slide over the canvas colour, carrying the alt text as its descr; without alt it is the native slide background
2293
+ // fill (written at packaging). Its overlay is one shape directly above the picture.
2294
+ async function addBackgroundImage(slide, presentation, background, slideIndex, slideContext, context, options) {
2295
+ const slidePath = `slides.${slideIndex}`, part = `ppt/slides/slide${slideIndex + 1}.xml`;
2296
+ const box = { x: 0, y: 0, w: slideContext.dimensions.widthInches, h: slideContext.dimensions.heightInches };
2297
+ const authored = authoredAt(presentation, background.path, slideContext);
2298
+ const effects = imageEffects({ opacity: background.opacity, recolor: background.recolor }, slideContext);
2299
+ let owner = 'background', overlayName = backgroundOverlayName(slidePath);
2300
+ // Only the background's own alt text makes it meaningful (FA-22): a referenced asset's alt does not count.
2301
+ const alt = background.alt;
2302
+ if (typeof alt === 'string' && alt) {
2303
+ const treatment = isPlainObject(authored) ? Object.fromEntries(BACKGROUND_IMAGE_KEYS.filter(key => key !== 'overlay' && authored[key] !== undefined).map(key => [key, structuredClone(authored[key])])) : {};
2304
+ const name = await addImagePicture(slide, presentation, background.src, box, background.path, { ...slideContext, slidePath }, options, {
2305
+ fit: background.fit, focus: background.focus, effects: background.opacity === undefined && !background.recolor ? null : effects, shrink: false,
2306
+ treatment, alt, role: 'background', objectName: backgroundImageName(slidePath)
2307
+ });
2308
+ if (!name) {
2309
+ options.onDiagnostic?.({code: 'unresolved-asset', path: background.path, message: 'The background image needs an embedded raster, a declared asset or a host imageResolver; the slide background color was exported instead.'});
2310
+ return;
2311
+ }
2312
+ owner = name;
2313
+ overlayName = imageOverlayName(name);
2314
+ } else {
2315
+ // An SVG background is its PNG raster at the slide's size: a slide background picture fill carries no SVG.
2316
+ const outcome = { box, cover: background.fit === 'cover' };
2317
+ const resolved = await resolveImage(background.src, presentation, options, background.path, outcome);
2318
+ if (!resolved) {
2319
+ if (!outcome.reported) options.onDiagnostic?.({code: 'unresolved-asset', path: background.path, message: 'The background image needs an embedded raster, a declared asset or a host imageResolver; the slide background color was exported instead.'});
2320
+ return;
2321
+ }
2322
+ // PptxGenJS embeds the raster and its relationship; packaging replaces its stretched fill with the fitted native fill.
2323
+ slide.background = { ...resolved };
2324
+ context.backgroundFills.set(part, { image: {
2325
+ fit: background.fit, focus: background.focus, opacity: background.opacity ?? 1, recolor: effects.recolor, path: background.path,
2326
+ width: box.w * 96, height: box.h * 96, report: options.onDiagnostic
2327
+ } });
2328
+ }
2329
+ if (background.overlay) addImageOverlay(slide, background.overlay, structuredClone(isPlainObject(authored) ? authored.overlay : undefined) ?? {color: background.overlay.color, opacity: background.overlay.opacity}, overlayName, owner, { ...slideContext, slidePath }, effects);
2068
2330
  }
2069
2331
 
2070
2332
  // design.watermark: one native picture per slide, added after the slide image
@@ -2076,13 +2338,14 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
2076
2338
  const watermark = local ? opfSlide.design.watermark : presentation.design?.watermark;
2077
2339
  if (watermark === undefined || watermark === null || watermark === false) return;
2078
2340
  const path = local ? `slides.${slideIndex}.design.watermark` : 'design.watermark';
2079
- const notExported = message => options.onDiagnostic?.({code: 'watermark-not-exported', path, message});
2080
- const asset = isPlainObject(watermark) || typeof watermark === 'string' ? watermark : null;
2081
- if (asset === null || (isPlainObject(asset) && typeof asset.src !== 'string')) {
2082
- notExported('The watermark needs an image source (a string, or an object with src); no watermark was exported for this slide.');
2341
+ // The schema requires exactly one of src and text, so there is no source-less watermark to report.
2342
+ const asset = watermark;
2343
+ const {widthInches, heightInches} = slideContext.dimensions;
2344
+ // FA-13: a text watermark is one native text box with the opacity as text alpha, centered and rotated as core's layoutWatermark says.
2345
+ if (isPlainObject(asset) && typeof asset.text === 'string') {
2346
+ addTextWatermark(slide, asset, slideIndex, {width: widthInches * 96, height: heightInches * 96}, slideContext, context, options, path);
2083
2347
  return;
2084
2348
  }
2085
- const {widthInches, heightInches} = slideContext.dimensions;
2086
2349
  const box = watermarkBox(widthInches, heightInches);
2087
2350
  const outcome = {box};
2088
2351
  const resolved = await resolveImage(asset, presentation, options, path, outcome);
@@ -2097,6 +2360,23 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
2097
2360
  slide.addImage({...resolved, objectName: watermarkName(), ...box, altText: assetAlt(asset, presentation) ?? 'Watermark'});
2098
2361
  }
2099
2362
 
2363
+ // The stamp takes the slide's own resolved fonts and default text color (readable on that slide's background), as the preview does.
2364
+ function addTextWatermark(slide, watermark, slideIndex, size, slideContext, context, options, path) {
2365
+ const layout = layoutWatermark(watermark.text, size, {fontFamily: slideContext.fonts.heading, fontWeight: 700, textMeasurement: options.textMeasurement});
2366
+ // The schema requires text, so this is null only for text that draws nothing (the preview draws nothing either).
2367
+ if (!layout) return;
2368
+ const opacity = watermarkOpacity(watermark);
2369
+ const {box} = layout;
2370
+ slide.addText(layout.text, {
2371
+ ...textBoxOptions({x: box.x / 96, y: box.y / 96, w: box.width / 96, h: box.height / 96}, slideContext, layout.fontSize * .75),
2372
+ ...nativeFontOptions(layout.style), bold: true,
2373
+ color: slideContext.textColor, transparency: Math.round((1 - opacity) * 100000) / 1000,
2374
+ align: 'center', valign: 'middle', fit: 'none', wrap: false, lineSpacingMultiple: 1,
2375
+ rotate: (layout.rotation + 360) % 360, objectName: watermarkTextName()
2376
+ });
2377
+ context.textWatermarks.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, text: layout.text, opacity});
2378
+ }
2379
+
2100
2380
  // The deck logo (design.logo, a slide's own logo or the primary organization's) on a cover or section slide: one native
2101
2381
  // picture per slide at core's geometry.logo box, fitted without cropping, anchored left and vertically centered, after
2102
2382
  // the watermark and before content (the preview's paint order). The frame and the provenance tag are written after
@@ -2116,54 +2396,6 @@ async function addLogo(slide, presentation, logo, slideIndex, slideContext, cont
2116
2396
  slide.addImage({...resolved, objectName: logoName(), ...region, altText: assetAlt(logo.source, presentation) ?? 'Logo'});
2117
2397
  }
2118
2398
 
2119
- // design.slideImage: one native picture at the shared frame, beneath content.
2120
- // Crop/fit and treatments are written after PptxGenJS embeds the bytes.
2121
- async function addSlideImage(slide, presentation, image, slideIndex, context, options) {
2122
- const box = { x: image.box.x / 96, y: image.box.y / 96, w: image.box.width / 96, h: image.box.height / 96 };
2123
- const outcome = { box, cover: image.fill === 'crop' };
2124
- const resolved = await resolveImage(image.value, presentation, options, image.sourcePath, outcome);
2125
- if (!resolved) {
2126
- addImagePlaceholder(slide, presentation, image.value, box, image.sourcePath, context, options);
2127
- return;
2128
- }
2129
- const objectName = slideImageName(`slides.${slideIndex}`);
2130
- const configured = image.path === 'design.slideImage' ? presentation.design?.slideImage : presentation.slides[slideIndex].design?.slideImage;
2131
- const { src: _source, ...treatment } = isPlainObject(configured) && 'position' in configured ? configured : {};
2132
- const paint = (entry, fallback) => {
2133
- const hex = normalizeHex(exportColor(entry, context, fallback), fallback);
2134
- const raw = exportColor(entry, context, fallback).replace(/^#/, '');
2135
- return { hex, alpha: /^[0-9a-f]{8}$/i.test(raw) ? parseInt(raw.slice(6), 16) / 255 : 1 };
2136
- };
2137
- // Native effects are resolved here, in the slide's color context.
2138
- const effects = {
2139
- shape: image.shape ?? null,
2140
- border: image.border ? { ...paint(image.border.color, context.colors.border), width: image.border.width } : null,
2141
- opacity: image.opacity ?? null,
2142
- recolor: image.recolor?.type === 'grayscale' ? { type: 'grayscale' }
2143
- : image.recolor?.type === 'duotone' ? { type: 'duotone', dark: paint(image.recolor.dark, '000000'), light: paint(image.recolor.light, 'FFFFFF') } : null,
2144
- overlay: image.overlay ? { ...paint(image.overlay.color, context.colors.text), opacity: image.overlay.opacity, box: image.overlay.box, shape: image.overlay.shape } : null,
2145
- };
2146
- // FF-53: a root `image` with the slide image's source is the slide image (core `replacesContent`); the
2147
- // manifest records that so an unchanged picture imports back as both design.slideImage and slide.image.
2148
- context.slideImages.set(objectName, { slide: `slides.${slideIndex}`, box, fill: image.fill, path: image.sourcePath, treatment: { ...treatment, position: image.position }, effects, content: image.replacesContent === true });
2149
- // PowerPoint applies opacity (a:alphaModFix), grayscale (a:grayscl) and the border (a:ln) to an SVG picture (native check
2150
- // 2026-10-01). A duotone recolor and a non-rectangular mask are not confirmed on an SVG picture: with either the SVG stays its
2151
- // PNG raster, so the effect applies as in the preview.
2152
- if (outcome.svg) {
2153
- const treated = [effects.recolor?.type === 'duotone' && 'duotone recolor', effects.shape && effects.shape.preset !== 'rect' && 'shape'].filter(Boolean);
2154
- if (treated.length) options.onDiagnostic?.({ code: 'svg-image-rasterized', path: image.sourcePath, message: `An SVG slide image with ${treated.join(', ')} exports as its PNG raster, so the effect applies as in the preview; PowerPoint has not been confirmed to apply it to an SVG picture.` });
2155
- else context.svgPictures.set(objectName, outcome.svg);
2156
- }
2157
- const slideImageAlt = image.alt ?? assetAlt(image.value, presentation);
2158
- context.pictureText.set(objectName, pictureText(slideImageAlt, image.value, presentation));
2159
- slide.addImage({ ...resolved, objectName, ...box, altText: slideImageAlt });
2160
- // The overlay scrim is a separate native shape directly above the picture.
2161
- if (effects.overlay) {
2162
- const overlay = effects.overlay.box;
2163
- 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 } });
2164
- }
2165
- }
2166
-
2167
2399
  // FF-62: chart text is one size. The preview draws every chart label (axis, legend, data label) at
2168
2400
  // max(14, composition.minFontSize ?? 16) px scaled by the slide's shorter side over 720 px (renderer charts.js, `fontPx`),
2169
2401
  // the readability floor: 12 pt on a 13.33 x 7.5 in slide. The export writes that size, in hundredths of a point (DrawingML `sz`).
@@ -2202,7 +2434,7 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2202
2434
 
2203
2435
  // Keep the resolved palette surface (including alpha) explicit in native
2204
2436
  // chart/plot areas, so inherited labels are assessed against their own panel.
2205
- const panelFill = context.colorScheme.surface ?? context.colorScheme[isDarkHex(context.colors.background) ? 'dark2' : 'light2'] ?? `#${context.colors.surface}`;
2437
+ const panelFill = context.colorScheme.surface ?? context.colorScheme[context.roles.dark ? 'dark2' : 'light2'] ?? `#${context.colors.surface}`;
2206
2438
  const labelColor = normalizeHex(textColorForFill(panelFill, `#${context.colors.text}`));
2207
2439
  const transparency = /^#[0-9a-f]{8}$/i.test(panelFill) ? (1 - parseInt(panelFill.slice(7), 16) / 255) * 100 : 0;
2208
2440
  const fill = {color:normalizeHex(panelFill),transparency};
@@ -2211,15 +2443,25 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2211
2443
  const textSize = chartTextSize(context);
2212
2444
  // RR-54: the columns' number formats (Excel codes per exported series) for the caches, labels, value axis and workbook.
2213
2445
  // The value axis shows the first plotted series' format (General when it has none), as the preview does.
2446
+ // A combo chart's secondary value axis shows its first series' format (FA-15).
2447
+ const secondaryIndex = chartData.combo?.findIndex((entry) => entry.axis === 'secondary') ?? -1;
2214
2448
  const numberFormats = chartData.formats && {...chartData.formats, axis: chartData.spec.grouping === 'percentStacked' ? undefined : chartData.formats.series[0],
2449
+ ...(secondaryIndex >= 0 ? {secondaryAxis: chartData.formats.series[secondaryIndex]} : {}),
2215
2450
  labels: !chartOptions?.dataLabels?.content?.includes('percent'), scatter: chartData.type === 'scatter'};
2216
2451
  context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading,labelColor,spec:chartData.spec,textSize,
2217
2452
  options:chartOptions,kind:chartTargetFor(chart.type)?.kind,pointCount:chartData.pointCount,numberFormats});
2218
2453
  recordPayloadData(context, objectName, 'chart', presentation, path);
2219
2454
  context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
2455
+ // FA-09: the chart's text alternative (the frame's descr; the empty string is PowerPoint's decorative marker), applied before the chartex frame copies the head.
2456
+ if (typeof chart.alt === 'string') context.chartAlts.set(objectName, chart.alt);
2220
2457
  const preferredPalette = CHART_COLORS.map(color=>`#${color}`);
2221
- const palette = (typeof opfComposition.chartPaletteForFill === "function" ? opfComposition.chartPaletteForFill(panelFill, preferredPalette) : preferredPalette.map(color=>chartColorForFill(panelFill,color))).map(color=>normalizeHex(color));
2222
- context.chartPalettes.set(objectName,palette);
2458
+ const palette = chartPaletteForFill(panelFill, preferredPalette).map(color=>normalizeHex(color));
2459
+ // FA-14: chart.highlight. A highlighted chart writes accent and muted colours over the series palette, and its data labels
2460
+ // contrast with those colours (src/chart-highlight.js).
2461
+ const highlight = chartData.chartex ? undefined : chartHighlightPlan({options: chartOptions, data: chartData.resolved, kind: chartTargetFor(chart.type)?.kind, panelFill, labelColor, primary: `#${context.colors.accent}`,
2462
+ schemeFor: hex => schemeColorValue('primary', hex, context)});
2463
+ context.chartPalettes.set(objectName,highlight ? highlight.seriesFills : palette);
2464
+ if (highlight) context.chartHeadings.get(objectName).highlight = highlight;
2223
2465
  if (chartData.chartex) {
2224
2466
  // The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
2225
2467
  // PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
@@ -2235,7 +2477,7 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2235
2477
  // bar chart with one series whenever chartColors is a custom array of more than one colour, so PowerPoint drew each
2236
2478
  // column of a single-series column or bar chart in a different colour. One colour for that series writes no c:dPt.
2237
2479
  const chartColors = chartData.type === 'bar' && chartData.series.length === 1 ? palette.slice(0, 1) : palette;
2238
- slide.addChart(chartData.type, chartData.series, {
2480
+ const chartOptionsXml = {
2239
2481
  objectName,
2240
2482
  x: region.x,
2241
2483
  y: region.y,
@@ -2271,7 +2513,37 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2271
2513
  ...(chartData.spec.radarStyle ? { radarStyle: chartData.spec.radarStyle } : {}),
2272
2514
  // ScatterWithMarkers: markers only, no connecting line.
2273
2515
  ...(chartData.type === 'scatter' ? { lineSize: 0, lineDataSymbol: 'circle' } : {})
2274
- });
2516
+ };
2517
+ if (chartData.combo?.length && chartData.combo.length === chartData.series.length) {
2518
+ slide.addChart(...comboChart(chartData, chartOptionsXml, {palette, chartOptions, labelColor, font: context.fonts.body, textSize}));
2519
+ return;
2520
+ }
2521
+ slide.addChart(chartData.type, chartData.series, chartOptionsXml);
2522
+ }
2523
+
2524
+ // FA-15: a combo chart is one PptxGenJS multi-type chart: a clustered column c:barChart on the primary axes, a c:lineChart with
2525
+ // markers for the primary-axis lines, and one for the secondary-axis lines on a second c:valAx (at the right, crossing at the
2526
+ // maximum of a deleted second c:catAx). Each series keeps its palette colour by its index in the whole chart (PptxGenJS restarts
2527
+ // the colour cycle per chart type), and the embedded workbook holds every series. The secondary axis has no gridlines and only
2528
+ // its own title; applyComboConstruct puts the line groups into schema order afterwards.
2529
+ function comboChart(chartData, options, {palette, chartOptions, labelColor, font, textSize}) {
2530
+ const series = chartData.series.map((entry, index) => ({...entry, color: palette[index % palette.length]}));
2531
+ const part = (role, axis) => series.filter((_, index) => chartData.combo[index].role === role && (role === 'bar' || chartData.combo[index].axis === axis));
2532
+ const bars = part('bar'), primaryLines = part('line', 'primary'), secondaryLines = part('line', 'secondary');
2533
+ const lineOptions = {lineDataSymbol: 'circle'};
2534
+ const types = [
2535
+ // The column group's own colour list: with one column series, a longer list would make PptxGenJS colour each column (c:dPt, RR-36).
2536
+ ...(bars.length ? [{type: 'bar', data: bars, options: {barDir: 'col', barGrouping: 'clustered', chartColors: bars.map((entry) => entry.color)}}] : []),
2537
+ ...(primaryLines.length ? [{type: 'line', data: primaryLines, options: lineOptions}] : []),
2538
+ ...(secondaryLines.length ? [{type: 'line', data: secondaryLines, options: {...lineOptions, secondaryValAxis: true, secondaryCatAxis: true}}] : []),
2539
+ ];
2540
+ // A gap in a line series breaks the line, as the preview draws it (PptxGenJS defaults to span).
2541
+ const {barGrouping: _grouping, ...rest} = options;
2542
+ const shared = {...rest, displayBlanksAs: 'gap'};
2543
+ if (!secondaryLines.length) return [types, {...shared, barDir: 'col'}];
2544
+ const title = chartOptions?.axisTitles?.secondary;
2545
+ const secondaryValue = {valGridLine: {style: 'none'}, showValAxisTitle: Boolean(title), ...(title ? {valAxisTitle: title, valAxisTitleColor: labelColor, valAxisTitleFontFace: font, valAxisTitleFontSize: textSize / 100} : {})};
2546
+ return [types, {...shared, barDir: 'col', valAxes: [{}, secondaryValue], catAxes: [{}, {catAxisHidden: true, showCatAxisTitle: false}]}];
2275
2547
  }
2276
2548
 
2277
2549
  // RR-54: a chart or table that uses a dataset, DataColumn objects, a mapping, a data source or number formats records its
@@ -2330,9 +2602,9 @@ function addTablePayload(slide, authoredTable, region, context, options, path, p
2330
2602
  const color = nativeColor(run.color !== undefined && run.color !== '' ? run.color : cellStyle.color, rawColor, context, defaultText), transparency = rawColor.length === 8 ? (1 - parseInt(rawColor.slice(6), 16) / 255) * 100 : 0;
2331
2603
  const runOptions = {
2332
2604
  ...nativeFontOptions(runStyle),fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
2333
- underline:run.underline ? {style:'sng',color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
2334
- color,transparency,baseline:fragment?.baselineShift ? -fragment.baselineShift / fragment.fontSize * 2000 : undefined,
2335
- hyperlink:run.link && /^(https?:|mailto:)/i.test(run.link) ? {url:run.link} : undefined,
2605
+ underline:run.underline ? {style:'sng',color:linkColor(run,color,context)} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
2606
+ color:linkColor(run,color,context),transparency,baseline:fragment?.baselineShift ? -fragment.baselineShift / fragment.fontSize * 2000 : undefined,
2607
+ hyperlink:run.link && /^(https?:|mailto:|tel:)/i.test(run.link) ? {url:run.link} : undefined,
2336
2608
  };
2337
2609
  // PptxGenJS marks every part of a newline-containing run as a paragraph
2338
2610
  // break, including its final part. Split explicitly so the next styled
@@ -2394,6 +2666,39 @@ function pixelBox(region) {
2394
2666
  return {x: region.x * 96, y: region.y * 96, width: region.w * 96, height: region.h * 96};
2395
2667
  }
2396
2668
 
2669
+ // One native text box per laid-out rich line (core's richLines), each carrying its runs: run color, size, weight, underline, strike,
2670
+ // super/subscript (a citation marker is a baseline run) and http(s)/mailto links. The body's lines stay untagged; a heading or quote
2671
+ // body passes `objectName` and `tag`, which name and tag every line with the boundary between wrapped lines (a soft wrap inserts
2672
+ // nothing, a hard line break keeps its separator), recovered from the flattened text. `fallback` is the color of runs without one.
2673
+ function addRichFitLines(slide, fit, box, context, {value, alignment: logicalAlignment, fallback, objectName, tag}) {
2674
+ const region = {x: box.x / 96, y: box.y / 96, w: box.width / 96, h: box.height / 96};
2675
+ const runText = run => typeof run === 'string' ? run : run.text;
2676
+ const whole = typeof value === 'string' ? value : value.map(runText).join('');
2677
+ const count = fit.richLines.length;
2678
+ let cursor = 0;
2679
+ for (const [index, line] of fit.richLines.entries()) {
2680
+ const runs = line.fragments.map(fragment => {
2681
+ const runColor = exportColor(fragment.run.color, context, context.colors.text);
2682
+ const color = nativeColor(fragment.run.color, runColor, context, fallback);
2683
+ return {text: fragment.text, options: {...nativeFontOptions(fragment.style), fontSize: (fragment.nominalSize ?? fragment.fontSize) * .75, color: linkColor(fragment.run, color, context), underline: fragment.run.underline ? {style: 'sng', color: linkColor(fragment.run, color, context)} : undefined, strike: fragment.run.strikethrough ? 'sngStrike' : undefined, baseline: fragment.baselineShift ? -fragment.baselineShift / (fragment.nominalSize ?? fragment.fontSize) * 2000 : undefined, hyperlink: fragment.kind !== 'marker' && fragment.run.link && /^(https?:|mailto:|tel:)/i.test(fragment.run.link) ? {url: fragment.run.link} : undefined, ...langOptions(context, fragment.style.lang)}};
2684
+ });
2685
+ // Logical alignment (RR-05): a right-to-left paragraph starts at the right edge; every wrapped line shares its paragraph's direction.
2686
+ const placed = fit.placement?.lines[index], alignment = placed?.alignment ?? physicalAlignment(logicalAlignment, fit.directions?.[index]), factor = alignment === 'right' ? 1 : alignment === 'center' ? .5 : 0;
2687
+ const area = placed ? {...region, x: (placed.x + line.width * factor - box.width * factor) / 96, y: placed.y / 96, h: placed.height / 96} : {...region, y: region.y + line.y / 96, h: line.height / 96};
2688
+ // PptxGenJS reads the paragraph direction from the first run's options, not from the shape options.
2689
+ if (fit.directions?.[index] === 'rtl') for (const run of runs) run.options.rtlMode = true;
2690
+ let name;
2691
+ if (tag) {
2692
+ const text = fit.lines[index] ?? '', end = cursor + text.length;
2693
+ const newline = index < count - 1 && whole.startsWith(text, cursor) ? /^(\r\n|\r|\n)/.exec(whole.slice(end)) : null;
2694
+ name = objectName(index);
2695
+ tag(name, index, count, {boundary: index === count - 1 ? 'end' : newline ? 'hard' : 'soft', separator: newline ? newline[0] : ''});
2696
+ cursor = end + (newline ? newline[0].length : 0);
2697
+ }
2698
+ if (runs.length) slide.addText(runs, {...textBoxOptions(area, context, fit.fontSize * .75), align: alignment, fit: 'none', wrap: false, lineSpacingMultiple: 1, ...(name ? {objectName: name} : {})});
2699
+ }
2700
+ }
2701
+
2397
2702
  // Match the published renderer's payload geometry and shared core text fitting.
2398
2703
  // Each fitted line remains native editable text, without PowerPoint rewrapping it.
2399
2704
  function addMeasuredPayloadText(slide, text, box, context, options, config) {
@@ -2405,7 +2710,7 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
2405
2710
  if (fit.overflow && !config.diagnosticsHandled) {
2406
2711
  const diagnostic = {code: 'text-overflow', path: config.path, message: 'Text exceeds its cell at the minimum font size; shorten it, increase its space, or split the slide.'};
2407
2712
  options.onDiagnostic?.(diagnostic);
2408
- if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path, issues: [diagnostic]});
2713
+ if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path, diagnostics: [diagnostic]});
2409
2714
  }
2410
2715
  // Generated socials carry one link per explicit source line; wrapped lines share it.
2411
2716
  let sourceLineIndex = 0;
@@ -2469,7 +2774,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
2469
2774
  for(const [index,part]of layout.parts.entries()){
2470
2775
  if(part.type==='image'){
2471
2776
  const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
2472
- const objectName = await addImagePayload(slide,presentation,part.image,region,part.path,{...context,imageFill:'fit'},options);
2777
+ const objectName = await addImagePayload(slide,presentation,part.image,region,part.path,context,options);
2473
2778
  // A generated deck logo is listed beside the manifest topology (see furnitureManifest) and tagged as a logo.
2474
2779
  if (objectName && part.field === 'logo') context.furnitureLogoTags.set(objectName, {v:1, role:'furniture', group:String(slideIndex), furniture:part.kind, zone:part.zone});
2475
2780
  else if (objectName) context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:manifestPartIndex(layout.parts, index)});
@@ -2511,52 +2816,76 @@ function addCodePayload(slide, value, layout, region, context, path, options) {
2511
2816
  if (invalid) throw new OPFPptxError('invalid-code-text', `Code text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before exporting.`, {path:part.path});
2512
2817
  }
2513
2818
  const group = String(context.codeTags.size + 1), panelName = `OPF code ${group} panel`;
2514
- 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});
2515
- context.codeTags.set(panelName,codeManifest(value,layout,group));
2819
+ 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,diagnostics:layout.diagnostics});
2820
+ // FA-13: code.highlight is one native rectangle per run of marked lines, between the panel and the line text boxes; marked
2821
+ // lines keep the syntax colours, the others are dimmed, from the same core helpers the preview calls.
2822
+ const highlight = codeHighlight(value, layout, context);
2823
+ context.codeTags.set(panelName,codeManifest(value,layout,group,highlight?.bands.length));
2516
2824
  const syntax = codeSyntax(value, layout, context, path, options);
2517
2825
  slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', width: .75}, objectName:panelName});
2826
+ if (highlight) {
2827
+ const bodyPart = layout.parts.find(part => part.role === 'body');
2828
+ highlight.bands.forEach((band, bandIndex) => {
2829
+ const lineHeight = bodyPart.fit.lineHeight;
2830
+ const bandName = `OPF code ${group} highlight ${bandIndex + 1}`;
2831
+ context.codeTags.set(bandName, {v:1, group, role:'highlight', index:bandIndex});
2832
+ slide.addShape('rect', {x: region.x + .01, y: (bodyPart.box.y + band.first * lineHeight) / 96, w: region.w - .02, h: (band.last - band.first + 1) * lineHeight / 96,
2833
+ fill: {color: highlight.colors.band.slice(1)}, line: {type: 'none'}, objectName: bandName});
2834
+ });
2835
+ }
2518
2836
  for (const [partIndex,part] of layout.parts.entries()) {
2519
- 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});
2837
+ if (!part.fit) throw new OPFPptxError('layout-overflow', 'Code content has no usable internal space; increase its cell size before exporting.', {path:part.path,diagnostics:layout.diagnostics});
2520
2838
  for (const [index,line] of part.fit.sourceLines.entries()) {
2521
2839
  const tabStops=line.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'}));
2522
2840
  const objectName = `OPF code ${group} ${part.role} line ${index+1}`;
2523
2841
  context.codeTags.set(objectName,{v:1,group,role:'line',part:partIndex,line:index});
2524
2842
  // RR-07: the same token ranges and palette the preview paints; the text of the runs is the line text, unchanged.
2525
- const runs = syntax && part.role==='body' ? opfCore.codeLineRuns(syntax.tokens,line.start,line.end,part.text) : undefined;
2843
+ const runs = syntax && part.role==='body' ? codeLineRuns(syntax.tokens,line.start,line.end,part.text) : undefined;
2844
+ const lineColors = highlight && part.role==='body' ? (highlight.marked.has(highlight.numbers[index]) ? highlight.colors.lit : highlight.colors.dim) : undefined;
2526
2845
  const lineText = part.text.slice(line.start,line.end);
2527
2846
  const lineOptions = {
2528
2847
  ...textBoxOptions({x:part.box.x/96,y:(part.box.y+index*part.fit.lineHeight)/96,w:part.box.width/96,h:part.fit.lineHeight/96},context,part.fit.fontSize*.75),
2529
2848
  ...nativeFontOptions(part.style),
2530
- color:part.role==='body'?'E5E7EB':'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
2849
+ color:part.role==='body'?(lineColors?.plain.slice(1)??'E5E7EB'):'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
2531
2850
  tabStops:tabStops.length?tabStops:undefined,objectName,
2532
2851
  };
2533
2852
  // A run's own options replace the line's, so every run repeats the font, size, alignment and tab stops and differs only in colour.
2534
- slide.addText(runs?.some(run=>run.kind) ? runs.map(run=>({text:part.text.slice(run.start,run.end),options:{...lineOptions,color:syntax.palette[run.kind??'plain'].slice(1)}})) : lineText,lineOptions);
2853
+ slide.addText(runs?.some(run=>run.kind) ? runs.map(run=>({text:part.text.slice(run.start,run.end),options:{...lineOptions,color:(lineColors??syntax.palette)[run.kind??'plain'].slice(1)}})) : lineText,lineOptions);
2535
2854
  }
2536
2855
  }
2537
2856
  }
2538
2857
 
2858
+ // FA-13: the marked lines of code.highlight, their bands (runs of displayed lines) and the lit/dimmed colours, from core (the preview
2859
+ // calls the same functions); undefined when the code marks no line.
2860
+ function codeHighlight(value, layout, context) {
2861
+ if (!value || typeof value !== 'object' || !Array.isArray(value.highlight)) return undefined;
2862
+ const body = layout.parts.find(part => part.role === 'body');
2863
+ if (!body?.fit) return undefined;
2864
+ const lines = codeHighlightLines(value.highlight, body.text).lines;
2865
+ if (!lines.length) return undefined;
2866
+ return {marked: new Set(lines), numbers: codeLineNumbers(body.fit.sourceLines), bands: codeHighlightBands(body.fit.sourceLines, lines), colors: codeHighlightColors(context.colorScheme)};
2867
+ }
2868
+
2539
2869
  // Token ranges and palette for the code body, from core (the preview calls the same functions); undefined for plain code.
2540
2870
  function codeSyntax(value, layout, context, path, options) {
2541
2871
  const body = layout.parts.find(part => part.role === 'body');
2542
2872
  const language = typeof value?.language === 'string' ? value.language : layout.parts.find(part => part.role === 'language')?.text;
2543
2873
  if (!body || !language) return undefined;
2544
- if (typeof opfCore.tokenizeCode !== 'function') return undefined;
2545
- const tokens = opfCore.tokenizeCode(body.text, language);
2546
- return tokens.length ? {tokens, palette: opfCore.codeSyntaxPaletteForScheme(context.colorScheme)} : undefined;
2874
+ const tokens = tokenizeCode(body.text, language);
2875
+ return tokens.length ? {tokens, palette: codeSyntaxPaletteForScheme(context.colorScheme)} : undefined;
2547
2876
  }
2548
2877
 
2549
2878
  function addMetricPayload(slide,value,layout,context,path,options) {
2550
2879
  if (!layout) throw new OPFPptxError('missing-metric-layout','Metric export requires coordinated core metric geometry.',{path});
2551
2880
  const group=String(context.metricTags.size+1),manifest=metricManifest(value,layout,group);
2552
2881
  // RR-07: a trend colours the trend and delta text and adds one native arrow shape, from core's accepted geometry.
2553
- const trendMark=typeof opfCore.metricTrendMark==='function'?opfCore.metricTrendMark(layout,{background:`#${normalizeHex(context.colors.background)}`}):undefined;
2882
+ const trendMark=metricTrendMark(layout,{background:`#${normalizeHex(context.colors.background)}`});
2554
2883
  const partColor=part=>trendMark&&(part.role==='trend'||part.role==='delta')?trendMark.color.slice(1):part.role==='value'?context.colors.accent:context.textColor;
2555
2884
  for (const [partIndex,part] of layout.parts.entries()) {
2556
2885
  const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
2557
2886
  if (invalid) throw new OPFPptxError('invalid-metric-text',`Metric text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before exporting.`,{path:part.path});
2558
2887
  if (!part.visible) continue;
2559
- if (!part.fit||part.linePositions?.length!==part.fit.sourceLines.length) throw new OPFPptxError('layout-overflow','Metric content has no accepted internal line positions; increase its cell size or coordinate package versions.',{path:part.path,issues:layout.diagnostics});
2888
+ if (!part.fit||part.linePositions?.length!==part.fit.sourceLines.length) throw new OPFPptxError('layout-overflow','Metric content has no accepted internal line positions; increase its cell size or coordinate package versions.',{path:part.path,diagnostics:layout.diagnostics});
2560
2889
  for (const [index,line] of part.fit.sourceLines.entries()) {
2561
2890
  const origin=part.linePositions[index],objectName=`OPF metric ${group} ${part.role} line ${index+1}`;
2562
2891
  context.metricTags.set(objectName,partIndex===0&&index===0?manifest:{v:1,group,role:'line',part:partIndex,line:index});
@@ -2582,37 +2911,80 @@ function addMetricPayload(slide,value,layout,context,path,options) {
2582
2911
  }
2583
2912
  }
2584
2913
 
2585
- function addQuotePayload(slide, value, layout, context, options, path) {
2914
+ async function addQuotePayload(slide, presentation, value, layout, context, options, path) {
2586
2915
  if (!layout) throw new OPFPptxError('missing-quote-layout', 'Quote export requires a coordinated core build with shared quote geometry.', {path});
2587
- for (const part of layout.parts) if (!part.fit) throw new OPFPptxError('layout-overflow', 'Quote content has no usable internal space; increase its cell size before exporting.', {path:part.path,issues:layout.diagnostics});
2916
+ for (const part of layout.parts) if (!part.fit) throw new OPFPptxError('layout-overflow', 'Quote content has no usable internal space; increase its cell size before exporting.', {path:part.path,diagnostics:layout.diagnostics});
2588
2917
  // Every line shape is tagged (OPF_QUOTE_V1) so an unchanged export re-imports as a quote payload (FF-57).
2589
2918
  const group=String(context.quoteTags.size),anchor=quoteManifest(value,layout);
2590
2919
  for (const [index,part] of layout.parts.entries()) {
2920
+ // FA-10: a TextRun[] quote body (core's rich lines, quotation marks joined to its first and last run) is exported line by line like rich body text, with the same quote tags.
2921
+ if (part.runs && part.fit.richLines) {
2922
+ addRichFitLines(slide, part.fit, part.box, context, {
2923
+ value: part.text, alignment: part.fit.placement?.alignment ?? context.contentAlignment ?? 'left', fallback: context.textColor,
2924
+ objectName: line => `OPF quote ${group} part ${index} line ${line}`,
2925
+ tag: (name, line, count, boundary) => context.quoteTags.set(name, {v: 1, role: 'text', group, part: index, line, count, ...boundary, base: richBase(part.fit, part.style, context.colors.text), ...(index === 0 && line === 0 ? {anchor} : {})})
2926
+ });
2927
+ continue;
2928
+ }
2591
2929
  addMeasuredPayloadText(slide,part.text,part.box,context,options,{
2592
2930
  path:part.path,fit:part.fit,textStyle:part.style,diagnosticsHandled:true,
2593
2931
  color:part.role==='footer'?context.mutedColor:context.textColor,
2594
2932
  quote:{group,part:index,anchor},
2595
2933
  });
2596
2934
  }
2935
+ if (layout.photo) await addQuotePhoto(slide, presentation, layout.photo, group, context, options);
2936
+ }
2937
+
2938
+ // FA-12: the attributed person's headshot is a native picture in the core circle frame: cropped to fill it (a:srcRect after the bytes
2939
+ // are embedded) with the `ellipse` preset geometry, the alt text as its description, tagged into the quote's provenance group.
2940
+ async function addQuotePhoto(slide, presentation, photo, group, context, options) {
2941
+ const box = { x: photo.box.x / 96, y: photo.box.y / 96, w: photo.box.width / 96, h: photo.box.height / 96 };
2942
+ const outcome = { box, cover: true };
2943
+ const resolved = await resolveImage(photo.value, presentation, options, photo.path, outcome);
2944
+ if (!resolved) {
2945
+ addImagePlaceholder(slide, presentation, photo.value, box, photo.path, context, options);
2946
+ return;
2947
+ }
2948
+ const objectName = quotePhotoName(group);
2949
+ // PowerPoint is not confirmed to apply a non-rectangular mask to an SVG picture, so an SVG photo exports as its PNG raster (as a masked slide image does).
2950
+ if (outcome.svg) options.onDiagnostic?.({ code: 'svg-image-rasterized', path: photo.path, message: 'An SVG quote photo exports as its PNG raster so the circular mask applies as in the preview; PowerPoint has not been confirmed to apply it to an SVG picture.' });
2951
+ context.quotePhotos.set(objectName, { region: box, mode: 'crop', path: photo.path, shape: photo.shape });
2952
+ context.quoteTags.set(objectName, { v: 1, role: 'photo', group });
2953
+ const alt = assetAlt(photo.value, presentation);
2954
+ context.pictureText.set(objectName, pictureText(alt, photo.value, presentation));
2955
+ slide.addImage({ ...resolved, objectName, ...box, altText: alt });
2597
2956
  }
2598
2957
 
2599
2958
  function addTimelinePayload(slide, value, layout, context, options, path) {
2600
2959
  if(!layout)throw new OPFPptxError('missing-timeline-layout','Timeline export requires a coordinated core build with shared timeline geometry.',{path});
2601
2960
  // Validate optional field fits before the provenance manifest dereferences
2602
2961
  // source lines or any drawing/tag mutation is performed.
2603
- for(const part of layout.parts)if(!part.fit)throw new OPFPptxError('layout-overflow','Timeline field has no usable space; change the arrangement or paginate events.',{path:part.path,issues:layout.diagnostics});
2962
+ for(const part of layout.parts)if(!part.fit)throw new OPFPptxError('layout-overflow','Timeline field has no usable space; change the arrangement or paginate events.',{path:part.path,diagnostics:layout.diagnostics});
2604
2963
  const scale=Math.min(context.dimensions.widthInches,context.dimensions.heightInches)*96/720;
2605
2964
  const group=String(context.timelineTags.size),anchor=timelineManifest(value,layout),connectorName=`OPF timeline ${group} connector`;
2606
2965
  const {x1,y1,x2,y2}=layout.connector;
2607
2966
  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}});
2608
2967
  context.timelineTags.set(connectorName,{v:1,group,role:'connector'});
2968
+ // FA-11: a status draws from the deck's colors through core's shared shapes: native ellipses (solid fill, or the
2969
+ // background fill plus a line for a hollow marker or a ring) and a muted text color for a planned event.
2970
+ const statusColors={background:`#${normalizeHex(context.colors.background)}`,primary:`#${context.colors.accent}`,text:`#${normalizeHex(context.colors.text)}`,mutedText:`#${context.colors.mutedText}`};
2609
2971
  for(const marker of layout.markers){
2610
- const objectName=`OPF timeline ${group} marker ${marker.eventIndex}`;
2611
- slide.addShape('ellipse',{objectName,x:(marker.x-marker.radius)/96,y:(marker.y-marker.radius)/96,w:marker.radius*2/96,h:marker.radius*2/96,fill:{color:context.colors.accent},line:{transparency:100}});
2612
- context.timelineTags.set(objectName,{v:1,group,role:'marker',eventIndex:marker.eventIndex});
2972
+ if(!marker.status){
2973
+ const objectName=`OPF timeline ${group} marker ${marker.eventIndex}`;
2974
+ slide.addShape('ellipse',{objectName,x:(marker.x-marker.radius)/96,y:(marker.y-marker.radius)/96,w:marker.radius*2/96,h:marker.radius*2/96,fill:{color:context.colors.accent},line:{transparency:100}});
2975
+ context.timelineTags.set(objectName,{v:1,group,role:'marker',eventIndex:marker.eventIndex});
2976
+ continue;
2977
+ }
2978
+ for(const shape of timelineMarkerShapes(marker,statusColors)){
2979
+ const objectName=`OPF timeline ${group} ${shape.role} ${marker.eventIndex}`;
2980
+ slide.addShape(shape.shape,{objectName,x:(shape.cx-shape.radius)/96,y:(shape.cy-shape.radius)/96,w:shape.radius*2/96,h:shape.radius*2/96,
2981
+ fill:{color:normalizeHex(shape.fill)},line:shape.stroke?{color:normalizeHex(shape.stroke.color),width:shape.stroke.width*.75}:{transparency:100}});
2982
+ context.timelineTags.set(objectName,{v:1,group,role:shape.role,eventIndex:marker.eventIndex,...(shape.role==='marker'?{status:marker.status}:{})});
2983
+ }
2613
2984
  }
2614
2985
  for(const [index,part]of layout.parts.entries()){
2615
- addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,timeline:{group,part:index,anchor}});
2986
+ const statusColor=part.status==='planned'?normalizeHex(timelineTextColor(part,statusColors)):undefined;
2987
+ addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,timeline:{group,part:index,anchor},...(statusColor?{color:statusColor}:{})});
2616
2988
  }
2617
2989
  }
2618
2990
 
@@ -2777,6 +3149,12 @@ function chosenFamilyMeasurement(measurement) {
2777
3149
  return wrapped;
2778
3150
  }
2779
3151
 
3152
+ // FA-13: TextRun.lang as the run's own proofing language (a:rPr lang), when it differs from the deck language.
3153
+ function langOptions(context, tag) {
3154
+ const lang = runLanguageTag(context.scriptFonts, tag);
3155
+ return lang ? {lang} : {};
3156
+ }
3157
+
2780
3158
  function nativeFontOptions(style) {
2781
3159
  const face=style.fontFace;
2782
3160
  if(face!==undefined) {
@@ -2826,14 +3204,15 @@ function textRuns(value, context, fallbackFontSize) {
2826
3204
  options: {
2827
3205
  bold: run?.bold,
2828
3206
  italic: run?.italic,
2829
- underline: run?.underline ? { style: "sng", color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor) } : undefined,
3207
+ underline: run?.underline ? { style: "sng", color: linkColor(run, nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor), context) } : undefined,
2830
3208
  strike: run?.strikethrough ? "sngStrike" : undefined,
2831
- color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor),
3209
+ color: linkColor(run, nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor), context),
2832
3210
  fontFace: run?.fontFamily ?? context.fonts.body,
2833
3211
  fontSize: run?.fontSize ?? fallbackFontSize,
2834
3212
  superscript: run?.superscript,
2835
3213
  subscript: !run?.superscript && run?.subscript,
2836
- hyperlink: run?.link && /^(https?:|mailto:)/i.test(run.link) ? { url: run.link } : undefined
3214
+ hyperlink: run?.link && /^(https?:|mailto:|tel:)/i.test(run.link) ? { url: run.link } : undefined,
3215
+ ...langOptions(context, run?.lang)
2837
3216
  }
2838
3217
  };
2839
3218
  });
@@ -2860,7 +3239,7 @@ function toPptxChartData(chart, chartexMode = 'auto', presentation) {
2860
3239
  return unplottable("dataset-unknown", "The chart's dataset is missing, so it cannot be drawn here.", `The chart references dataset '${stringifyText(authored.dataset)}', which the document does not hold (or a field it names).`);
2861
3240
  }
2862
3241
  if (!authored || !Array.isArray(authored.columns) || !Array.isArray(authored.rows)) {
2863
- 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.");
3242
+ return unplottable("no-columns", "The chart has no inline data, so it cannot be drawn here.", "The chart data is not inline columns and rows. Supply inline columns and rows or a dataset.");
2864
3243
  }
2865
3244
  if (authored.rows.length === 0) return unplottable("no-rows", "The chart has no data rows.", "The chart data has no rows.");
2866
3245
  if (authored.columns.length === 0) return unplottable("no-columns", "The chart has no data columns.", "The chart data has no columns.");
@@ -2887,14 +3266,16 @@ function toPptxChartData(chart, chartexMode = 'auto', presentation) {
2887
3266
  : 'that PowerPoint has not yet accepted natively from this exporter (pass chartex: \'native\' to write its chartex part)';
2888
3267
  adaptations.push({adaptation: "chartex-fallback", message: `The '${typeName}' chart is a PowerPoint extension (chartex) chart ${reason}; its data is exported as a native clustered column chart instead.`});
2889
3268
  }
2890
- const mapped = {type: spec.pptx, spec, chartex, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined, diagnostics, heading: data.columns[0], pointCount: data.rows.length};
3269
+ // FA-14: the resolved table (category, X, series columns), which chart.highlight names its series and categories in.
3270
+ const mapped = {resolved: {columns: data.columns, hasX: resolvedData.hasX === true, rows: data.rows}, type: spec.pptx, spec, chartex, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined, diagnostics, heading: data.columns[0], pointCount: data.rows.length,
3271
+ // FA-15: core's combo plan (column series, then the primary-axis lines, then the secondary-axis lines), aligned with the series.
3272
+ ...(spec.family === 'combo' ? {combo: comboPlanOf(resolvedData, data.columns.length - 1)} : {})};
2891
3273
  // Number formats only when a column has one: a chart without formats carries no `formats` and is written as before.
2892
3274
  const withFormats = (result, formats) => formats.series.some((code) => code !== undefined) || formats.x !== undefined ? {...result, formats} : result;
2893
3275
  if (data.columns.length === 1) {
2894
3276
  const heading = data.columns[0];
2895
- // The lone column holds the chart's values. Core (opf#376) already reads it with chartNumber and reports each
2896
- // non-numeric cell; a core without RR-54 hands it over as authored, so it is read (and its rejects counted) here. A
2897
- // cell that holds no number is skipped, never plotted as 0.
3277
+ // The lone column holds the chart's values. Each cell is read with chartNumber and each non-numeric cell is counted
3278
+ // as rejected. A cell that holds no number is skipped, never plotted as 0.
2898
3279
  const points = data.rows.map((row, index) => ({row: index + 1, value: chartNumber(row?.[0]), cell: row?.[0]}));
2899
3280
  for (const point of points) if (point.value === null && point.cell !== null && point.cell !== undefined && point.cell !== '') rejected.push({path: `/data/rows/${point.row - 1}/0`, cell: point.cell});
2900
3281
  const plottedPoints = points.filter((point) => point.value !== null);
@@ -2964,6 +3345,12 @@ function toPptxChartData(chart, chartexMode = 'auto', presentation) {
2964
3345
  return withFormats({...mapped, series, hasCategories: true, adaptations, notNumeric}, formats);
2965
3346
  }
2966
3347
 
3348
+ /** Core's combo plan; a core without one draws every series as columns except the last, a line on the primary axis (the preview's rule). */
3349
+ function comboPlanOf(resolved, count) {
3350
+ if (Array.isArray(resolved.combo) && resolved.combo.length === count) return resolved.combo;
3351
+ return Array.from({length: count}, (_, index) => ({role: count > 1 && index === count - 1 ? 'line' : 'bar', axis: 'primary'}));
3352
+ }
3353
+
2967
3354
  /** A chart column's name: the string, or a DataColumn's `name`. */
2968
3355
  function dataColumnName(column) {
2969
3356
  return typeof column === 'string' ? column : column !== null && typeof column === 'object' && typeof column.name === 'string' ? column.name : undefined;
@@ -3193,71 +3580,12 @@ function assetAlt(asset, presentation) {
3193
3580
  return undefined;
3194
3581
  }
3195
3582
 
3196
- function resolveCatalogRecord(presentation, kind, reference, fallbackId) {
3197
- const id = referenceId(reference) ?? fallbackId;
3198
- const inlineRecords = normalizeRecords(presentation.catalogs?.[kind]);
3199
- return findById(inlineRecords, id)
3200
- ?? findById(defaultCatalog(kind), id)
3201
- ?? findById(defaultCatalog(kind), fallbackId)
3202
- ?? null;
3203
- }
3204
-
3205
- function resolveDesignRecord(presentation, kind, reference, fallbackId) {
3206
- const base = resolveCatalogRecord(presentation, kind, reference, fallbackId) ?? {};
3207
- return {
3208
- ...base,
3209
- ...(isPlainObject(reference) ? withoutSchema(reference) : {})
3210
- };
3211
- }
3212
-
3213
3583
  function referenceId(reference) {
3214
3584
  if (typeof reference === "string") return reference;
3215
3585
  if (isPlainObject(reference) && typeof reference.id === "string") return reference.id;
3216
3586
  return null;
3217
3587
  }
3218
3588
 
3219
- const DEFAULT_SOURCE_PREFIX = "https://www.pptx.gallery/";
3220
-
3221
- // Same order as opf-render's socialPlatformRecords so preview and export format
3222
- // socials identically. Core applies inline document records first; then the
3223
- // document catalog source (host-supplied options.catalogSources, or the bundled
3224
- // catalog for pptx.gallery/pkg sources), injected options.catalogs, and the
3225
- // bundled catalog (the engine default source also resolves to it).
3226
- function socialPlatformRecords(presentation, options) {
3227
- const kind = "socialPlatforms", declared = presentation.catalogs?.[kind]?.source;
3228
- // `source` is one source or an ordered search path (an array): records of each source in order, first match wins.
3229
- const sourceRecords = (Array.isArray(declared) ? declared : [declared]).filter(source => typeof source === "string").flatMap(source => {
3230
- const bySource = options.catalogSources?.[source];
3231
- return bySource ? normalizeRecords(bySource)
3232
- : source.startsWith(DEFAULT_SOURCE_PREFIX) || source.startsWith("pkg:@openpresentation/opf/") ? defaultCatalog(kind) : [];
3233
- });
3234
- return [...sourceRecords, ...normalizeRecords(options.catalogs?.[kind]), ...defaultCatalog(kind)];
3235
- }
3236
-
3237
- function defaultCatalog(kind) {
3238
- return Array.isArray(bundledCatalogs[kind]) ? bundledCatalogs[kind] : [];
3239
- }
3240
-
3241
- function normalizeRecords(catalog) {
3242
- if (!catalog) return [];
3243
- if (Array.isArray(catalog)) return catalog;
3244
- if (Array.isArray(catalog.records)) return catalog.records;
3245
- return [];
3246
- }
3247
-
3248
- function findById(records, id) {
3249
- return records.find((record) => record?.id === id) ?? null;
3250
- }
3251
-
3252
- function withoutSchema(value) {
3253
- return Object.fromEntries(Object.entries(value).filter(([key]) => key !== "$schema"));
3254
- }
3255
-
3256
- function resolveDimensions(value) {
3257
- const { width, height } = resolveCanvasDimensions(value);
3258
- return { widthInches: width / 96, heightInches: height / 96 };
3259
- }
3260
-
3261
3589
  // A solid color or pattern background color is a ColorRef (hex, `var:` variable, colour-scheme slot or role), resolved
3262
3590
  // as for table fills and run colours. Roles resolve through the colour scheme alone: the background cannot depend on itself.
3263
3591
  function resolveBackgroundColorRef(entry, colorScheme, variables = {}) {
@@ -3269,29 +3597,18 @@ function resolveBackground(value, colorScheme, variables = {}) {
3269
3597
  const reference = entry => normalizeHex(resolveBackgroundColorRef(entry, colorScheme, variables) ?? entry, fallback);
3270
3598
  if (typeof value === "string") {
3271
3599
  if (value.startsWith("#")) return normalizeHex(value, fallback);
3272
- return normalizeHex(colorScheme[value] ?? colorScheme.background ?? colorScheme.light1 ?? "#FFFFFF", fallback);
3600
+ return normalizeHex(colorScheme[value] ?? defaultSlideBackground(colorScheme), fallback);
3273
3601
  }
3274
3602
  if (isPlainObject(value)) {
3275
3603
  if (value.type === "solid" && value.color) return reference(value.color);
3276
3604
  if (value.type === "theme" && value.slot) {
3277
- return normalizeHex(colorScheme[value.slot] ?? colorScheme.light1 ?? "#FFFFFF", fallback);
3605
+ return normalizeHex(colorScheme[value.slot] ?? defaultSlideBackground(colorScheme), fallback);
3278
3606
  }
3279
3607
  // Like the SVG preview, text contrast follows a pattern's background color.
3280
3608
  if (value.type === "pattern") return reference(value.pattern?.backgroundColor ?? "#FFFFFF");
3281
3609
  if (value.backgroundColor) return normalizeHex(value.backgroundColor, fallback);
3282
3610
  }
3283
- return normalizeHex(colorScheme.background ?? colorScheme.light1 ?? "#FFFFFF", fallback);
3284
- }
3285
-
3286
- function resolveFonts(fontScheme) {
3287
- return {id:fontScheme.id,...resolveFontFamilies(fontScheme),scheme:{type:fontScheme.type,major:fontScheme.major,minor:fontScheme.minor}};
3288
- }
3289
-
3290
-
3291
- function readableTextColor(background, colorScheme) {
3292
- return isDarkHex(background)
3293
- ? normalizeHex(colorScheme.light1 ?? "#FFFFFF")
3294
- : normalizeHex(colorScheme.text ?? colorScheme.dark1 ?? "#0F172A");
3611
+ return normalizeHex(defaultSlideBackground(colorScheme), fallback);
3295
3612
  }
3296
3613
 
3297
3614
  function normalizeHex(value, fallback = "000000") {
@@ -3306,15 +3623,6 @@ function normalizeHex(value, fallback = "000000") {
3306
3623
  return fallback.replace(/^#/, "").toUpperCase().slice(0, 6);
3307
3624
  }
3308
3625
 
3309
- function isDarkHex(value) {
3310
- const hex = normalizeHex(value);
3311
- if (!/^[0-9A-F]{6}$/.test(hex)) return false;
3312
- const red = Number.parseInt(hex.slice(0, 2), 16);
3313
- const green = Number.parseInt(hex.slice(2, 4), 16);
3314
- const blue = Number.parseInt(hex.slice(4, 6), 16);
3315
- return (red * 299 + green * 587 + blue * 114) / 1000 < 128;
3316
- }
3317
-
3318
3626
  // The master, layout and notes master half of the native header/footer (RR-11): placeholders and p:hf on the finished parts.
3319
3627
  // Every deck gets them (flags off when no slide uses a type), so Insert > Header & Footer works on a deck with no footer too.
3320
3628
  function writeNativeFurnitureMasters(output, context) {
@@ -3373,13 +3681,18 @@ async function normalizePptxZip(raw, context) {
3373
3681
  const {heading,labelColor,spec,textSize}=context.chartHeadings.get(name);
3374
3682
  if(heading!==undefined)writeChartCategoryHeading(entries,chartPart,heading);
3375
3683
  entries[chartPart]=encodeText(omitEmptyNumberPoints(applyChartConstruct(decodeText(entries[chartPart]),spec)));
3684
+ const highlight=context.chartHeadings.get(name).highlight;
3685
+ if(highlight){
3686
+ try{entries[chartPart]=encodeText(applyChartHighlight(decodeText(entries[chartPart]),highlight));}
3687
+ catch(error){throw new OPFPptxError('packaging-failed',`Chart highlight could not be written: ${errorMessage(error)}`);}
3688
+ }
3376
3689
  // PptxGenJS hardcodes a black fallback in pie/doughnut label properties.
3377
3690
  // Normalize only our generated chart text styles; point/series fills stay intact.
3378
3691
  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>`)));
3379
3692
  entries[chartPart]=encodeText(applyChartTextSize(decodeText(entries[chartPart]),textSize));
3380
3693
  // RR-35: data labels last, so their own text colours (contrast inside a mark) are not normalized away.
3381
3694
  const {options:chartOptions,kind:optionKind,pointCount}=context.chartHeadings.get(name);
3382
- if(chartOptions?.dataLabels)entries[chartPart]=encodeText(applyDataLabels(decodeText(entries[chartPart]),{resolved:chartOptions,kind:optionKind,palette:context.chartPalettes.get(name),labelColor,font:context.chartFonts.get(name)?.body??'Arial',textSize,pointCount}));
3695
+ if(chartOptions?.dataLabels)entries[chartPart]=encodeText(applyDataLabels(decodeText(entries[chartPart]),{resolved:chartOptions,kind:optionKind,palette:context.chartPalettes.get(name),labelColor,font:context.chartFonts.get(name)?.body??'Arial',textSize,pointCount,highlight}));
3383
3696
  // RR-54: the columns' number formats, after the labels: caches, label and value-axis formats, then the workbook cells.
3384
3697
  const numberFormats=context.chartHeadings.get(name).numberFormats;
3385
3698
  if(numberFormats){
@@ -3390,6 +3703,7 @@ async function normalizePptxZip(raw, context) {
3390
3703
  }
3391
3704
  }
3392
3705
  applyChartFonts(entries,context.chartFonts,parseRelationships);
3706
+ if (context.chartAlts.size) for (const [part, bytes] of Object.entries(entries)) if (/^ppt\/slides\/slide\d+\.xml$/.test(part)) { const xml = decodeText(bytes), next = applyChartAlt(xml, context.chartAlts); if (next !== xml) entries[part] = encodeText(next); }
3393
3707
  // Chartex charts: the native cx:chartSpace part, its style parts and the mc:AlternateContent frame (the classic chart above is the fallback).
3394
3708
  try {
3395
3709
  attachChartexParts(entries, context.chartex, parseRelationships);
@@ -3408,7 +3722,7 @@ async function normalizePptxZip(raw, context) {
3408
3722
  const relationships = parseRelationships(entries, part);
3409
3723
  for (const [picture] of decodeText(bytes).matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
3410
3724
  const name = picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
3411
- const placement = context.imagePlacements.get(name) ?? context.slideImages.get(name) ?? (name === watermarkName() ? context.watermarks.get(part) : name === logoName() ? context.logos.get(part) : undefined);
3725
+ const placement = context.images.get(name) ?? context.quotePhotos.get(name) ?? (name === watermarkName() ? context.watermarks.get(part) : name === logoName() ? context.logos.get(part) : undefined);
3412
3726
  const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
3413
3727
  if (placement) imageSources.set(relationships.get(id)?.path, placement.path);
3414
3728
  }
@@ -3437,11 +3751,18 @@ async function normalizePptxZip(raw, context) {
3437
3751
  }
3438
3752
  imageMetadata.set(part, metadata);
3439
3753
  }
3440
- // Slide images need the embedded dimensions; document provenance (FF-32)
3754
+ // Image pictures need the embedded dimensions (core's fitImage crops and pads them); document provenance (FF-32)
3441
3755
  // later records the placed frames as slide geometry evidence.
3442
- placeSlideImages(entries, context.slideImages, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
3443
- throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path });
3444
- });
3756
+ try {
3757
+ placeImages(entries, context.images, context.imageOverlays, {
3758
+ metadataFor: (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), fitImage,
3759
+ fail: path => { throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path }); }
3760
+ });
3761
+ } catch (error) {
3762
+ if (error instanceof OPFPptxError) throw error;
3763
+ throw new OPFPptxError("packaging-failed", `Image pictures could not be placed: ${errorMessage(error)}`);
3764
+ }
3765
+ tagTextWatermarks(entries, context.textWatermarks);
3445
3766
  placeWatermarks(entries, context.watermarks, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
3446
3767
  throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path });
3447
3768
  });
@@ -3599,7 +3920,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3599
3920
  const metadata = imageMetadata.get(backgroundRelationships.get(id)?.path);
3600
3921
  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 });
3601
3922
  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.'});
3602
- return `<p:bg><p:bgPr>${nativeImageBackgroundFill(id, metadata, fill.image)}<a:effectLst/></p:bgPr></p:bg>`;
3923
+ return `<p:bg><p:bgPr>${nativeImageBackgroundFill(id, metadata, {...fill.image, fitImage})}<a:effectLst/></p:bgPr></p:bg>`;
3603
3924
  });
3604
3925
  }
3605
3926
  // PptxGenJS table IDs can collide with other objects on the same slide.
@@ -3685,7 +4006,8 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3685
4006
  }
3686
4007
  const text = context.pictureText.get(picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1]);
3687
4008
  if (text) picture = writePictureText(picture, text);
3688
- const placement = context.imagePlacements.get(picture.match(/name="(OPF image \d+)"/)?.[1]);
4009
+ // Image pictures were placed with the slide's other pictures (placeImages); quote photos are fitted here.
4010
+ const placement = context.quotePhotos.get(picture.match(/name="(OPF quote photo \d+)"/)?.[1]);
3689
4011
  if (!placement) return picture;
3690
4012
  const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
3691
4013
  const dimensions = imageMetadata.get(relationships.get(id)?.path);
@@ -3698,6 +4020,8 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3698
4020
  const attrs = Object.entries(fitted.crop).map(([key, value]) => `${key}="${value}"`).join(' ');
3699
4021
  picture = picture.replace('<a:stretch>', `<a:srcRect ${attrs}/><a:stretch>`);
3700
4022
  }
4023
+ // The circular headshot mask: the core frame's DrawingML preset (ellipse) instead of the rectangle.
4024
+ if (placement.shape) picture = picture.replace(/<a:prstGeom\b[\s\S]*?<\/a:prstGeom>/, `<a:prstGeom prst="${placement.shape.preset}"><a:avLst/></a:prstGeom>`);
3701
4025
  return picture;
3702
4026
  });
3703
4027
  // Native bullets otherwise inherit the first rich run's size, font and
@@ -3733,6 +4057,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3733
4057
  if (context.scriptFonts) xml = partScriptFonts(path, xml, context.scriptFonts, context.partSlides.get(path) ?? 0);
3734
4058
  else if (/^ppt\/theme\/theme\d+\.xml$/.test(path)) xml = themeEastAsianFromLatin(xml);
3735
4059
  if (/^ppt\/(?:slides\/slide|notesSlides\/notesSlide)\d+\.xml$/.test(path)) xml = stripRunScriptFonts(xml);
4060
+ if (context.scriptFonts && /^ppt\/slides\/slide\d+\.xml$/.test(path)) xml = runLanguageFonts(xml, context.scriptFonts, context.partSlides.get(path) ?? 0);
3736
4061
  return encodeText(normalizePartReferences(xml, renameMaps));
3737
4062
  }
3738
4063
  return bytes;