@openpresentation/opf-pptx 0.13.1 → 0.14.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';
@@ -33,33 +35,36 @@ import {importImageOrientation} from './image-import.js';
33
35
  import {extractSignals, normalizeSignalOptions, themeFactsFor} from './import-signals.js';
34
36
  import {placeSlideImages, importSlideImage, slideImageName, slideImageOverlayName} from './slide-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
+ chartPaletteForFill, codeHighlightBands, codeHighlightColors, codeHighlightLines, codeLineNumbers, codeLineRuns, codeSyntaxPaletteForScheme, composeSlide,
50
+ DEFAULT_FONT_SCHEME, defaultSlideBackground, fitText, layoutTable, layoutWatermark, metricTrendMark, resolveCanvasDimensions, resolveColorRoles, resolveFontFamilies,
51
+ 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
58
  catalogs as bundledCatalogs,
54
- validatePresentation
59
+ hasContentVariables,
60
+ isTemplate,
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",
@@ -225,7 +227,9 @@ export async function toPptx(input, options = {}) {
225
227
  }
226
228
  const presentation = resolveTemplateInput(parseInput(input), options);
227
229
  assertValidBoundary(presentation);
228
- options = {...options, textMeasurement: chosenFamilyMeasurement(options.textMeasurement), svgRasters: new Map()};
230
+ // `options.fonts` is the fonts handle (the renderer's loadFonts()); the exporter reads its measurement, wrapped so the package names the
231
+ // chosen family (FF-31). Internally `options.textMeasurement` is that wrapped measurement; it is not a public option.
232
+ options = {...options, textMeasurement: chosenFamilyMeasurement(options.fonts?.textMeasurement), svgRasters: new Map()};
229
233
 
230
234
  const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
231
235
  context.listMarkers = new Map();
@@ -237,6 +241,7 @@ export async function toPptx(input, options = {}) {
237
241
  context.pictureText = new Map();
238
242
  context.slideImages = new Map();
239
243
  context.watermarks = new Map();
244
+ context.textWatermarks = new Map();
240
245
  context.logos = new Map();
241
246
  context.logoPlaceholderTags = new Map();
242
247
  context.bulletImages = new Map();
@@ -249,6 +254,7 @@ export async function toPptx(input, options = {}) {
249
254
  context.plainTextTags = new Map();
250
255
  context.timelineTags = new Map();
251
256
  context.quoteTags = new Map();
257
+ context.quotePhotos = new Map();
252
258
  context.furnitureTags = new Map();
253
259
  context.furnitureLogoTags = new Map();
254
260
  context.furnitureFields = new Map();
@@ -261,6 +267,7 @@ export async function toPptx(input, options = {}) {
261
267
  context.metricTags = new Map();
262
268
  context.metricDescriptions = new Map();
263
269
  context.chartHeadings = new Map();
270
+ context.chartAlts = new Map();
264
271
  context.chartFonts = new Map();
265
272
  context.chartPalettes = new Map();
266
273
  context.chartex = new Map();
@@ -278,7 +285,7 @@ export async function toPptx(input, options = {}) {
278
285
  }
279
286
  context.chartexMode = options.chartex ?? 'auto';
280
287
  Object.assign(context, exportTheme(presentation, context));
281
- context.reportedFontSchemes = new Set();
288
+ context.reportedReferences = new Set();
282
289
  // Document references and metadata tags (FF-32, docs/document-roundtrip.md).
283
290
  context.documentProvenance = options.provenance === false ? null : documentProvenance(presentation, {
284
291
  mode: options.provenance ?? "full",
@@ -297,14 +304,14 @@ export async function toPptx(input, options = {}) {
297
304
  context.masterBackground = masterBackground(context);
298
305
  context.linkSentinels = linkSentinels(presentation);
299
306
  const pptx = new PptxGenJS();
300
- configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
307
+ configurePresentation(pptx, presentation, {...context,fonts:exportSlideContext(presentation,0,context,options).fonts});
301
308
 
302
309
  for (let index = 0; index < presentation.slides.length; index += 1) {
303
310
  await addSlide(pptx, presentation, presentation.slides[index], index, context, options);
304
311
  }
305
312
 
306
313
  // 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),
314
+ context.fontPitch = fontPitchFamilies(presentation.slides.map((slide, index) => exportSlideContext(presentation, index, context, options).fonts),
308
315
  [...normalizeRecords(presentation.catalogs?.fontSchemes), ...defaultCatalog("fontSchemes")]);
309
316
 
310
317
  let raw;
@@ -384,6 +391,7 @@ export async function fromPptx(input, options = {}) {
384
391
  const furniture = importFurniture(furnitureContexts, entries, options.onDiagnostic);
385
392
  if (Object.keys(furniture.design).length) imported.design = {...imported.design, ...furniture.design};
386
393
  if (furniture.organization) imported.organization = furniture.organization;
394
+ if (furniture.speaker) imported.speaker = furniture.speaker;
387
395
 
388
396
  // RR-54: the datasets recorded at export (OPF_DATASETS_V1). Chart and table frames restore their dataset references against
389
397
  // them, so the document carries them before document provenance validates the restored document.
@@ -391,7 +399,7 @@ export async function fromPptx(input, options = {}) {
391
399
  const mediaRegistry = Object.create(null);
392
400
  for (let index = 0; index < slidePaths.length; index += 1) {
393
401
  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]));
402
+ 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.design) : undefined, dataProvenance: {datasets}}, furniture.slides[index], furnitureContexts[index]));
395
403
  }
396
404
  if (datasets) imported.datasets = datasets;
397
405
  // Native sections (PowerPoint's own section list, `Default Section` = none)
@@ -421,14 +429,14 @@ export async function fromPptx(input, options = {}) {
421
429
  let slideProvenance = slidePaths.map(() => ({structure: "untagged"}));
422
430
  let restoredGroups = [];
423
431
  try {
424
- const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true,
432
+ const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true, speakerConflict: furniture.speakerConflict === true,
425
433
  // Host catalogs format stored socials exactly as export did (FF-34).
426
434
  socialPlatformRecords: catalogs => socialPlatformRecords({catalogs}, options),
427
435
  nativeSections: slideSections,
428
436
  slides: slidePaths.map((path, index) => ({path, root: furnitureContexts[index].root, relationships: furnitureContexts[index].relationships, contentBounds: furnitureContexts[index].contentBounds}))}, report);
429
437
  // The stored language wins while the runs still carry its tag (FF-07).
430
438
  restored.groups = reconcileLanguage(restored.groups, observedLanguage, report);
431
- imported = applyDocumentProvenance(imported, restored, validatePresentation, report);
439
+ imported = applyDocumentProvenance(imported, restored, checkFormat, report);
432
440
  slideProvenance = restored.slides;
433
441
  restoredGroups = restored.groups;
434
442
  } catch (error) {
@@ -443,11 +451,11 @@ export async function fromPptx(input, options = {}) {
443
451
  if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
444
452
  languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
445
453
 
446
- const result = validatePresentation(imported);
447
- if (!result.valid) {
454
+ const validation = checkFormat(imported);
455
+ if (!validation.valid) {
448
456
  throw new OPFPptxError("invalid-import-opf", "Imported PPTX did not produce valid OPF.", {
449
- issues: result.errors,
450
- result
457
+ findings: validation.findings.filter(finding => finding.severity === "error"),
458
+ report: validation
451
459
  });
452
460
  }
453
461
 
@@ -458,7 +466,7 @@ export async function fromPptx(input, options = {}) {
458
466
  const archive = {part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]};
459
467
  const signals = extractSignals({archive, presentationRoot, slidePaths, themeFacts: themeFactsFor(slidePaths[0], archive), limits: signalLimits,
460
468
  recorded: furnitureContexts.map(context => context.signalSources), slideProvenance, contentPaths});
461
- return {document: imported, signals};
469
+ return {presentation: imported, signals};
462
470
  }
463
471
 
464
472
  // RR-08: run colours named by a ColorRef (variable, scheme slot, role) come back as that name where the document's own
@@ -623,6 +631,16 @@ function readCoreProperties(entries) {
623
631
  };
624
632
  }
625
633
 
634
+ // 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,
635
+ // else the bundled record's, else the shared fallback; heading and body families are never code. Only a package the exporter
636
+ // wrote (it carries OPF tag parts) is read this way; a deck from another tool keeps its runs' font families as they are.
637
+ function importedCodeFamily(design) {
638
+ const reference = design?.fontScheme;
639
+ const id = referenceId(reference);
640
+ const base = (id && findById(defaultCatalog("fontSchemes"), id)) || findById(defaultCatalog("fontSchemes"), DEFAULT_FONT_SCHEME);
641
+ return resolveFontFamilies({...base, ...(isPlainObject(reference) ? reference : {})}).code;
642
+ }
643
+
626
644
  function dimensionsFromPresentation(presentationRoot) {
627
645
  const size = presentationRoot["p:sldSz"];
628
646
  const width = emuToInches(size?.cx);
@@ -659,6 +677,11 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
659
677
  nativeContext.slideImagePictures = slideImage.consumed;
660
678
  nativeContext.slideImageShapes = slideImage.consumedShapes;
661
679
  const watermarkPath = `slides.${slideIndex}.design.watermark`;
680
+ // FA-13: a text watermark is a tagged native text box; it is not content and never imports as a text block.
681
+ const textWatermark = importTextWatermark(nativeContext.shapes, nativeContext.paragraphs.map(shapeParagraphs => shapeParagraphs.map(paragraph => ({text: paragraph.text}))), relationships, entries, slideIndex,
682
+ diagnostic => options.onDiagnostic?.({...diagnostic, path: watermarkPath}));
683
+ if (textWatermark.design) slide.design = {...slide.design, ...textWatermark.design};
684
+ nativeContext.watermarkShapes = textWatermark.consumed;
662
685
  const watermark = importWatermark(nativeContext.pictures, relationships, entries, slideIndex,
663
686
  picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
664
687
  // The recorded fit is re-derived from design.watermark.
@@ -684,12 +707,22 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
684
707
  // fill an absent role by geometry; explicit native placeholders still apply.
685
708
  const inferHeadings = !items.some(item => item.heading||item.sourceText);
686
709
  const heading = field => {const index=items.findIndex(item=>item.heading===field);return index<0?null:items.splice(index,1)[0];};
710
+ // FA-10: a heading is its plain text, or TextRun[] when its native runs are formatted differently from one another (the way
711
+ // body text reads current native runs). A tagged line group brings its own value; one native shape (a title placeholder, or the
712
+ // shape the importer took for the title) is read through the native body reader.
713
+ const headingText = item => {
714
+ const key = item.sources?.length === 1 ? /^sp:(\d+)$/.exec(item.sources[0]) : null;
715
+ const parts = item.richText === undefined && key ? joinRichLines([{index: Number(key[1])}], nativeContext.readBody, () => '') : undefined;
716
+ const value = item.richText ?? (parts ? headingValue(parts) : undefined);
717
+ // Only when it reads as the same words as the plain path: the native text is authoritative.
718
+ return Array.isArray(value) && value.map(run => typeof run === 'string' ? run : run.text).join('') === item.text ? value : item.text;
719
+ };
687
720
  const tagItem=heading('tag');
688
- if(tagItem)slide.tag=tagItem.text;
721
+ if(tagItem)slide.tag=headingText(tagItem);
689
722
  const titleItem = heading('title')??takeTitleItem(items, dimensions, inferHeadings);
690
- if (titleItem) slide.title = titleItem.text;
723
+ if (titleItem) slide.title = headingText(titleItem);
691
724
  const subtitleItem = heading('subtitle')??takeSubtitleItem(items, titleItem, dimensions, inferHeadings);
692
- if (subtitleItem) slide.subtitle = subtitleItem.text;
725
+ if (subtitleItem) slide.subtitle = headingText(subtitleItem);
693
726
 
694
727
  // Heading inference keeps its existing scalar/native metrics. Only remaining
695
728
  // ordinary body shapes gain current rich values; tagged recovery is separate.
@@ -760,16 +793,21 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
760
793
  nativeContext.signalRoles = roles;
761
794
  const readBody = nativeBodyReader(slidePath, {
762
795
  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}`}));
796
+ }, relationships, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.${diagnostic.path}`}), {deckLang: options.deckLang, codeFamily: options.codeFamily});
764
797
  const code = importCodeGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.code`}));
765
798
  const metric = importMetricGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.metric`}));
766
799
  const cards = importCardFrames(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
767
800
  const media = importMediaGroups(shapes, paragraphs, relationships, entries, slideIndex, diagnostic => options.onDiagnostic?.(diagnostic), nativeContext.mediaRegistry);
768
801
  nativeContext.mediaAssets = media.assets;
769
- const headings=importHeadingGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
802
+ nativeContext.readBody=readBody;
803
+ const headings=importHeadingGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}),readBody);
770
804
  const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
771
805
  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`}));
806
+ const quotes=importQuoteGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.quote`}),nativeContext.pictures,
807
+ picture=>importPicture(entries,picture,slidePath,relationships,diagnostic=>{
808
+ // The recorded crop is re-derived from the circular frame of the quote photo.
809
+ if(diagnostic.code!=='unsupported-image-crop')options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.quote.photo`});
810
+ }),readBody);
773
811
  // RR-34: tagged caption and footnote lines are not content; captions re-attach to their media block and notes rebuild cite/footnote.
774
812
  const annotations=importAnnotations({shapes,paragraphs,relationships,entries,slideIndex,readBody,report:diagnostic=>options.onDiagnostic?.(diagnostic)});
775
813
  nativeContext.annotations=annotations;
@@ -778,7 +816,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
778
816
  if(item){
779
817
  const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
780
818
  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)});
819
+ items.push({...item,heading:group.field,sourceText:!group.field,sources:sourcesOf(group.shapes),...(group.richText!==undefined?{richText:group.richText}:{})});
782
820
  }
783
821
  }
784
822
  for(const group of timelines.items){
@@ -787,18 +825,18 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
787
825
  items.push({kind:'timeline',sourceText:true,bounds:union,payload:group.payload,sources:sourcesOf(group.shapes)});
788
826
  }
789
827
  for(const group of quotes.items){
790
- const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
828
+ 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
829
  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)});
830
+ items.push({kind:'quote',bounds:union,payload:group.payload,sources:[...sourcesOf(group.shapes),...group.pictures.map(picture=>`pic:${nativeContext.pictures.indexOf(picture)}`)]});
793
831
  }
794
832
  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
833
  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
834
  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
835
  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'
836
+ const role = furniture.text.has(index) ? 'furniture' : nativeContext.slideImageShapes?.has(index) ? 'slide-image' : nativeContext.watermarkShapes?.has(index) ? 'watermark' : nativeContext.logoPlaceholderShapes?.has(index) ? 'logo' : cards.has(shape) ? 'card-frame'
799
837
  : 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
838
  if (role) roles.set(shapeKeys.get(shape), role);
801
- if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index)) continue;
839
+ if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index) || nativeContext.watermarkShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index)) continue;
802
840
  if (annotations.consumed.has(shape)) { roles.set(shapeKeys.get(shape), 'annotation'); continue; }
803
841
  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
842
  const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
@@ -834,7 +872,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
834
872
  }
835
873
 
836
874
  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;
875
+ const pictureRole = furniture.pictures.has(index) ? 'furniture' : nativeContext.slideImagePictures?.has(index) ? 'slide-image' : nativeContext.watermarkPictures?.has(index) ? 'watermark' : nativeContext.logoPictures?.has(index) ? 'logo' : quotes.consumedPictures.has(picture) ? 'quote' : undefined;
838
876
  if (pictureRole) { roles.set(`pic:${index}`, pictureRole); continue; }
839
877
  const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
840
878
  const item = importPicture(entries, picture, slidePath, relationships, report);
@@ -916,15 +954,18 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
916
954
  : chartFromRelationship(entries, slidePath, relationships, chartRelId, report);
917
955
  const stored = imported && record('chart');
918
956
  const part = relationships.get(chartRelId)?.path;
919
- const chart = stored && part && entries[part]
957
+ const restored = stored && part && entries[part]
920
958
  ? restore('chart', imported, () => restoreChartData(imported, stored, chartEvidence(decodeText(entries[part])), {datasets: data.datasets, report: diagnostic => data.report?.('chart', diagnostic)}))
921
959
  : imported;
960
+ const chart = restored?.type === 'combo' ? elideComboDefault(restored, data.datasets) : restored;
961
+ // FA-09: the frame's descr (or PowerPoint's decorative marker) is the chart's text alternative.
962
+ const alt = chart ? readFrameAlt(frame["p:nvGraphicFramePr"]?.["p:cNvPr"]) : undefined;
922
963
  return {
923
964
  kind: "chart",
924
965
  bounds,
925
966
  name,
926
967
  payload: chart
927
- ? { type: "chart", chart }
968
+ ? { type: "chart", chart: alt === undefined ? chart : { ...chart, alt } }
928
969
  : { type: "text", text: `PowerPoint chart: ${name || chartRelId}` }
929
970
  };
930
971
  }
@@ -1211,9 +1252,12 @@ function chartFromRelationship(entries, slidePath, relationships, relId, report)
1211
1252
  const plotArea = doc["c:chartSpace"]?.["c:chart"]?.["c:plotArea"];
1212
1253
  if (!plotArea) return null;
1213
1254
 
1214
- const chartNode = firstChartNode(plotArea);
1255
+ // FA-15: a clustered column group with one or two line groups is a combo chart; its series are read in c:order.
1256
+ const combo = comboFromNative(plotArea);
1257
+ const comboSeries = combo ? comboSeriesOf(combo) : undefined;
1258
+ const chartNode = combo ? {node: combo.bar, type: 'combo'} : firstChartNode(plotArea);
1215
1259
  if (!chartNode) return null;
1216
- const series = asArray(chartNode.node["c:ser"]);
1260
+ const series = comboSeries ? comboSeries.map(entry => entry.ser) : asArray(chartNode.node["c:ser"]);
1217
1261
  if (series.length === 0) return null;
1218
1262
  if (series.length > MAX_CHART_CACHE_POINTS) {
1219
1263
  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 +1270,8 @@ function chartFromRelationship(entries, slidePath, relationships, relId, report)
1226
1270
  const xCode = chartNode.type === 'scatter' ? cacheFormatCode(series[0]?.["c:xVal"]) : undefined;
1227
1271
  // RR-35: axis titles, legend position and data labels read back into the chart's option fields.
1228
1272
  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});
1273
+ 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,
1274
+ ...(combo ? {combo: {lineNode: combo.lines[0]?.node, secondaryValueAxis: secondaryValueAxisOf(plotArea, combo)}} : {})});
1230
1275
  for (const note of notes) report?.(note);
1231
1276
  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
1277
  return chart && Object.keys(options).length ? {...chart, ...options} : chart;
@@ -1257,10 +1302,57 @@ function chartFromRelationship(entries, slidePath, relationships, relId, report)
1257
1302
  data: {
1258
1303
  columns: formattedColumns([readChartCategoryHeading(entries,relationship.path) ?? "Category", ...names], [undefined, ...valueCodes], report),
1259
1304
  rows
1260
- }
1305
+ },
1306
+ ...(comboSeries ? comboFields(comboSeries, names, report) : {})
1261
1307
  });
1262
1308
  }
1263
1309
 
1310
+ // FA-15: the series of a combo plot area with their role and axis, in c:order (document order breaks ties).
1311
+ function comboSeriesOf(combo) {
1312
+ const entries = [
1313
+ ...asArray(combo.bar["c:ser"]).map(ser => ({ser, role: 'bar', secondary: false})),
1314
+ ...combo.lines.flatMap(group => asArray(group.node["c:ser"]).map(ser => ({ser, role: 'line', secondary: group.secondary}))),
1315
+ ];
1316
+ const order = (entry, index) => {
1317
+ const value = Number(entry.ser?.["c:order"]?.val);
1318
+ return Number.isFinite(value) ? value : index;
1319
+ };
1320
+ return entries.map((entry, index) => ({...entry, key: order(entry, index), index})).sort((a, b) => a.key - b.key || a.index - b.index);
1321
+ }
1322
+
1323
+ // The c:valAx a secondary line group plots against (its axis that the column group does not use), or undefined.
1324
+ function secondaryValueAxisOf(plotArea, combo) {
1325
+ const group = combo.lines.find(entry => entry.secondary);
1326
+ if (!group) return undefined;
1327
+ const ids = new Set(asArray(group.node["c:axId"]).map(axis => String(axis?.val)));
1328
+ return asArray(plotArea["c:valAx"]).find(axis => ids.has(String(axis?.["c:axId"]?.val)));
1329
+ }
1330
+
1331
+ // `line` and `secondaryAxis` of an imported combo chart by series name (elideComboDefault drops a `line` that is the default).
1332
+ // Combo lines always draw markers in OPF; a native line without markers is reported, never silently changed.
1333
+ function comboFields(comboSeries, names, report) {
1334
+ const line = names.filter((_, index) => comboSeries[index].role === 'line');
1335
+ const secondaryAxis = names.filter((_, index) => comboSeries[index].secondary);
1336
+ const unmarked = names.filter((_, index) => comboSeries[index].role === 'line' && comboSeries[index].ser?.["c:marker"]?.["c:symbol"]?.val === 'none');
1337
+ 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.`});
1338
+ if (new Set(line).size !== line.length || new Set(names).size !== names.length) {
1339
+ 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).'});
1340
+ return {};
1341
+ }
1342
+ return {...(line.length ? {line} : {}), ...(secondaryAxis.length ? {secondaryAxis} : {})};
1343
+ }
1344
+
1345
+ // An imported combo chart writes `line` only when it differs from the default (the last plotted series), judged on the final
1346
+ // data (a restored data record can order the columns differently from the native series).
1347
+ function elideComboDefault(chart, datasets) {
1348
+ if (!Array.isArray(chart.line)) return chart;
1349
+ const {line, ...rest} = chart;
1350
+ const resolved = resolveChartData(rest, {datasets: datasets ?? {}});
1351
+ if (!resolved.ok || !Array.isArray(resolved.combo)) return chart;
1352
+ const defaults = resolved.columns.slice(1).filter((_, index) => resolved.combo[index]?.role === 'line');
1353
+ return defaults.length === line.length && defaults.every(name => line.includes(name)) ? rest : chart;
1354
+ }
1355
+
1264
1356
  // The format code of a numeric cache (c:numRef/c:numCache or c:numLit), or undefined.
1265
1357
  function cacheFormatCode(node) {
1266
1358
  const cache = node?.["c:numRef"]?.["c:numCache"] ?? node?.["c:numLit"];
@@ -1505,60 +1597,63 @@ function parseInput(input) {
1505
1597
  // required variable is refused. Decks without content variables are returned untouched. The package stores the resolved
1506
1598
  // deck, not the template form, so fromPptx returns the filled deck.
1507
1599
  function resolveTemplateInput(presentation, options) {
1508
- if (typeof opfCore.resolveVariables !== "function" || !isPlainObject(presentation)) return presentation;
1600
+ if (!isPlainObject(presentation)) return presentation;
1509
1601
  const values = options.variables;
1510
1602
  if (values !== undefined && !isPlainObject(values)) {
1511
1603
  throw new OPFPptxError("invalid-variables", "The variables option must be an object keyed by variable id.", {path: "options.variables"});
1512
1604
  }
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});
1605
+ const template = isTemplate(presentation);
1606
+ if (!template && !hasContentVariables(presentation) && !(values && Object.keys(values).length)) return presentation;
1607
+ const result = resolveVariables(presentation, values ?? {}, {examples: template});
1516
1608
  const errors = result.diagnostics.filter(entry => entry.severity === "error");
1517
1609
  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});
1610
+ throw new OPFPptxError(errors.some(entry => entry.code === "variable-unfilled") ? "unfilled-variables" : "invalid-variables", errors[0].message, {findings: errors, path: errors[0].path});
1519
1611
  }
1520
1612
  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});
1613
+ 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
1614
  }
1523
1615
  return result.presentation;
1524
1616
  }
1525
1617
 
1526
1618
  function assertValidBoundary(presentation) {
1527
- const result = validatePresentation(presentation);
1528
- if (!result.valid) {
1619
+ const report = checkFormat(presentation);
1620
+ if (!report.valid) {
1529
1621
  throw new OPFPptxError("invalid-opf", "OPF validation failed.", {
1530
- issues: result.errors,
1531
- result
1622
+ findings: report.findings.filter(finding => finding.severity === "error"),
1623
+ report
1532
1624
  });
1533
1625
  }
1534
1626
  }
1535
1627
 
1536
- function resolvePresentationContext(presentation, options) {
1628
+ // RR-55: what a slide (or, for the deck level, an empty slide) resolves to in core, the one chain every engine shares: canvas,
1629
+ // layout, font families, alignment and content box as composeSlide's options, plus the theme, colour scheme and font scheme
1630
+ // records they came from. `diagnostics` names references that matched no record.
1631
+ function coreContext(presentation, index, options) {
1632
+ return resolveCoreSlideContext(presentation, index, {fonts: options.textMeasurement ? {textMeasurement: options.textMeasurement} : undefined, date: options.date});
1633
+ }
1634
+
1635
+ // The families composition and the package use: core's, with the measurement provider's own resolution of the chosen family
1636
+ // (FF-31) applied to the three roles the exporter writes.
1637
+ function exportFonts(core, textMeasurement) {
1638
+ const {fontFamilies} = core.options, scheme = core.resolved.fontScheme;
1639
+ const fonts = {id: scheme.id, ...fontFamilies, scheme: {type: scheme.type, major: scheme.major, minor: scheme.minor}};
1640
+ for (const role of ["heading", "body", "code"]) fonts[role] = resolveTextStyle({fontFamily: fonts[role], fontWeight: role === "heading" ? 700 : 400}, textMeasurement).fontFamily;
1641
+ return fonts;
1642
+ }
1643
+
1644
+ function resolvePresentationContext(presentation, options, core = coreContext({...presentation, slides: [{}]}, 0, {...options, textMeasurement: undefined})) {
1537
1645
  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);
1646
+ // `core` defaults to the deck level: the resolution of a slide that sets nothing of its own.
1647
+ // The theme and colour scheme records come from core, which falls back to the default records (reporting unresolved-theme and
1648
+ // unresolved-color-scheme) for a reference that matches no record.
1649
+ const {theme, colorScheme} = core.resolved;
1650
+ const dimensions = {widthInches: core.options.width / 96, heightInches: core.options.height / 96};
1556
1651
  const variables = resolveVariableColors(presentation.variables);
1557
1652
  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);
1653
+ const fonts = exportFonts(core, options.textMeasurement);
1654
+ // One resolution for every color role (core resolveColorRoles), shared with the SVG preview and the audit.
1655
+ const roles = resolveColorRoles(colorScheme, {background: `#${background}`});
1656
+ const textColor = normalizeHex(roles.text);
1562
1657
 
1563
1658
  return {
1564
1659
  seed: Number.isInteger(options.seed) ? options.seed : DEFAULT_SEED,
@@ -1568,16 +1663,19 @@ function resolvePresentationContext(presentation, options) {
1568
1663
  compressionLevel: Number.isInteger(options.compressionLevel) ? options.compressionLevel : 6,
1569
1664
  layoutName: "OPF_CANVAS",
1570
1665
  dimensions,
1666
+ theme,
1571
1667
  colorScheme,
1572
1668
  backgroundDefinition: design.background ?? theme?.background,
1573
1669
  fonts,
1574
- unresolvedFontScheme,
1670
+ // The color roles a ColorRef names (primary, secondary, accent, background, surface, text, textSecondary, hyperlink).
1671
+ roles,
1575
1672
  colors: {
1576
1673
  background,
1577
1674
  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"),
1675
+ mutedText: normalizeHex(roles.textSecondary),
1676
+ // The primary color, which the exporter draws accent fills (table headers, tag, media badge) in. The `accent` ColorRef role is roles.accent.
1677
+ accent: normalizeHex(roles.primary),
1678
+ surface: normalizeHex(roles.surface),
1581
1679
  border: normalizeHex(colorScheme.accent5 ?? "#CBD5E1")
1582
1680
  },
1583
1681
  variables
@@ -1618,11 +1716,31 @@ function contrastRatio(first, second) {
1618
1716
  const a = relativeLuminance(first), b = relativeLuminance(second);
1619
1717
  return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
1620
1718
  }
1719
+ // The tag's color as a literal (the one tagColor writes, resolved).
1720
+ function tagHex(context) {
1721
+ return contrastRatio(context.colors.accent, context.colors.background) < TAG_MIN_CONTRAST ? context.colors.text : context.colors.accent;
1722
+ }
1723
+
1724
+ // The defaults a rich heading or quote body is exported with on every run (weight, color, size, family). The tag records them so that
1725
+ // import can tell the heading's own look from run formatting the author chose.
1726
+ function richBase(fit, style, color) {
1727
+ const font = nativeFontOptions(style);
1728
+ return {bold: font.bold === true, color: `#${normalizeHex(color)}`, fontSize: Math.round(fit.fontSize * .75 * 100) / 100, fontFamily: font.fontFace};
1729
+ }
1730
+
1621
1731
  function tagColor(context) {
1622
1732
  if (contrastRatio(context.colors.accent, context.colors.background) < TAG_MIN_CONTRAST) return context.textColor;
1623
1733
  return nativeColor('primary', context.colors.accent, context);
1624
1734
  }
1625
1735
 
1736
+ // A link run with no color of its own is written in the theme hyperlink color (a:schemeClr hlink where the deck theme
1737
+ // holds that exact color, else the literal), underlined by PowerPoint, as the preview draws it. A run color is kept.
1738
+ function linkColor(run, color, context) {
1739
+ const link = typeof run?.link === 'string' && /^(https?:|mailto:|tel:)/i.test(run.link);
1740
+ if (!link || !(run.color === undefined || run.color === null || run.color === '')) return color;
1741
+ return nativeColor('hyperlink', normalizeHex(context.roles.hyperlink), context);
1742
+ }
1743
+
1626
1744
  // FF-24c: two literals no document color uses stand in for hlink and folHlink
1627
1745
  // (see nativeColor). When the document uses every candidate, those colors stay literal.
1628
1746
  const LINK_SENTINELS = [['FE01A0', 'FE01A1'], ['FE02B0', 'FE02B1'], ['FE03C0', 'FE03C1']];
@@ -1662,7 +1780,7 @@ function masterBackground(context) {
1662
1780
  function exportTheme(presentation, context) {
1663
1781
  const reference = presentation.design?.theme;
1664
1782
  const id = referenceId(reference);
1665
- const theme = id ? resolveDesignRecord(presentation, "themes", reference, DEFAULTS.theme) : null;
1783
+ const theme = id ? context.theme : null;
1666
1784
  const scheme = context.colorScheme;
1667
1785
  return {
1668
1786
  themeColors: themeSlotColors(scheme),
@@ -1719,7 +1837,19 @@ function configurePresentation(pptx, presentation, context) {
1719
1837
 
1720
1838
  async function addSlide(pptx, presentation, opfSlide, slideIndex, context, options) {
1721
1839
  const slide = pptx.addSlide();
1722
- const slideContext = resolveSlideContext(presentation, opfSlide, context, options, slideIndex);
1840
+ const slideContext = exportSlideContext(presentation, slideIndex, context, options);
1841
+ const { widthInches, heightInches } = slideContext.dimensions;
1842
+ // A slide with no layout, or one that names a layout no catalog defines, is composed with no layout record (automatic composition),
1843
+ // as in the preview; core reports the unknown id as unresolved-layout.
1844
+ // Core's resolved options (canvas, layout, families, alignment, content box, darkBackground), with the exporter's own additions:
1845
+ // the families after the measurement provider's resolution of the chosen family, the raster padding and the host's social platform records.
1846
+ const composeOptions = { ...slideContext.core.options, fontFamilies: slideContext.composeFonts, textRasterPadding:options.textRasterPadding, socialPlatforms: socialPlatformRecords(presentation, options) };
1847
+ // Core resolves every shared design key once (slide design, deck design, then the layout record's design): the text alignment of
1848
+ // each item (item.alignment), and the imageFill that backgrounds and picture placements use (SlideComposition.design).
1849
+ const geometry = composeSlide(opfSlide, composeOptions);
1850
+ slideContext.imageFill = geometry.design.imageFill ?? "fit";
1851
+ // The default footer band (date left, text center, number right): where a footer added natively lands (RR-11).
1852
+ if (slideIndex === 0) context.defaultFooterOptions = composeOptions;
1723
1853
  slide.background = { color: slideContext.colors.background };
1724
1854
  const backgroundDefinition = slideContext.backgroundDefinition;
1725
1855
  const backgroundPath = `${opfSlide.design?.background !== undefined ? `slides.${slideIndex}.` : ''}design.background`;
@@ -1750,23 +1880,6 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1750
1880
  slide.color = slideContext.textColor;
1751
1881
  if (opfSlide.hidden === true) slide.hidden = true;
1752
1882
 
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
1883
  for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
1771
1884
  // The content topology (groups, regions, root form, block ids) with the leaf boxes this geometry draws.
1772
1885
  recordContentTopology(context.documentProvenance, opfSlide, slideIndex, geometry.items);
@@ -1783,14 +1896,14 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1783
1896
  const frame=item.frameBox;
1784
1897
  context.cardTags.set(`OPF card ${item.path}`,item.path);
1785
1898
  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}`;
1899
+ const surface=itemContext.colorScheme.surface??itemContext.colorScheme[itemContext.roles.dark?'dark2':'light2']??`#${itemContext.colors.surface}`;
1787
1900
  slide.addShape('roundRect',{x:frame.x/96,y:frame.y/96,w:frame.width/96,h:frame.height/96,
1788
1901
  rectRadius:8*Math.min(widthInches,heightInches)/720,
1789
1902
  fill:paint(surface),line:{...paint(itemContext.colorScheme.accent5??`#${itemContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
1790
1903
  }
1791
1904
  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)) {
1905
+ 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});
1906
+ } else if (["title", "subtitle", "tag"].includes(item.field) && !item.text?.richLines) {
1794
1907
  // Estimated-font headings use one native text box, which still needs a
1795
1908
  // role tag. Role recovery must not depend on outline measurement support.
1796
1909
  const objectName = `OPF heading ${item.path} line 0`;
@@ -1799,34 +1912,30 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1799
1912
  ...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),
1800
1913
  ...nativeFontOptions(item.textStyle),
1801
1914
  color: item.field === "tag" ? tagColor(itemContext) : itemContext.textColor,
1802
- align: physicalAlignment(alignmentFor(item), item.text.directions?.[0]),
1915
+ align: physicalAlignment(item.alignment, item.text.directions?.[0]),
1803
1916
  ...(item.text.directions?.[0] === 'rtl' ? {rtlMode: true} : {}),
1804
1917
  objectName,
1805
1918
  breakLine: false
1806
1919
  });
1807
1920
  } else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
1808
1921
  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
- }
1922
+ } else if (["text", "title", "subtitle", "tag"].includes(item.field) && item.text?.richLines) {
1923
+ // 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).
1924
+ const heading = item.field !== 'text';
1925
+ addRichFitLines(slide, item.text, item.box, itemContext, {
1926
+ value: item.value, alignment: item.text.placement?.alignment ?? item.alignment ?? 'left',
1927
+ fallback: item.field === 'tag' ? tagColor(itemContext) : itemContext.textColor,
1928
+ ...(heading ? {
1929
+ objectName: index => `OPF heading ${item.path} line ${index}`,
1930
+ 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)})
1931
+ } : {})
1932
+ });
1824
1933
  } 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])});
1934
+ 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])});
1826
1935
  } else {
1827
1936
  // 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
1937
  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);
1938
+ 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
1939
  if (item.caption) {
1831
1940
  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));
1832
1941
  addCaption(slide, item, mediaName, itemContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: item.caption.path}));
@@ -1847,24 +1956,24 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1847
1956
  }
1848
1957
  }
1849
1958
 
1850
- function resolveSlideContext(presentation, slide, baseContext, options, slideIndex = 0) {
1959
+ // One slide's export context: core's resolveSlideContext (`core`: composeSlide's options, the records and the diagnostics) and
1960
+ // the exporter's colours and measurement-resolved families derived from it.
1961
+ function exportSlideContext(presentation, slideIndex, baseContext, options) {
1962
+ const slide = presentation.slides[slideIndex];
1851
1963
  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
- }
1964
+ const core = coreContext(presentation, slideIndex, options);
1965
+ const resolved = resolvePresentationContext(effective, options, core);
1966
+ // A reference that matched no record is reported once per place it is written.
1967
+ for (const diagnostic of core.diagnostics) {
1968
+ if (baseContext.reportedReferences?.has(diagnostic.path)) continue;
1969
+ baseContext.reportedReferences?.add(diagnostic.path);
1970
+ options.onDiagnostic?.(diagnostic);
1862
1971
  }
1863
1972
  if (Math.abs(resolved.dimensions.widthInches - baseContext.dimensions.widthInches) > 1e-6
1864
1973
  || Math.abs(resolved.dimensions.heightInches - baseContext.dimensions.heightInches) > 1e-6) {
1865
1974
  throw new OPFPptxError("mixed-slide-dimensions", "PowerPoint requires one canvas size per presentation. Set dimensions on the deck or export this slide separately.");
1866
1975
  }
1867
- const slideContext = { ...baseContext, backgroundDefinition: resolved.backgroundDefinition, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors, variables: resolved.variables, imageFill: effective.design.imageFill ?? "fit" };
1976
+ 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
1977
  // A slide-level color scheme (directly or through a slide theme) pins every
1869
1978
  // named color on that slide to literal sRGB; see schemeColorValue().
1870
1979
  const slideScheme = themeSlotColors(resolved.colorScheme), deckScheme = baseContext.themeColors ?? {};
@@ -1909,7 +2018,7 @@ async function addPayload(slide, presentation, payload, region, path, context, o
1909
2018
  addMetricPayload(slide, payload.metric,metricLayout,context,path,options);
1910
2019
  break;
1911
2020
  case "quote":
1912
- addQuotePayload(slide, payload.quote, quoteLayout, context, options, path);
2021
+ await addQuotePayload(slide, presentation, payload.quote, quoteLayout, context, options, path);
1913
2022
  break;
1914
2023
  case "timeline":
1915
2024
  addTimelinePayload(slide, payload.timeline, timelineLayout, context, options, path);
@@ -1946,7 +2055,7 @@ function richLineRuns(line,color,native,context) {
1946
2055
  const fallback=color.replace(/^#/,'');
1947
2056
  return line.fragments.map(fragment=>{
1948
2057
  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}};
2058
+ 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
2059
  });
1951
2060
  }
1952
2061
  // Every native line of a list, marker paragraph or not, is named for the list's
@@ -2076,13 +2185,14 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
2076
2185
  const watermark = local ? opfSlide.design.watermark : presentation.design?.watermark;
2077
2186
  if (watermark === undefined || watermark === null || watermark === false) return;
2078
2187
  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.');
2188
+ // The schema requires exactly one of src and text, so there is no source-less watermark to report.
2189
+ const asset = watermark;
2190
+ const {widthInches, heightInches} = slideContext.dimensions;
2191
+ // FA-13: a text watermark is one native text box with the opacity as text alpha, centered and rotated as core's layoutWatermark says.
2192
+ if (isPlainObject(asset) && typeof asset.text === 'string') {
2193
+ addTextWatermark(slide, asset, slideIndex, {width: widthInches * 96, height: heightInches * 96}, slideContext, context, options, path);
2083
2194
  return;
2084
2195
  }
2085
- const {widthInches, heightInches} = slideContext.dimensions;
2086
2196
  const box = watermarkBox(widthInches, heightInches);
2087
2197
  const outcome = {box};
2088
2198
  const resolved = await resolveImage(asset, presentation, options, path, outcome);
@@ -2097,6 +2207,23 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
2097
2207
  slide.addImage({...resolved, objectName: watermarkName(), ...box, altText: assetAlt(asset, presentation) ?? 'Watermark'});
2098
2208
  }
2099
2209
 
2210
+ // The stamp takes the slide's own resolved fonts and default text color (readable on that slide's background), as the preview does.
2211
+ function addTextWatermark(slide, watermark, slideIndex, size, slideContext, context, options, path) {
2212
+ const layout = layoutWatermark(watermark.text, size, {fontFamily: slideContext.fonts.heading, fontWeight: 700, textMeasurement: options.textMeasurement});
2213
+ // The schema requires text, so this is null only for text that draws nothing (the preview draws nothing either).
2214
+ if (!layout) return;
2215
+ const opacity = watermarkOpacity(watermark);
2216
+ const {box} = layout;
2217
+ slide.addText(layout.text, {
2218
+ ...textBoxOptions({x: box.x / 96, y: box.y / 96, w: box.width / 96, h: box.height / 96}, slideContext, layout.fontSize * .75),
2219
+ ...nativeFontOptions(layout.style), bold: true,
2220
+ color: slideContext.textColor, transparency: Math.round((1 - opacity) * 100000) / 1000,
2221
+ align: 'center', valign: 'middle', fit: 'none', wrap: false, lineSpacingMultiple: 1,
2222
+ rotate: (layout.rotation + 360) % 360, objectName: watermarkTextName()
2223
+ });
2224
+ context.textWatermarks.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, text: layout.text, opacity});
2225
+ }
2226
+
2100
2227
  // The deck logo (design.logo, a slide's own logo or the primary organization's) on a cover or section slide: one native
2101
2228
  // picture per slide at core's geometry.logo box, fitted without cropping, anchored left and vertically centered, after
2102
2229
  // the watermark and before content (the preview's paint order). The frame and the provenance tag are written after
@@ -2202,7 +2329,7 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2202
2329
 
2203
2330
  // Keep the resolved palette surface (including alpha) explicit in native
2204
2331
  // 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}`;
2332
+ const panelFill = context.colorScheme.surface ?? context.colorScheme[context.roles.dark ? 'dark2' : 'light2'] ?? `#${context.colors.surface}`;
2206
2333
  const labelColor = normalizeHex(textColorForFill(panelFill, `#${context.colors.text}`));
2207
2334
  const transparency = /^#[0-9a-f]{8}$/i.test(panelFill) ? (1 - parseInt(panelFill.slice(7), 16) / 255) * 100 : 0;
2208
2335
  const fill = {color:normalizeHex(panelFill),transparency};
@@ -2211,15 +2338,25 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2211
2338
  const textSize = chartTextSize(context);
2212
2339
  // RR-54: the columns' number formats (Excel codes per exported series) for the caches, labels, value axis and workbook.
2213
2340
  // The value axis shows the first plotted series' format (General when it has none), as the preview does.
2341
+ // A combo chart's secondary value axis shows its first series' format (FA-15).
2342
+ const secondaryIndex = chartData.combo?.findIndex((entry) => entry.axis === 'secondary') ?? -1;
2214
2343
  const numberFormats = chartData.formats && {...chartData.formats, axis: chartData.spec.grouping === 'percentStacked' ? undefined : chartData.formats.series[0],
2344
+ ...(secondaryIndex >= 0 ? {secondaryAxis: chartData.formats.series[secondaryIndex]} : {}),
2215
2345
  labels: !chartOptions?.dataLabels?.content?.includes('percent'), scatter: chartData.type === 'scatter'};
2216
2346
  context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading,labelColor,spec:chartData.spec,textSize,
2217
2347
  options:chartOptions,kind:chartTargetFor(chart.type)?.kind,pointCount:chartData.pointCount,numberFormats});
2218
2348
  recordPayloadData(context, objectName, 'chart', presentation, path);
2219
2349
  context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
2350
+ // 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.
2351
+ if (typeof chart.alt === 'string') context.chartAlts.set(objectName, chart.alt);
2220
2352
  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);
2353
+ const palette = chartPaletteForFill(panelFill, preferredPalette).map(color=>normalizeHex(color));
2354
+ // FA-14: chart.highlight. A highlighted chart writes accent and muted colours over the series palette, and its data labels
2355
+ // contrast with those colours (src/chart-highlight.js).
2356
+ const highlight = chartData.chartex ? undefined : chartHighlightPlan({options: chartOptions, data: chartData.resolved, kind: chartTargetFor(chart.type)?.kind, panelFill, labelColor, primary: `#${context.colors.accent}`,
2357
+ schemeFor: hex => schemeColorValue('primary', hex, context)});
2358
+ context.chartPalettes.set(objectName,highlight ? highlight.seriesFills : palette);
2359
+ if (highlight) context.chartHeadings.get(objectName).highlight = highlight;
2223
2360
  if (chartData.chartex) {
2224
2361
  // The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
2225
2362
  // PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
@@ -2235,7 +2372,7 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2235
2372
  // bar chart with one series whenever chartColors is a custom array of more than one colour, so PowerPoint drew each
2236
2373
  // column of a single-series column or bar chart in a different colour. One colour for that series writes no c:dPt.
2237
2374
  const chartColors = chartData.type === 'bar' && chartData.series.length === 1 ? palette.slice(0, 1) : palette;
2238
- slide.addChart(chartData.type, chartData.series, {
2375
+ const chartOptionsXml = {
2239
2376
  objectName,
2240
2377
  x: region.x,
2241
2378
  y: region.y,
@@ -2271,7 +2408,37 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2271
2408
  ...(chartData.spec.radarStyle ? { radarStyle: chartData.spec.radarStyle } : {}),
2272
2409
  // ScatterWithMarkers: markers only, no connecting line.
2273
2410
  ...(chartData.type === 'scatter' ? { lineSize: 0, lineDataSymbol: 'circle' } : {})
2274
- });
2411
+ };
2412
+ if (chartData.combo?.length && chartData.combo.length === chartData.series.length) {
2413
+ slide.addChart(...comboChart(chartData, chartOptionsXml, {palette, chartOptions, labelColor, font: context.fonts.body, textSize}));
2414
+ return;
2415
+ }
2416
+ slide.addChart(chartData.type, chartData.series, chartOptionsXml);
2417
+ }
2418
+
2419
+ // FA-15: a combo chart is one PptxGenJS multi-type chart: a clustered column c:barChart on the primary axes, a c:lineChart with
2420
+ // markers for the primary-axis lines, and one for the secondary-axis lines on a second c:valAx (at the right, crossing at the
2421
+ // maximum of a deleted second c:catAx). Each series keeps its palette colour by its index in the whole chart (PptxGenJS restarts
2422
+ // the colour cycle per chart type), and the embedded workbook holds every series. The secondary axis has no gridlines and only
2423
+ // its own title; applyComboConstruct puts the line groups into schema order afterwards.
2424
+ function comboChart(chartData, options, {palette, chartOptions, labelColor, font, textSize}) {
2425
+ const series = chartData.series.map((entry, index) => ({...entry, color: palette[index % palette.length]}));
2426
+ const part = (role, axis) => series.filter((_, index) => chartData.combo[index].role === role && (role === 'bar' || chartData.combo[index].axis === axis));
2427
+ const bars = part('bar'), primaryLines = part('line', 'primary'), secondaryLines = part('line', 'secondary');
2428
+ const lineOptions = {lineDataSymbol: 'circle'};
2429
+ const types = [
2430
+ // The column group's own colour list: with one column series, a longer list would make PptxGenJS colour each column (c:dPt, RR-36).
2431
+ ...(bars.length ? [{type: 'bar', data: bars, options: {barDir: 'col', barGrouping: 'clustered', chartColors: bars.map((entry) => entry.color)}}] : []),
2432
+ ...(primaryLines.length ? [{type: 'line', data: primaryLines, options: lineOptions}] : []),
2433
+ ...(secondaryLines.length ? [{type: 'line', data: secondaryLines, options: {...lineOptions, secondaryValAxis: true, secondaryCatAxis: true}}] : []),
2434
+ ];
2435
+ // A gap in a line series breaks the line, as the preview draws it (PptxGenJS defaults to span).
2436
+ const {barGrouping: _grouping, ...rest} = options;
2437
+ const shared = {...rest, displayBlanksAs: 'gap'};
2438
+ if (!secondaryLines.length) return [types, {...shared, barDir: 'col'}];
2439
+ const title = chartOptions?.axisTitles?.secondary;
2440
+ const secondaryValue = {valGridLine: {style: 'none'}, showValAxisTitle: Boolean(title), ...(title ? {valAxisTitle: title, valAxisTitleColor: labelColor, valAxisTitleFontFace: font, valAxisTitleFontSize: textSize / 100} : {})};
2441
+ return [types, {...shared, barDir: 'col', valAxes: [{}, secondaryValue], catAxes: [{}, {catAxisHidden: true, showCatAxisTitle: false}]}];
2275
2442
  }
2276
2443
 
2277
2444
  // RR-54: a chart or table that uses a dataset, DataColumn objects, a mapping, a data source or number formats records its
@@ -2330,9 +2497,9 @@ function addTablePayload(slide, authoredTable, region, context, options, path, p
2330
2497
  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
2498
  const runOptions = {
2332
2499
  ...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,
2500
+ underline:run.underline ? {style:'sng',color:linkColor(run,color,context)} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
2501
+ color:linkColor(run,color,context),transparency,baseline:fragment?.baselineShift ? -fragment.baselineShift / fragment.fontSize * 2000 : undefined,
2502
+ hyperlink:run.link && /^(https?:|mailto:|tel:)/i.test(run.link) ? {url:run.link} : undefined,
2336
2503
  };
2337
2504
  // PptxGenJS marks every part of a newline-containing run as a paragraph
2338
2505
  // break, including its final part. Split explicitly so the next styled
@@ -2394,6 +2561,39 @@ function pixelBox(region) {
2394
2561
  return {x: region.x * 96, y: region.y * 96, width: region.w * 96, height: region.h * 96};
2395
2562
  }
2396
2563
 
2564
+ // One native text box per laid-out rich line (core's richLines), each carrying its runs: run color, size, weight, underline, strike,
2565
+ // super/subscript (a citation marker is a baseline run) and http(s)/mailto links. The body's lines stay untagged; a heading or quote
2566
+ // body passes `objectName` and `tag`, which name and tag every line with the boundary between wrapped lines (a soft wrap inserts
2567
+ // nothing, a hard line break keeps its separator), recovered from the flattened text. `fallback` is the color of runs without one.
2568
+ function addRichFitLines(slide, fit, box, context, {value, alignment: logicalAlignment, fallback, objectName, tag}) {
2569
+ const region = {x: box.x / 96, y: box.y / 96, w: box.width / 96, h: box.height / 96};
2570
+ const runText = run => typeof run === 'string' ? run : run.text;
2571
+ const whole = typeof value === 'string' ? value : value.map(runText).join('');
2572
+ const count = fit.richLines.length;
2573
+ let cursor = 0;
2574
+ for (const [index, line] of fit.richLines.entries()) {
2575
+ const runs = line.fragments.map(fragment => {
2576
+ const runColor = exportColor(fragment.run.color, context, context.colors.text);
2577
+ const color = nativeColor(fragment.run.color, runColor, context, fallback);
2578
+ 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)}};
2579
+ });
2580
+ // Logical alignment (RR-05): a right-to-left paragraph starts at the right edge; every wrapped line shares its paragraph's direction.
2581
+ const placed = fit.placement?.lines[index], alignment = placed?.alignment ?? physicalAlignment(logicalAlignment, fit.directions?.[index]), factor = alignment === 'right' ? 1 : alignment === 'center' ? .5 : 0;
2582
+ 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};
2583
+ // PptxGenJS reads the paragraph direction from the first run's options, not from the shape options.
2584
+ if (fit.directions?.[index] === 'rtl') for (const run of runs) run.options.rtlMode = true;
2585
+ let name;
2586
+ if (tag) {
2587
+ const text = fit.lines[index] ?? '', end = cursor + text.length;
2588
+ const newline = index < count - 1 && whole.startsWith(text, cursor) ? /^(\r\n|\r|\n)/.exec(whole.slice(end)) : null;
2589
+ name = objectName(index);
2590
+ tag(name, index, count, {boundary: index === count - 1 ? 'end' : newline ? 'hard' : 'soft', separator: newline ? newline[0] : ''});
2591
+ cursor = end + (newline ? newline[0].length : 0);
2592
+ }
2593
+ if (runs.length) slide.addText(runs, {...textBoxOptions(area, context, fit.fontSize * .75), align: alignment, fit: 'none', wrap: false, lineSpacingMultiple: 1, ...(name ? {objectName: name} : {})});
2594
+ }
2595
+ }
2596
+
2397
2597
  // Match the published renderer's payload geometry and shared core text fitting.
2398
2598
  // Each fitted line remains native editable text, without PowerPoint rewrapping it.
2399
2599
  function addMeasuredPayloadText(slide, text, box, context, options, config) {
@@ -2405,7 +2605,7 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
2405
2605
  if (fit.overflow && !config.diagnosticsHandled) {
2406
2606
  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
2607
  options.onDiagnostic?.(diagnostic);
2408
- if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path, issues: [diagnostic]});
2608
+ if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path, diagnostics: [diagnostic]});
2409
2609
  }
2410
2610
  // Generated socials carry one link per explicit source line; wrapped lines share it.
2411
2611
  let sourceLineIndex = 0;
@@ -2511,52 +2711,76 @@ function addCodePayload(slide, value, layout, region, context, path, options) {
2511
2711
  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
2712
  }
2513
2713
  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));
2714
+ 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});
2715
+ // FA-13: code.highlight is one native rectangle per run of marked lines, between the panel and the line text boxes; marked
2716
+ // lines keep the syntax colours, the others are dimmed, from the same core helpers the preview calls.
2717
+ const highlight = codeHighlight(value, layout, context);
2718
+ context.codeTags.set(panelName,codeManifest(value,layout,group,highlight?.bands.length));
2516
2719
  const syntax = codeSyntax(value, layout, context, path, options);
2517
2720
  slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', width: .75}, objectName:panelName});
2721
+ if (highlight) {
2722
+ const bodyPart = layout.parts.find(part => part.role === 'body');
2723
+ highlight.bands.forEach((band, bandIndex) => {
2724
+ const lineHeight = bodyPart.fit.lineHeight;
2725
+ const bandName = `OPF code ${group} highlight ${bandIndex + 1}`;
2726
+ context.codeTags.set(bandName, {v:1, group, role:'highlight', index:bandIndex});
2727
+ 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,
2728
+ fill: {color: highlight.colors.band.slice(1)}, line: {type: 'none'}, objectName: bandName});
2729
+ });
2730
+ }
2518
2731
  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});
2732
+ 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
2733
  for (const [index,line] of part.fit.sourceLines.entries()) {
2521
2734
  const tabStops=line.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'}));
2522
2735
  const objectName = `OPF code ${group} ${part.role} line ${index+1}`;
2523
2736
  context.codeTags.set(objectName,{v:1,group,role:'line',part:partIndex,line:index});
2524
2737
  // 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;
2738
+ const runs = syntax && part.role==='body' ? codeLineRuns(syntax.tokens,line.start,line.end,part.text) : undefined;
2739
+ const lineColors = highlight && part.role==='body' ? (highlight.marked.has(highlight.numbers[index]) ? highlight.colors.lit : highlight.colors.dim) : undefined;
2526
2740
  const lineText = part.text.slice(line.start,line.end);
2527
2741
  const lineOptions = {
2528
2742
  ...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
2743
  ...nativeFontOptions(part.style),
2530
- color:part.role==='body'?'E5E7EB':'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
2744
+ color:part.role==='body'?(lineColors?.plain.slice(1)??'E5E7EB'):'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
2531
2745
  tabStops:tabStops.length?tabStops:undefined,objectName,
2532
2746
  };
2533
2747
  // 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);
2748
+ 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
2749
  }
2536
2750
  }
2537
2751
  }
2538
2752
 
2753
+ // FA-13: the marked lines of code.highlight, their bands (runs of displayed lines) and the lit/dimmed colours, from core (the preview
2754
+ // calls the same functions); undefined when the code marks no line.
2755
+ function codeHighlight(value, layout, context) {
2756
+ if (!value || typeof value !== 'object' || !Array.isArray(value.highlight)) return undefined;
2757
+ const body = layout.parts.find(part => part.role === 'body');
2758
+ if (!body?.fit) return undefined;
2759
+ const lines = codeHighlightLines(value.highlight, body.text).lines;
2760
+ if (!lines.length) return undefined;
2761
+ return {marked: new Set(lines), numbers: codeLineNumbers(body.fit.sourceLines), bands: codeHighlightBands(body.fit.sourceLines, lines), colors: codeHighlightColors(context.colorScheme)};
2762
+ }
2763
+
2539
2764
  // Token ranges and palette for the code body, from core (the preview calls the same functions); undefined for plain code.
2540
2765
  function codeSyntax(value, layout, context, path, options) {
2541
2766
  const body = layout.parts.find(part => part.role === 'body');
2542
2767
  const language = typeof value?.language === 'string' ? value.language : layout.parts.find(part => part.role === 'language')?.text;
2543
2768
  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;
2769
+ const tokens = tokenizeCode(body.text, language);
2770
+ return tokens.length ? {tokens, palette: codeSyntaxPaletteForScheme(context.colorScheme)} : undefined;
2547
2771
  }
2548
2772
 
2549
2773
  function addMetricPayload(slide,value,layout,context,path,options) {
2550
2774
  if (!layout) throw new OPFPptxError('missing-metric-layout','Metric export requires coordinated core metric geometry.',{path});
2551
2775
  const group=String(context.metricTags.size+1),manifest=metricManifest(value,layout,group);
2552
2776
  // 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;
2777
+ const trendMark=metricTrendMark(layout,{background:`#${normalizeHex(context.colors.background)}`});
2554
2778
  const partColor=part=>trendMark&&(part.role==='trend'||part.role==='delta')?trendMark.color.slice(1):part.role==='value'?context.colors.accent:context.textColor;
2555
2779
  for (const [partIndex,part] of layout.parts.entries()) {
2556
2780
  const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
2557
2781
  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
2782
  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});
2783
+ 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
2784
  for (const [index,line] of part.fit.sourceLines.entries()) {
2561
2785
  const origin=part.linePositions[index],objectName=`OPF metric ${group} ${part.role} line ${index+1}`;
2562
2786
  context.metricTags.set(objectName,partIndex===0&&index===0?manifest:{v:1,group,role:'line',part:partIndex,line:index});
@@ -2582,37 +2806,80 @@ function addMetricPayload(slide,value,layout,context,path,options) {
2582
2806
  }
2583
2807
  }
2584
2808
 
2585
- function addQuotePayload(slide, value, layout, context, options, path) {
2809
+ async function addQuotePayload(slide, presentation, value, layout, context, options, path) {
2586
2810
  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});
2811
+ 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
2812
  // Every line shape is tagged (OPF_QUOTE_V1) so an unchanged export re-imports as a quote payload (FF-57).
2589
2813
  const group=String(context.quoteTags.size),anchor=quoteManifest(value,layout);
2590
2814
  for (const [index,part] of layout.parts.entries()) {
2815
+ // 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.
2816
+ if (part.runs && part.fit.richLines) {
2817
+ addRichFitLines(slide, part.fit, part.box, context, {
2818
+ value: part.text, alignment: part.fit.placement?.alignment ?? context.contentAlignment ?? 'left', fallback: context.textColor,
2819
+ objectName: line => `OPF quote ${group} part ${index} line ${line}`,
2820
+ 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} : {})})
2821
+ });
2822
+ continue;
2823
+ }
2591
2824
  addMeasuredPayloadText(slide,part.text,part.box,context,options,{
2592
2825
  path:part.path,fit:part.fit,textStyle:part.style,diagnosticsHandled:true,
2593
2826
  color:part.role==='footer'?context.mutedColor:context.textColor,
2594
2827
  quote:{group,part:index,anchor},
2595
2828
  });
2596
2829
  }
2830
+ if (layout.photo) await addQuotePhoto(slide, presentation, layout.photo, group, context, options);
2831
+ }
2832
+
2833
+ // 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
2834
+ // are embedded) with the `ellipse` preset geometry, the alt text as its description, tagged into the quote's provenance group.
2835
+ async function addQuotePhoto(slide, presentation, photo, group, context, options) {
2836
+ const box = { x: photo.box.x / 96, y: photo.box.y / 96, w: photo.box.width / 96, h: photo.box.height / 96 };
2837
+ const outcome = { box, cover: true };
2838
+ const resolved = await resolveImage(photo.value, presentation, options, photo.path, outcome);
2839
+ if (!resolved) {
2840
+ addImagePlaceholder(slide, presentation, photo.value, box, photo.path, context, options);
2841
+ return;
2842
+ }
2843
+ const objectName = quotePhotoName(group);
2844
+ // 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).
2845
+ 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.' });
2846
+ context.quotePhotos.set(objectName, { region: box, mode: 'crop', path: photo.path, shape: photo.shape });
2847
+ context.quoteTags.set(objectName, { v: 1, role: 'photo', group });
2848
+ const alt = assetAlt(photo.value, presentation);
2849
+ context.pictureText.set(objectName, pictureText(alt, photo.value, presentation));
2850
+ slide.addImage({ ...resolved, objectName, ...box, altText: alt });
2597
2851
  }
2598
2852
 
2599
2853
  function addTimelinePayload(slide, value, layout, context, options, path) {
2600
2854
  if(!layout)throw new OPFPptxError('missing-timeline-layout','Timeline export requires a coordinated core build with shared timeline geometry.',{path});
2601
2855
  // Validate optional field fits before the provenance manifest dereferences
2602
2856
  // 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});
2857
+ 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
2858
  const scale=Math.min(context.dimensions.widthInches,context.dimensions.heightInches)*96/720;
2605
2859
  const group=String(context.timelineTags.size),anchor=timelineManifest(value,layout),connectorName=`OPF timeline ${group} connector`;
2606
2860
  const {x1,y1,x2,y2}=layout.connector;
2607
2861
  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
2862
  context.timelineTags.set(connectorName,{v:1,group,role:'connector'});
2863
+ // FA-11: a status draws from the deck's colors through core's shared shapes: native ellipses (solid fill, or the
2864
+ // background fill plus a line for a hollow marker or a ring) and a muted text color for a planned event.
2865
+ const statusColors={background:`#${normalizeHex(context.colors.background)}`,primary:`#${context.colors.accent}`,text:`#${normalizeHex(context.colors.text)}`,mutedText:`#${context.colors.mutedText}`};
2609
2866
  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});
2867
+ if(!marker.status){
2868
+ const objectName=`OPF timeline ${group} marker ${marker.eventIndex}`;
2869
+ 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}});
2870
+ context.timelineTags.set(objectName,{v:1,group,role:'marker',eventIndex:marker.eventIndex});
2871
+ continue;
2872
+ }
2873
+ for(const shape of timelineMarkerShapes(marker,statusColors)){
2874
+ const objectName=`OPF timeline ${group} ${shape.role} ${marker.eventIndex}`;
2875
+ 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,
2876
+ fill:{color:normalizeHex(shape.fill)},line:shape.stroke?{color:normalizeHex(shape.stroke.color),width:shape.stroke.width*.75}:{transparency:100}});
2877
+ context.timelineTags.set(objectName,{v:1,group,role:shape.role,eventIndex:marker.eventIndex,...(shape.role==='marker'?{status:marker.status}:{})});
2878
+ }
2613
2879
  }
2614
2880
  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}});
2881
+ const statusColor=part.status==='planned'?normalizeHex(timelineTextColor(part,statusColors)):undefined;
2882
+ 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
2883
  }
2617
2884
  }
2618
2885
 
@@ -2777,6 +3044,12 @@ function chosenFamilyMeasurement(measurement) {
2777
3044
  return wrapped;
2778
3045
  }
2779
3046
 
3047
+ // FA-13: TextRun.lang as the run's own proofing language (a:rPr lang), when it differs from the deck language.
3048
+ function langOptions(context, tag) {
3049
+ const lang = runLanguageTag(context.scriptFonts, tag);
3050
+ return lang ? {lang} : {};
3051
+ }
3052
+
2780
3053
  function nativeFontOptions(style) {
2781
3054
  const face=style.fontFace;
2782
3055
  if(face!==undefined) {
@@ -2826,14 +3099,15 @@ function textRuns(value, context, fallbackFontSize) {
2826
3099
  options: {
2827
3100
  bold: run?.bold,
2828
3101
  italic: run?.italic,
2829
- underline: run?.underline ? { style: "sng", color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor) } : undefined,
3102
+ underline: run?.underline ? { style: "sng", color: linkColor(run, nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor), context) } : undefined,
2830
3103
  strike: run?.strikethrough ? "sngStrike" : undefined,
2831
- color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor),
3104
+ color: linkColor(run, nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor), context),
2832
3105
  fontFace: run?.fontFamily ?? context.fonts.body,
2833
3106
  fontSize: run?.fontSize ?? fallbackFontSize,
2834
3107
  superscript: run?.superscript,
2835
3108
  subscript: !run?.superscript && run?.subscript,
2836
- hyperlink: run?.link && /^(https?:|mailto:)/i.test(run.link) ? { url: run.link } : undefined
3109
+ hyperlink: run?.link && /^(https?:|mailto:|tel:)/i.test(run.link) ? { url: run.link } : undefined,
3110
+ ...langOptions(context, run?.lang)
2837
3111
  }
2838
3112
  };
2839
3113
  });
@@ -2860,7 +3134,7 @@ function toPptxChartData(chart, chartexMode = 'auto', presentation) {
2860
3134
  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
3135
  }
2862
3136
  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.");
3137
+ 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
3138
  }
2865
3139
  if (authored.rows.length === 0) return unplottable("no-rows", "The chart has no data rows.", "The chart data has no rows.");
2866
3140
  if (authored.columns.length === 0) return unplottable("no-columns", "The chart has no data columns.", "The chart data has no columns.");
@@ -2887,14 +3161,16 @@ function toPptxChartData(chart, chartexMode = 'auto', presentation) {
2887
3161
  : 'that PowerPoint has not yet accepted natively from this exporter (pass chartex: \'native\' to write its chartex part)';
2888
3162
  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
3163
  }
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};
3164
+ // FA-14: the resolved table (category, X, series columns), which chart.highlight names its series and categories in.
3165
+ 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,
3166
+ // FA-15: core's combo plan (column series, then the primary-axis lines, then the secondary-axis lines), aligned with the series.
3167
+ ...(spec.family === 'combo' ? {combo: comboPlanOf(resolvedData, data.columns.length - 1)} : {})};
2891
3168
  // Number formats only when a column has one: a chart without formats carries no `formats` and is written as before.
2892
3169
  const withFormats = (result, formats) => formats.series.some((code) => code !== undefined) || formats.x !== undefined ? {...result, formats} : result;
2893
3170
  if (data.columns.length === 1) {
2894
3171
  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.
3172
+ // The lone column holds the chart's values. Each cell is read with chartNumber and each non-numeric cell is counted
3173
+ // as rejected. A cell that holds no number is skipped, never plotted as 0.
2898
3174
  const points = data.rows.map((row, index) => ({row: index + 1, value: chartNumber(row?.[0]), cell: row?.[0]}));
2899
3175
  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
3176
  const plottedPoints = points.filter((point) => point.value !== null);
@@ -2964,6 +3240,12 @@ function toPptxChartData(chart, chartexMode = 'auto', presentation) {
2964
3240
  return withFormats({...mapped, series, hasCategories: true, adaptations, notNumeric}, formats);
2965
3241
  }
2966
3242
 
3243
+ /** 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). */
3244
+ function comboPlanOf(resolved, count) {
3245
+ if (Array.isArray(resolved.combo) && resolved.combo.length === count) return resolved.combo;
3246
+ return Array.from({length: count}, (_, index) => ({role: count > 1 && index === count - 1 ? 'line' : 'bar', axis: 'primary'}));
3247
+ }
3248
+
2967
3249
  /** A chart column's name: the string, or a DataColumn's `name`. */
2968
3250
  function dataColumnName(column) {
2969
3251
  return typeof column === 'string' ? column : column !== null && typeof column === 'object' && typeof column.name === 'string' ? column.name : undefined;
@@ -3193,23 +3475,6 @@ function assetAlt(asset, presentation) {
3193
3475
  return undefined;
3194
3476
  }
3195
3477
 
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
3478
  function referenceId(reference) {
3214
3479
  if (typeof reference === "string") return reference;
3215
3480
  if (isPlainObject(reference) && typeof reference.id === "string") return reference.id;
@@ -3249,15 +3514,6 @@ function findById(records, id) {
3249
3514
  return records.find((record) => record?.id === id) ?? null;
3250
3515
  }
3251
3516
 
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
3517
  // A solid color or pattern background color is a ColorRef (hex, `var:` variable, colour-scheme slot or role), resolved
3262
3518
  // as for table fills and run colours. Roles resolve through the colour scheme alone: the background cannot depend on itself.
3263
3519
  function resolveBackgroundColorRef(entry, colorScheme, variables = {}) {
@@ -3269,29 +3525,18 @@ function resolveBackground(value, colorScheme, variables = {}) {
3269
3525
  const reference = entry => normalizeHex(resolveBackgroundColorRef(entry, colorScheme, variables) ?? entry, fallback);
3270
3526
  if (typeof value === "string") {
3271
3527
  if (value.startsWith("#")) return normalizeHex(value, fallback);
3272
- return normalizeHex(colorScheme[value] ?? colorScheme.background ?? colorScheme.light1 ?? "#FFFFFF", fallback);
3528
+ return normalizeHex(colorScheme[value] ?? defaultSlideBackground(colorScheme), fallback);
3273
3529
  }
3274
3530
  if (isPlainObject(value)) {
3275
3531
  if (value.type === "solid" && value.color) return reference(value.color);
3276
3532
  if (value.type === "theme" && value.slot) {
3277
- return normalizeHex(colorScheme[value.slot] ?? colorScheme.light1 ?? "#FFFFFF", fallback);
3533
+ return normalizeHex(colorScheme[value.slot] ?? defaultSlideBackground(colorScheme), fallback);
3278
3534
  }
3279
3535
  // Like the SVG preview, text contrast follows a pattern's background color.
3280
3536
  if (value.type === "pattern") return reference(value.pattern?.backgroundColor ?? "#FFFFFF");
3281
3537
  if (value.backgroundColor) return normalizeHex(value.backgroundColor, fallback);
3282
3538
  }
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");
3539
+ return normalizeHex(defaultSlideBackground(colorScheme), fallback);
3295
3540
  }
3296
3541
 
3297
3542
  function normalizeHex(value, fallback = "000000") {
@@ -3306,15 +3551,6 @@ function normalizeHex(value, fallback = "000000") {
3306
3551
  return fallback.replace(/^#/, "").toUpperCase().slice(0, 6);
3307
3552
  }
3308
3553
 
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
3554
  // The master, layout and notes master half of the native header/footer (RR-11): placeholders and p:hf on the finished parts.
3319
3555
  // 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
3556
  function writeNativeFurnitureMasters(output, context) {
@@ -3373,13 +3609,18 @@ async function normalizePptxZip(raw, context) {
3373
3609
  const {heading,labelColor,spec,textSize}=context.chartHeadings.get(name);
3374
3610
  if(heading!==undefined)writeChartCategoryHeading(entries,chartPart,heading);
3375
3611
  entries[chartPart]=encodeText(omitEmptyNumberPoints(applyChartConstruct(decodeText(entries[chartPart]),spec)));
3612
+ const highlight=context.chartHeadings.get(name).highlight;
3613
+ if(highlight){
3614
+ try{entries[chartPart]=encodeText(applyChartHighlight(decodeText(entries[chartPart]),highlight));}
3615
+ catch(error){throw new OPFPptxError('packaging-failed',`Chart highlight could not be written: ${errorMessage(error)}`);}
3616
+ }
3376
3617
  // PptxGenJS hardcodes a black fallback in pie/doughnut label properties.
3377
3618
  // Normalize only our generated chart text styles; point/series fills stay intact.
3378
3619
  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
3620
  entries[chartPart]=encodeText(applyChartTextSize(decodeText(entries[chartPart]),textSize));
3380
3621
  // RR-35: data labels last, so their own text colours (contrast inside a mark) are not normalized away.
3381
3622
  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}));
3623
+ 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
3624
  // RR-54: the columns' number formats, after the labels: caches, label and value-axis formats, then the workbook cells.
3384
3625
  const numberFormats=context.chartHeadings.get(name).numberFormats;
3385
3626
  if(numberFormats){
@@ -3390,6 +3631,7 @@ async function normalizePptxZip(raw, context) {
3390
3631
  }
3391
3632
  }
3392
3633
  applyChartFonts(entries,context.chartFonts,parseRelationships);
3634
+ 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
3635
  // Chartex charts: the native cx:chartSpace part, its style parts and the mc:AlternateContent frame (the classic chart above is the fallback).
3394
3636
  try {
3395
3637
  attachChartexParts(entries, context.chartex, parseRelationships);
@@ -3408,7 +3650,7 @@ async function normalizePptxZip(raw, context) {
3408
3650
  const relationships = parseRelationships(entries, part);
3409
3651
  for (const [picture] of decodeText(bytes).matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
3410
3652
  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);
3653
+ const placement = context.imagePlacements.get(name) ?? context.quotePhotos.get(name) ?? context.slideImages.get(name) ?? (name === watermarkName() ? context.watermarks.get(part) : name === logoName() ? context.logos.get(part) : undefined);
3412
3654
  const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
3413
3655
  if (placement) imageSources.set(relationships.get(id)?.path, placement.path);
3414
3656
  }
@@ -3442,6 +3684,7 @@ async function normalizePptxZip(raw, context) {
3442
3684
  placeSlideImages(entries, context.slideImages, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
3443
3685
  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
3686
  });
3687
+ tagTextWatermarks(entries, context.textWatermarks);
3445
3688
  placeWatermarks(entries, context.watermarks, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
3446
3689
  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
3690
  });
@@ -3685,7 +3928,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3685
3928
  }
3686
3929
  const text = context.pictureText.get(picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1]);
3687
3930
  if (text) picture = writePictureText(picture, text);
3688
- const placement = context.imagePlacements.get(picture.match(/name="(OPF image \d+)"/)?.[1]);
3931
+ const placement = context.imagePlacements.get(picture.match(/name="(OPF image \d+)"/)?.[1]) ?? context.quotePhotos.get(picture.match(/name="(OPF quote photo \d+)"/)?.[1]);
3689
3932
  if (!placement) return picture;
3690
3933
  const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
3691
3934
  const dimensions = imageMetadata.get(relationships.get(id)?.path);
@@ -3698,6 +3941,8 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3698
3941
  const attrs = Object.entries(fitted.crop).map(([key, value]) => `${key}="${value}"`).join(' ');
3699
3942
  picture = picture.replace('<a:stretch>', `<a:srcRect ${attrs}/><a:stretch>`);
3700
3943
  }
3944
+ // The circular headshot mask: the core frame's DrawingML preset (ellipse) instead of the rectangle.
3945
+ if (placement.shape) picture = picture.replace(/<a:prstGeom\b[\s\S]*?<\/a:prstGeom>/, `<a:prstGeom prst="${placement.shape.preset}"><a:avLst/></a:prstGeom>`);
3701
3946
  return picture;
3702
3947
  });
3703
3948
  // Native bullets otherwise inherit the first rich run's size, font and
@@ -3733,6 +3978,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3733
3978
  if (context.scriptFonts) xml = partScriptFonts(path, xml, context.scriptFonts, context.partSlides.get(path) ?? 0);
3734
3979
  else if (/^ppt\/theme\/theme\d+\.xml$/.test(path)) xml = themeEastAsianFromLatin(xml);
3735
3980
  if (/^ppt\/(?:slides\/slide|notesSlides\/notesSlide)\d+\.xml$/.test(path)) xml = stripRunScriptFonts(xml);
3981
+ if (context.scriptFonts && /^ppt\/slides\/slide\d+\.xml$/.test(path)) xml = runLanguageFonts(xml, context.scriptFonts, context.partSlides.get(path) ?? 0);
3736
3982
  return encodeText(normalizePartReferences(xml, renameMaps));
3737
3983
  }
3738
3984
  return bytes;