@openpresentation/opf-pptx 0.11.8 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,23 +1,32 @@
1
1
  import {nativeBodyReader, joinNativeParagraphs} from './body-text-import.js';
2
+ import {autoNumScheme, deriveListNumbering, withDisplayedNumbers} from './numbered-list.js';
2
3
  import {isFaceStyleSuffix} from './font-weights.js';
3
4
  import {importTableFrames} from './table-import.js';
4
5
  import {applyChartFonts, applyPitchFamilies, finalizeFontsUsed, fontPitchFamilies} from './package-fonts.js';
5
6
  import {readChartCategoryHeading,writeChartCategoryHeading} from './chart-workbook.js';
6
7
  import {CHARTEX_FALLBACK,resolveChartType,chartTypeFromNative,applyChartConstruct,NATIVE_CHART_ELEMENTS} from './chart-types.js';
7
8
  import {attachChartexParts,chartFromChartex,CHARTEX_GRAPHIC_DATA_URI} from './chartex.js';
9
+ import {applyDataLabels,chartOptionsFromClassic,chartTargetFor,classicChartOptions,reportChartOptionDiagnostics,resolveChartOptionsFor} from './chart-options.js';
8
10
  import {attachCodeTags, attachTextTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
9
11
  import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
10
12
  import {attachCardTags,importCardFrames} from './card-provenance.js';
11
13
  import {attachMediaTags,importMediaGroups,mediaCaption,mediaFrameRecord,mediaTextFingerprint} from './media-provenance.js';
14
+ // RR-34: captions and footnote areas as tagged text boxes; citation markers round-trip through the tags and the marker runs.
15
+ import {addCaption, addFootnotes} from './annotation-export.js';
16
+ import {attachAnnotationTags, captionValue, importAnnotations, restoreCitations} from './annotation-provenance.js';
12
17
  import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
13
18
  import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
14
19
  import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
15
20
  import {attachQuoteTags,quoteManifest,importQuoteGroups} from './quote-provenance.js';
16
21
  import {attachFurnitureTags, furnitureManifest, importFurniture, manifestPartIndex, staticDateFallback} from './furniture-provenance.js';
17
- import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, recordContentTopology, restoreDocumentProvenance} from './document-provenance.js';
22
+ import {restoreRunColors} from './run-colors.js';
23
+ import {joinWrappedText} from './content-topology.js';
24
+ import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, recordContentTopology, restoreDocumentProvenance, joinAuthors, splitAuthors, slideListBreaks, DEFAULT_AUTHOR} from './document-provenance.js';
18
25
  import {INVALID_XML_CHARACTER, nativeSections, writeSectionList} from './sections.js';
19
26
  import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
27
+ import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, writeNativeMasters} from './native-furniture.js';
20
28
  import {importImageOrientation} from './image-import.js';
29
+ import {extractSignals, normalizeSignalOptions, themeFactsFor} from './import-signals.js';
21
30
  import {placeSlideImages, importSlideImage, slideImageName, slideImageOverlayName} from './slide-image-provenance.js';
22
31
  import {dedupeMedia} from './media-dedupe.js';
23
32
  import {placeWatermarks, importWatermark, watermarkName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
@@ -25,10 +34,13 @@ import {placeLogos, importLogo, importLogoPlaceholders, logoName, LOGO_TAG} from
25
34
  import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
26
35
  import {importBackground} from './background-import.js';
27
36
  import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
28
- import {languageDiagnostics, observeLanguage, partScriptFonts, planScriptFonts, reconcileLanguage} from './script-fonts.js';
29
- import { webpToPng } from '#image-fallback';
37
+ import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, reconcileLanguage} from './script-fonts.js';
38
+ import { webpToPng, svgToPng, readLocalFile } from '#image-fallback';
39
+ import { prepareSvg, svgDataUriBytes, svgRasterScale, svgBlipRelationship, attachSvgPictures } from './svg-image.js';
30
40
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
31
41
  import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
42
+ // chartPaletteForFill ships with core RR-29 (opf#270); an older published core still loads and clamps each colour on its own.
43
+ import * as opfComposition from "@openpresentation/opf/composition";
32
44
  import { colorContext, resolveColorRefValue, resolveExportColor, resolveVariableColors } from "./color-ref.js";
33
45
  import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
34
46
  import { unzipSync, zipSync } from "fflate";
@@ -37,8 +49,14 @@ import {
37
49
  catalogs as bundledCatalogs,
38
50
  validatePresentation
39
51
  } from "@openpresentation/opf";
52
+ // Optional core exports (RR-07 syntax colours, trend marks) are read from the namespace so an older published core still loads.
53
+
54
+ // Optional core exports are read from the namespace so an older published core still loads;
55
+ // resolveVariables ships with core RR-32.
56
+ import * as opfCore from "@openpresentation/opf";
40
57
 
41
58
  export {checkPptxTypefaces, inventoryPptxTypefaces, packageFontsUsed, THEME_SCRIPT_SUPPLEMENTS} from './typeface-inventory.js';
59
+ export {DEFAULT_SIGNAL_LIMITS, SIGNALS_VERSION, isMonospaceFamily} from './import-signals.js';
42
60
 
43
61
  export const packageName = "@openpresentation/opf-pptx";
44
62
 
@@ -201,9 +219,9 @@ export async function toPptx(input, options = {}) {
201
219
  if (options.provenance !== undefined && !['full', 'references-only', false].includes(options.provenance)) {
202
220
  throw new OPFPptxError('invalid-provenance-option', "provenance must be 'full', 'references-only' or false.", {path: 'options.provenance'});
203
221
  }
204
- const presentation = parseInput(input);
222
+ const presentation = resolveTemplateInput(parseInput(input), options);
205
223
  assertValidBoundary(presentation);
206
- options = {...options, textMeasurement: chosenFamilyMeasurement(options.textMeasurement)};
224
+ options = {...options, textMeasurement: chosenFamilyMeasurement(options.textMeasurement), svgRasters: new Map()};
207
225
 
208
226
  const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
209
227
  context.listMarkers = new Map();
@@ -211,6 +229,7 @@ export async function toPptx(input, options = {}) {
211
229
  context.tableHeaders = new Map();
212
230
  context.tableCells = new Map();
213
231
  context.imagePlacements = new Map();
232
+ context.svgPictures = new Map();
214
233
  context.pictureText = new Map();
215
234
  context.slideImages = new Map();
216
235
  context.watermarks = new Map();
@@ -230,10 +249,16 @@ export async function toPptx(input, options = {}) {
230
249
  context.furnitureLogoTags = new Map();
231
250
  context.furnitureFields = new Map();
232
251
  context.furnitureManifests = new Map();
252
+ context.nativeFurniture = new Map();
253
+ context.hostDate = options.date;
233
254
  context.codeTags = new Map();
255
+ context.captionTags = new Map();
256
+ context.footnoteTags = new Map();
234
257
  context.metricTags = new Map();
258
+ context.metricDescriptions = new Map();
235
259
  context.chartHeadings = new Map();
236
260
  context.chartFonts = new Map();
261
+ context.chartPalettes = new Map();
237
262
  context.chartex = new Map();
238
263
  context.imageFormat = options.imageFormat ?? "compatible";
239
264
  // Chartex export mode (FF-22b): 'auto' (default) writes native chartex parts for the constructs the native PowerPoint
@@ -289,6 +314,8 @@ export async function toPptx(input, options = {}) {
289
314
  }
290
315
 
291
316
  export async function fromPptx(input, options = {}) {
317
+ // Opt-in raw shape signals (import-signals.js). Validated first so a bad option fails before any work.
318
+ const signalLimits = normalizeSignalOptions(options.signals, (code, message, details) => new OPFPptxError(code, message, details));
292
319
  const entries = readPptxZip(input);
293
320
  const presentationDoc = parseRequiredXml(entries, "ppt/presentation.xml");
294
321
  const presentationRoot = presentationDoc["p:presentation"];
@@ -318,16 +345,21 @@ export async function fromPptx(input, options = {}) {
318
345
  });
319
346
  if (observedLanguage.language !== undefined) imported.language = observedLanguage.language;
320
347
  if (core.description) imported.description = core.description;
321
- if (core.author) imported.author = core.author;
348
+ if (core.author) imported.author = splitAuthors(core.author);
322
349
  const themeDesign = importThemeDesign(entries, presentationRoot, presentationRels);
323
350
  const design = {...themeDesign.design, ...(dimensions ? {dimensions} : {})};
324
351
  if (Object.keys(design).length) imported.design = design;
325
352
 
353
+ // Layouts and the master are read once however many slides inherit a placeholder from them (RR-11).
354
+ const cached = read => { const memo = new Map(); return path => { if (!memo.has(path)) memo.set(path, read(path)); return memo.get(path); }; };
355
+ const cachedRelationships = cached(path => parseRelationships(entries, path)), cachedPart = cached(path => parseOptionalXml(entries, path));
326
356
  const furnitureContexts = slidePaths.map((slidePath, index) => {
327
357
  const root = parseRequiredXml(entries, slidePath)['p:sld'];
328
358
  if (!root) throw new OPFPptxError('invalid-pptx', `PPTX slide is not a PresentationML slide: ${slidePath}.`, {path: slidePath});
329
359
  const tree = root['p:cSld']?.['p:spTree'], relationships = parseRelationships(entries, slidePath);
330
360
  return {root, relationships, shapes: nativeTextShapes(tree), pictures: nativePictures(tree),
361
+ // RR-11: native footer placeholders resolve their position through the layout and master.
362
+ path: slidePath, slideWidth: Number(presentationRoot['p:sldSz']?.cx), relationshipsOf: cachedRelationships, readPart: cachedPart,
331
363
  paragraphs: nativeShapeParagraphs(decodeText(entries[slidePath])),
332
364
  readPicture: picture => importPicture(entries, picture, slidePath, relationships,
333
365
  diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${index}.design`}))};
@@ -339,7 +371,7 @@ export async function fromPptx(input, options = {}) {
339
371
  const mediaRegistry = Object.create(null);
340
372
  for (let index = 0; index < slidePaths.length; index += 1) {
341
373
  furnitureContexts[index].mediaRegistry = mediaRegistry;
342
- imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options, furniture.slides[index], furnitureContexts[index]));
374
+ imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, {...options, rtlDeck: observedRtl(observedLanguage)}, furniture.slides[index], furnitureContexts[index]));
343
375
  }
344
376
  // Native sections (PowerPoint's own section list, `Default Section` = none)
345
377
  // are reconciled with the footer text and the stored value in restoreDocumentProvenance.
@@ -366,6 +398,7 @@ export async function fromPptx(input, options = {}) {
366
398
  // Layout-structure recovery (FF-29) reads the stored OPF_SLIDE_V1 record from
367
399
  // here instead of writing a second slide tag.
368
400
  let slideProvenance = slidePaths.map(() => ({structure: "untagged"}));
401
+ let restoredGroups = [];
369
402
  try {
370
403
  const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true,
371
404
  // Host catalogs format stored socials exactly as export did (FF-34).
@@ -376,11 +409,16 @@ export async function fromPptx(input, options = {}) {
376
409
  restored.groups = reconcileLanguage(restored.groups, observedLanguage, report);
377
410
  imported = applyDocumentProvenance(imported, restored, validatePresentation, report);
378
411
  slideProvenance = restored.slides;
412
+ restoredGroups = restored.groups;
379
413
  } catch (error) {
380
414
  report({code: "invalid-document-provenance", path: "", message: `${errorMessage(error)} Ordinary import keeps the values observed in the PPTX.`});
381
415
  }
382
416
  if (slideProvenance.length !== imported.slides.length) throw new OPFPptxError("invalid-import-opf", "Slide provenance does not match the imported slides.");
417
+ // RR-34: marker runs become cite/footnote on the runs before them, and the references list is rebuilt
418
+ // from the stored record and the footnote boxes (an edited note keeps its edited text).
419
+ restoreCitations(imported, furnitureContexts.map(context => context.annotations?.notes ?? []), report);
383
420
  restoreLogoFallback(imported, furnitureContexts);
421
+ applyRunColors(imported, slideProvenance, restoredGroups, report, options);
384
422
  if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
385
423
  languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
386
424
 
@@ -392,7 +430,32 @@ export async function fromPptx(input, options = {}) {
392
430
  });
393
431
  }
394
432
 
395
- return imported;
433
+ if (!signalLimits) return imported;
434
+ // Where document provenance rebuilt the authored structure, flat block indexes map to their rebuilt paths.
435
+ const contentPaths = slidePaths.map(() => null);
436
+ for (const group of restoredGroups) if (group.applied && group.contentPaths?.paths) contentPaths[group.contentPaths.slide] = group.contentPaths.paths;
437
+ const archive = {part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]};
438
+ const signals = extractSignals({archive, presentationRoot, slidePaths, themeFacts: themeFactsFor(slidePaths[0], archive), limits: signalLimits,
439
+ recorded: furnitureContexts.map(context => context.signalSources), slideProvenance, contentPaths});
440
+ return {document: imported, signals};
441
+ }
442
+
443
+ // RR-08: run colours named by a ColorRef (variable, scheme slot, role) come back as that name where the document's own
444
+ // colour resolution still gives the run's colour; see run-colors.js.
445
+ function applyRunColors(imported, slideProvenance, restoredGroups, report, options) {
446
+ const restoredContent = new Set(restoredGroups.filter(group => group.applied && group.contentPaths).map(group => group.contentPaths.slide));
447
+ const info = slideProvenance.map((entry, index) => entry.structure === "untagged" ? undefined : {record: entry.record, structure: entry.structure === "match", content: restoredContent.has(index)});
448
+ const contexts = new Map();
449
+ const resolve = (slide, name) => {
450
+ try {
451
+ const key = slide.design ?? imported.design ?? null;
452
+ if (!contexts.has(key)) contexts.set(key, resolvePresentationContext(slide.design ? {...imported, design: {...imported.design, ...slide.design}} : imported, options));
453
+ return resolveColorRefValue(name, colorContext(contexts.get(key)));
454
+ } catch {
455
+ return undefined;
456
+ }
457
+ };
458
+ restoreRunColors(imported.slides, info, resolve, report);
396
459
  }
397
460
 
398
461
  // A consumed logo picture (OPF_LOGO_V1) never becomes content. Its own image restores the logo only when nothing else
@@ -616,12 +679,37 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
616
679
  item.text = current.map(paragraph => paragraph.text).join('\n');
617
680
  }
618
681
  }
619
- const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items))
620
- .map((item) => ({payload: payloadFromSlideItem(item), bounds: item.visualBounds ?? item.bounds}))
682
+ const listBreaks = slideListBreaks(entries, slidePath, slideRoot, relationships);
683
+ const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items, listBreaks, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`})))
684
+ .map((item) => ({payload: payloadFromSlideItem(item, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`})), bounds: item.visualBounds ?? item.bounds, sources: item.sources ?? []}))
621
685
  .filter((entry) => entry.payload);
622
686
  const blocks = content.map((entry) => entry.payload);
687
+ // RR-34: a tagged caption re-attaches to the block whose native media shape it names (never by position).
688
+ const annotations = nativeContext.annotations;
689
+ if (annotations?.captions.size) {
690
+ const tree = slideRoot['p:cSld']?.['p:spTree'];
691
+ const shapeName = node => scalarText(node?.['p:nvSpPr']?.['p:cNvPr']?.name ?? node?.['p:nvPicPr']?.['p:cNvPr']?.name ?? node?.['p:nvGraphicFramePr']?.['p:cNvPr']?.name).trim();
692
+ const keyByName = new Map();
693
+ nativeContext.shapes.forEach((shape, index) => keyByName.set(shapeName(shape), `sp:${index}`));
694
+ nativeContext.pictures.forEach((picture, index) => keyByName.set(shapeName(picture), `pic:${index}`));
695
+ asArray(tree?.['p:graphicFrame']).forEach((frame, index) => keyByName.set(shapeName(frame), `frame:${index}`));
696
+ asArray(tree?.['mc:AlternateContent']).forEach((alternate, index) => { for (const node of [...asArray(alternate?.['mc:Choice']), ...asArray(alternate?.['mc:Fallback'])]) if (node?.['p:graphicFrame']) keyByName.set(shapeName(node['p:graphicFrame']), `alt:${index}`); });
697
+ for (const [mediaName, caption] of annotations.captions) {
698
+ const key = keyByName.get(mediaName);
699
+ const entry = key === undefined ? undefined : content.find(item => item.sources.includes(key));
700
+ if (!entry || !['image', 'chart', 'table', 'video'].includes(inferPayloadKind(entry.payload))) {
701
+ options.onDiagnostic?.({code: 'caption-detached', path: `slides.${slideIndex}`, message: `The caption of ${caption.path} no longer has its media shape (${mediaName}); its text is imported as an ordinary text block.`});
702
+ blocks.push({type: 'text', text: caption.value});
703
+ continue;
704
+ }
705
+ entry.payload.caption = captionValue(caption);
706
+ }
707
+ }
623
708
  if (blocks.length > 0) slide.blocks = blocks;
624
709
  // Native bounds of each block in reference px, for content topology matching (document provenance).
710
+ // Which native shapes fed each field and block (import signals); never part of the document.
711
+ nativeContext.signalSources = {tag: tagItem?.sources ?? [], title: titleItem?.sources ?? [], subtitle: subtitleItem?.sources ?? [],
712
+ blocks: content.map(entry => ({sources: entry.sources, type: entry.payload.type})), roles: nativeContext.signalRoles ?? new Map()};
625
713
  nativeContext.contentBounds = content.map((entry) => entry.bounds ? {x: entry.bounds.x * 96, y: entry.bounds.y * 96, width: entry.bounds.w * 96, height: entry.bounds.h * 96} : null);
626
714
 
627
715
  const notes = readSlideNotes(entries, relationships);
@@ -638,6 +726,17 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
638
726
  const shapes = nativeContext.shapes;
639
727
  if (tree?.['p:grpSp']) options.onDiagnostic?.({code:'grouped-text-reflow',path:`slides.${slideIndex}`,message:'Grouped native text and pictures are retained, but group transforms and unsupported group members are not reconstructed; review the reflowed OPF.'});
640
728
  const paragraphs = nativeContext.paragraphs;
729
+ // FF-45: OMML equations (a14:m math zones) have no OPF model. Their fallback text is imported as plain text; the equation
730
+ // layout (fractions, radicals, limits, matrices) is lost, and that loss is diagnosed per shape instead of passing silently.
731
+ paragraphs.forEach((list, index) => {
732
+ const zones = list.reduce((count, paragraph) => count + (paragraph.math?.zones ?? 0), 0);
733
+ if (zones) options.onDiagnostic?.({code: 'math-equation-flattened', path: `slides.${slideIndex}`, message: `Native shape ${index} holds ${zones} OMML equation${zones === 1 ? '' : 's'} (a14:m); OPF has no equation model, so its fallback text is imported as plain text and the equation layout is not represented.`});
734
+ });
735
+ // Native shape keys (indexes in nativeTextShapes / nativePictures / frame order) for import signals.
736
+ const shapeKeys = new Map(shapes.map((shape, index) => [shape, `sp:${index}`]));
737
+ const sourcesOf = group => group.flatMap(shape => shapeKeys.has(shape) ? [shapeKeys.get(shape)] : []);
738
+ const roles = new Map();
739
+ nativeContext.signalRoles = roles;
641
740
  const readBody = nativeBodyReader(slidePath, {
642
741
  part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
643
742
  }, relationships, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.${diagnostic.path}`}));
@@ -650,29 +749,36 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
650
749
  const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
651
750
  const timelines=importTimelineGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
652
751
  const quotes=importQuoteGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.quote`}));
752
+ // RR-34: tagged caption and footnote lines are not content; captions re-attach to their media block and notes rebuild cite/footnote.
753
+ const annotations=importAnnotations({shapes,paragraphs,relationships,entries,slideIndex,readBody,report:diagnostic=>options.onDiagnostic?.(diagnostic)});
754
+ nativeContext.annotations=annotations;
653
755
  for(const group of [...headings.items,...plainText.items]) {
654
756
  const item=importShape(group.shapes[0],dimensions,group.paragraphs,true);
655
757
  if(item){
656
758
  const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
657
759
  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};}
658
- items.push({...item,heading:group.field,sourceText:!group.field});
760
+ items.push({...item,heading:group.field,sourceText:!group.field,sources:sourcesOf(group.shapes)});
659
761
  }
660
762
  }
661
763
  for(const group of timelines.items){
662
764
  const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
663
765
  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};}}
664
- items.push({kind:'timeline',sourceText:true,bounds:union,payload:group.payload});
766
+ items.push({kind:'timeline',sourceText:true,bounds:union,payload:group.payload,sources:sourcesOf(group.shapes)});
665
767
  }
666
768
  for(const group of quotes.items){
667
769
  const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
668
770
  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};}}
669
- items.push({kind:'quote',bounds:union,payload:group.payload});
771
+ items.push({kind:'quote',bounds:union,payload:group.payload,sources:sourcesOf(group.shapes)});
670
772
  }
671
- for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
672
- for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
673
- for (const item of media.items) items.push({kind:'media',sourceText:item.payload.type==='text',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
773
+ 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])});
774
+ 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])});
775
+ 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])});
674
776
  for (const [index,shape] of shapes.entries()) {
777
+ const role = furniture.text.has(index) ? 'furniture' : nativeContext.slideImageShapes?.has(index) ? 'slide-image' : nativeContext.logoPlaceholderShapes?.has(index) ? 'logo' : cards.has(shape) ? 'card-frame'
778
+ : 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;
779
+ if (role) roles.set(shapeKeys.get(shape), role);
675
780
  if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index)) continue;
781
+ if (annotations.consumed.has(shape)) { roles.set(shapeKeys.get(shape), 'annotation'); continue; }
676
782
  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;
677
783
  const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
678
784
  const item = importShape(shape, dimensions, paragraphs[index], ordinaryBody || furniture.taggedText.has(index) || media.captionShapes.has(shape));
@@ -680,33 +786,36 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
680
786
  // A damaged/edited furniture group falls back to current native text,
681
787
  // including cleared text boxes, without inventing a title or shape label.
682
788
  if (item && (furniture.taggedText.has(index) || media.captionShapes.has(shape))) item.sourceText = true;
683
- if (item) items.push(item);
789
+ if (item) items.push({...item, sources: [`sp:${index}`]});
684
790
  }
685
791
 
686
792
  const frames = asArray(tree?.["p:graphicFrame"]);
793
+ // RR-35: what a chart's axis titles, legend and data labels cannot express in OPF is reported on the chart's path.
794
+ const chartReport = label => diagnostic => options.onDiagnostic?.({code: 'chart-option-adapted', ...diagnostic, path: `slides.${slideIndex}.${label}.${diagnostic.option}`});
687
795
  const tables = frames.some(frame => frame['a:graphic']?.['a:graphicData']?.['a:tbl'])
688
796
  ? importTableFrames(slidePath, {
689
797
  part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
690
- }, relationships, (frame, cell, code, message) => options.onDiagnostic?.({code, message, path: `slides.${slideIndex}.tables.${frame}${cell ? '.' + cell : ''}`}), dimensions) : [];
798
+ }, relationships, (frame, cell, code, message) => options.onDiagnostic?.({code, message, path: `slides.${slideIndex}.tables.${frame}${cell ? '.' + cell : ''}`}), dimensions, {rtlDeck: options.rtlDeck === true}) : [];
691
799
  for (const [index, frame] of frames.entries()) {
692
- const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index]);
693
- if (item) items.push(item);
800
+ const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index], chartReport(`charts.${index}`));
801
+ if (item) items.push({...item, sources: [`frame:${index}`]});
694
802
  }
695
803
  // A chartex chart is an mc:AlternateContent: the choice frame references the cx:chartSpace part; the fallback is a classic chart frame
696
804
  // (this exporter) or a text shape (PowerPoint). The choice is read first and the fallback only when it names no OPF chart type.
697
- for (const alternate of asArray(tree?.["mc:AlternateContent"])) {
805
+ for (const [alternateIndex, alternate] of asArray(tree?.["mc:AlternateContent"]).entries()) {
698
806
  const choice = asArray(alternate?.["mc:Choice"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
699
807
  const fallback = asArray(alternate?.["mc:Fallback"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
700
- const chosen = choice ? importGraphicFrame(entries, choice, slidePath, relationships) : null;
701
- const item = chosen?.payload?.type === "chart" ? chosen : fallback ? importGraphicFrame(entries, fallback, slidePath, relationships) : chosen;
702
- if (item && item.kind !== "unknown") items.push(item);
808
+ const chosen = choice ? importGraphicFrame(entries, choice, slidePath, relationships, undefined, chartReport(`charts.alt${alternateIndex}`)) : null;
809
+ const item = chosen?.payload?.type === "chart" ? chosen : fallback ? importGraphicFrame(entries, fallback, slidePath, relationships, undefined, chartReport(`charts.alt${alternateIndex}`)) : chosen;
810
+ if (item && item.kind !== "unknown") items.push({...item, sources: [`alt:${alternateIndex}`]});
703
811
  }
704
812
 
705
813
  for (const [index, picture] of nativeContext.pictures.entries()) {
706
- if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index) || nativeContext.watermarkPictures?.has(index) || nativeContext.logoPictures?.has(index)) continue;
814
+ const pictureRole = furniture.pictures.has(index) ? 'furniture' : nativeContext.slideImagePictures?.has(index) ? 'slide-image' : nativeContext.watermarkPictures?.has(index) ? 'watermark' : nativeContext.logoPictures?.has(index) ? 'logo' : undefined;
815
+ if (pictureRole) { roles.set(`pic:${index}`, pictureRole); continue; }
707
816
  const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
708
817
  const item = importPicture(entries, picture, slidePath, relationships, report);
709
- if (item) items.push(item);
818
+ if (item) items.push({...item, sources: [`pic:${index}`]});
710
819
  }
711
820
 
712
821
  return items;
@@ -739,7 +848,7 @@ function importShape(shape, dimensions, paragraphs = readParagraphs(shape["p:txB
739
848
  };
740
849
  }
741
850
 
742
- function importGraphicFrame(entries, frame, slidePath, relationships, importedTable) {
851
+ function importGraphicFrame(entries, frame, slidePath, relationships, importedTable, report) {
743
852
  const bounds = shapeBounds(frame["p:xfrm"]);
744
853
  const name = scalarText(frame["p:nvGraphicFramePr"]?.["p:cNvPr"]?.name).trim();
745
854
  const graphicData = frame["a:graphic"]?.["a:graphicData"];
@@ -760,8 +869,8 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
760
869
  const chartRelId = graphicData?.["c:chart"]?.["r:id"] ?? chartExRelId;
761
870
  if (chartRelId) {
762
871
  const chart = chartExRelId
763
- ? chartexFromRelationship(entries, relationships, chartExRelId)
764
- : chartFromRelationship(entries, slidePath, relationships, chartRelId);
872
+ ? chartexFromRelationship(entries, relationships, chartExRelId, report)
873
+ : chartFromRelationship(entries, slidePath, relationships, chartRelId, report);
765
874
  return {
766
875
  kind: "chart",
767
876
  bounds,
@@ -800,7 +909,19 @@ function importPicture(entries, picture, slidePath, relationships, report) {
800
909
  const relId = picture["p:blipFill"]?.["a:blip"]?.["r:embed"];
801
910
  const relationship = relationships.get(relId);
802
911
  let bytes = relationship?.path ? entries[relationship.path] : null;
803
- if (!bytes) {
912
+ // A picture PowerPoint stores as SVG carries the SVG beside its PNG fallback: the SVG is the image.
913
+ let svg = null;
914
+ const svgRelationship = relationships.get(svgBlipRelationship(picture["p:blipFill"]?.["a:blip"]));
915
+ if (svgRelationship && svgRelationship.targetMode !== "External" && entries[svgRelationship.path]) {
916
+ const prepared = prepareSvg(entries[svgRelationship.path]);
917
+ if (prepared.error) report({code: "invalid-svg-image", message: `A picture's SVG was not imported (${prepared.error.message}); its PNG fallback was imported instead (none: the picture has no image).`});
918
+ else {
919
+ svg = prepared.bytes;
920
+ if (prepared.removed.length) report({code: "svg-sanitized", message: `The SVG had ${prepared.removed.join(", ")} removed on import.`});
921
+ }
922
+ }
923
+ // PowerPoint can save an SVG picture with no PNG fallback at all (an a:blip with no r:embed): the SVG alone is the image.
924
+ if (!bytes && !svg) {
804
925
  return {
805
926
  kind: "unknown",
806
927
  bounds,
@@ -813,7 +934,7 @@ function importPicture(entries, picture, slidePath, relationships, report) {
813
934
  if (crop && ['l','r','t','b'].some(key => Number(crop[key] ?? 0) !== 0)) {
814
935
  report({code: 'unsupported-image-crop', message: 'Native picture crop is not represented by the imported OPF asset; the full image was retained.'});
815
936
  }
816
- bytes = importImageOrientation(bytes, picture["p:spPr"]?.["a:xfrm"], report);
937
+ bytes = importImageOrientation(svg ?? bytes, picture["p:spPr"]?.["a:xfrm"], report);
817
938
 
818
939
  return {
819
940
  kind: "image",
@@ -823,7 +944,7 @@ function importPicture(entries, picture, slidePath, relationships, report) {
823
944
  payload: {
824
945
  type: "image",
825
946
  image: {
826
- src: `data:${rasterMetadata(bytes)?.mediaType ?? mediaTypeForPath(relationship.path)};base64,${bytesToBase64(bytes)}`,
947
+ src: `data:${svg ? "image/svg+xml" : rasterMetadata(bytes)?.mediaType ?? mediaTypeForPath(relationship?.path)};base64,${bytesToBase64(bytes)}`,
827
948
  ...(alt ? { alt } : {}),
828
949
  ...(title && alt ? { title } : {})
829
950
  }
@@ -832,11 +953,17 @@ function importPicture(entries, picture, slidePath, relationships, report) {
832
953
  }
833
954
 
834
955
  function readParagraphs(txBody) {
835
- return asArray(txBody?.["a:p"])
956
+ return withDisplayedNumbers(asArray(txBody?.["a:p"])
836
957
  .map((paragraph) => {
958
+ // FF-45: an a14:m math zone (OMML equation) inside mc:AlternateContent has no OPF model; its mc:Fallback runs are read as
959
+ // text (or, without them, the equation's m:t text), and `math` records the zones so the importer can diagnose the loss.
960
+ const alternates = asArray(paragraph?.["mc:AlternateContent"]);
961
+ const zones = alternates.flatMap(alternate => asArray(alternate?.["mc:Choice"])).filter(choice => choice?.["a14:m"] !== undefined);
962
+ const fallbackRuns = alternates.flatMap(alternate => asArray(alternate?.["mc:Fallback"])).flatMap(fallback => [...asArray(fallback?.["a:r"]), ...asArray(fallback?.["a:fld"])]);
837
963
  const runs = [
838
964
  ...asArray(paragraph?.["a:r"]),
839
- ...asArray(paragraph?.["a:fld"])
965
+ ...asArray(paragraph?.["a:fld"]),
966
+ ...fallbackRuns
840
967
  ];
841
968
  const texts = [];
842
969
  const sizes = [];
@@ -846,13 +973,29 @@ function readParagraphs(txBody) {
846
973
  const size = Number(run?.["a:rPr"]?.sz);
847
974
  if (Number.isFinite(size)) sizes.push(size / 100);
848
975
  }
976
+ const linear = zones.map(choice => keyedMathText(choice["a14:m"])).join("");
977
+ if (zones.length && !fallbackRuns.some(run => scalarText(run?.["a:t"]))) texts.push(linear);
849
978
  return {
850
979
  text: texts.join(""),
851
980
  bullet: asArray(paragraph?.["a:pPr"]).some(props => props?.["a:buChar"] !== undefined || props?.["a:buBlip"] !== undefined || props?.["a:buAutoNum"] !== undefined),
852
981
  level: Number(asArray(paragraph?.["a:pPr"])[0]?.lvl ?? 0),
853
- maxFontSize: sizes.length > 0 ? Math.max(...sizes) : 0
982
+ maxFontSize: sizes.length > 0 ? Math.max(...sizes) : 0,
983
+ ...(zones.length ? {math: {zones: zones.length, text: linear}} : {}),
984
+ ...autoNumberOf(asArray(paragraph?.["a:pPr"])[0])
854
985
  };
855
- });
986
+ }));
987
+ }
988
+ // RR-33: the native auto-number of a paragraph's properties, when its last bullet element is one.
989
+ function autoNumberOf(properties) {
990
+ if (properties?.["a:buAutoNum"] === undefined) return {};
991
+ const attributes = asArray(properties["a:buAutoNum"])[0] ?? {};
992
+ return {autoNum: {type: attributes.type, startAt: attributes.startAt}};
993
+ }
994
+ /** The m:t text of a keyed OMML tree (an a14:m zone) in document order: the equation's linear reading without its layout. */
995
+ function keyedMathText(node) {
996
+ if (Array.isArray(node)) return node.map(keyedMathText).join("");
997
+ if (!node || typeof node !== "object") return "";
998
+ return Object.entries(node).map(([key, value]) => key === "m:t" ? asArray(value).map(scalarText).join("") : keyedMathText(value)).join("");
856
999
  }
857
1000
 
858
1001
  function shapePlaceholderType(shape) {
@@ -919,7 +1062,10 @@ function joinRich(a, b) {
919
1062
  }
920
1063
  return joined;
921
1064
  }
922
- function mergeOpfListShapes(items) {
1065
+ // `breaks` (slideListBreaks) holds the whitespace of the hard breaks the export recorded, by list path and line number: the
1066
+ // continuation line after a break joins with it, so a newline inside an item (or a description) returns. A blank line is a
1067
+ // native shape with no text: it is never a description. Without a record the lines join without a separator, as before.
1068
+ function mergeOpfListShapes(items, breaks = null, report = () => {}) {
923
1069
  const lists = new Map();
924
1070
  for (const item of items) {
925
1071
  const match = /^OPF list (.+) line (\d+)$/.exec(item.name ?? '');
@@ -933,17 +1079,23 @@ function mergeOpfListShapes(items) {
933
1079
  entries.sort((a, b) => a.line - b.line);
934
1080
  if (!entries[0].item.paragraphs[0].bullet) continue;
935
1081
  const paragraphs = [];
1082
+ const recorded = breaks?.get(path), applied = new Set();
936
1083
  let bounds, size = 0;
937
- for (const {item} of entries) {
1084
+ for (const {item, line} of entries) {
938
1085
  const [paragraph] = item.paragraphs, current = paragraphs.at(-1);
1086
+ const gap = recorded?.get(line);
1087
+ const join = (a, b) => gap === undefined ? joinRich(a, b) : joinWrappedText([a, b], [gap]);
939
1088
  if (paragraph.bullet) { paragraphs.push({...paragraph}); size = paragraph.maxFontSize; }
1089
+ else if (paragraph.text === '') { /* a blank line of the entry: no text, no description */ }
940
1090
  else if (paragraph.maxFontSize < size - 0.01) {
941
1091
  const text = paragraph.richText ?? paragraph.text;
942
- current.description = current.description === undefined ? text : joinRich(current.description, text);
1092
+ if (current.description === undefined) current.description = text;
1093
+ else { current.description = join(current.description, text); applied.add(line); }
943
1094
  } else {
944
1095
  const before = current.text;
945
- current.text += paragraph.text;
946
- if (current.richText !== undefined || paragraph.richText !== undefined) current.richText = joinRich(current.richText ?? before, paragraph.richText ?? paragraph.text);
1096
+ current.text += (gap === undefined ? '' : Array.isArray(gap) ? gap[0] : gap) + paragraph.text;
1097
+ if (current.richText !== undefined || paragraph.richText !== undefined) current.richText = join(current.richText ?? before, paragraph.richText ?? paragraph.text);
1098
+ applied.add(line);
947
1099
  }
948
1100
  const b = item.visualBounds ?? item.bounds;
949
1101
  if (b) {
@@ -952,8 +1104,9 @@ function mergeOpfListShapes(items) {
952
1104
  }
953
1105
  consumed.add(item);
954
1106
  }
1107
+ if (recorded && [...recorded.keys()].some(line => !applied.has(line))) report({code: 'list-line-break-changed', message: `A hard line break recorded at export inside the list ${path} no longer has the continuation line it belonged to (a line was edited away, or became an entry), so that break was not restored; the list keeps the lines it has.`});
955
1108
  const first = entries[0].item;
956
- merged.set(first, {...first, paragraphs, text: paragraphs.map(paragraph => paragraph.text).join('\n'), bounds, visualBounds: undefined});
1109
+ merged.set(first, {...first, paragraphs, text: paragraphs.map(paragraph => paragraph.text).join('\n'), bounds, visualBounds: undefined, sources: entries.flatMap(({item}) => item.sources ?? [])});
957
1110
  }
958
1111
  return items.flatMap(item => merged.has(item) ? [merged.get(item)] : consumed.has(item) ? [] : [item]);
959
1112
  }
@@ -971,23 +1124,30 @@ function mergeAdjacentBulletShapes(items) {
971
1124
  &&item.bounds.y-(previous.bounds.y+previous.bounds.h)<.3){
972
1125
  previous.paragraphs.push(...item.paragraphs);
973
1126
  previous.text+='\n'+item.text;
1127
+ previous.sources=[...(previous.sources??[]),...(item.sources??[])];
974
1128
  previous.bounds.h=item.bounds.y+item.bounds.h-previous.bounds.y;
975
1129
  }else result.push({...item,paragraphs:item.paragraphs?[...item.paragraphs]:undefined,bounds:item.bounds?{...item.bounds}:undefined});
976
1130
  }
977
1131
  return result;
978
1132
  }
979
1133
 
980
- function payloadFromSlideItem(item) {
1134
+ function payloadFromSlideItem(item, report) {
981
1135
  if (item.payload) return item.payload;
982
1136
  if (item.kind === "text") {
983
1137
  if (item.paragraphs.some(p=>p.bullet)) {
1138
+ // RR-33: native auto-numbers come back as `numbering` and the entry `start` values that restore their numbers.
1139
+ const {numbering, starts, diagnostics} = deriveListNumbering(item.paragraphs);
1140
+ for (const diagnostic of diagnostics) report?.(diagnostic);
984
1141
  return {
985
1142
  type: "list",
986
- items: item.paragraphs.map((paragraph) => (
987
- paragraph.level > 0
988
- ? { text: paragraph.richText ?? paragraph.text, level: paragraph.level, ...(paragraph.description !== undefined ? {description: paragraph.description} : {}) }
989
- : paragraph.description !== undefined ? { text: paragraph.richText ?? paragraph.text, description: paragraph.description } : (paragraph.richText ?? paragraph.text)
990
- ))
1143
+ items: item.paragraphs.map((paragraph, index) => {
1144
+ const start = starts.get(index) === undefined ? {} : {start: starts.get(index)};
1145
+ return paragraph.level > 0
1146
+ ? { text: paragraph.richText ?? paragraph.text, level: paragraph.level, ...(paragraph.description !== undefined ? {description: paragraph.description} : {}), ...start }
1147
+ : paragraph.description !== undefined ? { text: paragraph.richText ?? paragraph.text, description: paragraph.description, ...start }
1148
+ : start.start !== undefined ? { text: paragraph.richText ?? paragraph.text, ...start } : (paragraph.richText ?? paragraph.text);
1149
+ }),
1150
+ ...(numbering !== undefined ? {numbering} : {})
991
1151
  };
992
1152
  }
993
1153
  return { type: "text", text: joinNativeParagraphs(item.paragraphs) };
@@ -996,7 +1156,7 @@ function payloadFromSlideItem(item) {
996
1156
  return null;
997
1157
  }
998
1158
 
999
- function chartFromRelationship(entries, slidePath, relationships, relId) {
1159
+ function chartFromRelationship(entries, slidePath, relationships, relId, report) {
1000
1160
  const relationship = relationships.get(relId);
1001
1161
  if (!relationship?.path || !entries[relationship.path]) return null;
1002
1162
  const doc = parseRequiredXml(entries, relationship.path);
@@ -1013,7 +1173,13 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
1013
1173
 
1014
1174
  const budget = { cells: 0 };
1015
1175
  const cachePath = (index, role) => `${relationship.path}#c:ser[${index}]/${role}`;
1016
- if (chartNode.type === 'scatter') return scatterFromSeries(entries, relationship.path, series, budget, cachePath);
1176
+ // RR-35: axis titles, legend position and data labels read back into the chart's option fields.
1177
+ const withOptions = chart => {
1178
+ 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'});
1179
+ for (const note of notes) report?.(note);
1180
+ return chart && Object.keys(options).length ? {...chart, ...options} : chart;
1181
+ };
1182
+ if (chartNode.type === 'scatter') return withOptions(scatterFromSeries(entries, relationship.path, series, budget, cachePath));
1017
1183
  const labels = cachedValues(series[0]?.["c:cat"], cachePath(0, 'c:cat'), budget);
1018
1184
  const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, 'c:tx'), budget) ?? `Series ${index + 1}`);
1019
1185
  const values = series.map((entry, index) => {
@@ -1034,13 +1200,13 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
1034
1200
  ]);
1035
1201
  }
1036
1202
 
1037
- return {
1203
+ return withOptions({
1038
1204
  type: chartNode.type,
1039
1205
  data: {
1040
1206
  columns: [readChartCategoryHeading(entries,relationship.path) ?? "Category", ...names],
1041
1207
  rows
1042
1208
  }
1043
- };
1209
+ });
1044
1210
  }
1045
1211
 
1046
1212
  // Scatter charts share X values (c:xVal) across Y series (c:yVal). OPF keeps
@@ -1066,7 +1232,7 @@ function scatterFromSeries(entries, chartPart, series, budget, cachePath) {
1066
1232
  // A chartex part (cx:chartSpace): the series layoutIds name the kept OPF chart
1067
1233
  // type and the cached dimensions restore the category-major data, under the
1068
1234
  // same bounds as the classic chart cache.
1069
- function chartexFromRelationship(entries, relationships, relId) {
1235
+ function chartexFromRelationship(entries, relationships, relId, report) {
1070
1236
  const relationship = relationships.get(relId);
1071
1237
  if (!relationship?.path || !entries[relationship.path]) return null;
1072
1238
  const doc = parseRequiredXml(entries, relationship.path);
@@ -1074,6 +1240,7 @@ function chartexFromRelationship(entries, relationships, relId) {
1074
1240
  heading: readChartCategoryHeading(entries, relationship.path),
1075
1241
  limits: {points: MAX_CHART_CACHE_POINTS, cells: MAX_CHART_CACHE_CELLS},
1076
1242
  path: relationship.path,
1243
+ report,
1077
1244
  invalid: (message, location) => {
1078
1245
  throw new OPFPptxError('invalid-chart-cache', `Invalid chart cache: ${message}. Repair the chart data before importing.`, {path: location ?? relationship.path});
1079
1246
  }
@@ -1258,6 +1425,30 @@ function parseInput(input) {
1258
1425
  throw new OPFPptxError("invalid-input", "OPF input must be a parsed object, JSON string, or Uint8Array.");
1259
1426
  }
1260
1427
 
1428
+ // Template variables (core resolveVariables, RR-32): a deck that uses content variables, or a template, is resolved to a
1429
+ // concrete deck before export, so the PPTX holds exactly the text, numbers, dates and images the preview shows. A template
1430
+ // exports with each unfilled variable's example (reported as variable-example-used); a normal deck with an unfilled
1431
+ // required variable is refused. Decks without content variables are returned untouched. The package stores the resolved
1432
+ // deck, not the template form, so fromPptx returns the filled deck.
1433
+ function resolveTemplateInput(presentation, options) {
1434
+ if (typeof opfCore.resolveVariables !== "function" || !isPlainObject(presentation)) return presentation;
1435
+ const values = options.variables;
1436
+ if (values !== undefined && !isPlainObject(values)) {
1437
+ throw new OPFPptxError("invalid-variables", "The variables option must be an object keyed by variable id.", {path: "options.variables"});
1438
+ }
1439
+ const template = opfCore.isTemplate(presentation);
1440
+ if (!template && !opfCore.hasContentVariables(presentation) && !(values && Object.keys(values).length)) return presentation;
1441
+ const result = opfCore.resolveVariables(presentation, values ?? {}, {examples: template});
1442
+ const errors = result.diagnostics.filter(entry => entry.severity === "error");
1443
+ if (errors.length) {
1444
+ throw new OPFPptxError(errors.some(entry => entry.code === "variable-unfilled") ? "unfilled-variables" : "invalid-variables", errors[0].message, {issues: errors, path: errors[0].path});
1445
+ }
1446
+ for (const entry of result.diagnostics) {
1447
+ if (entry.code === "variable-example-used") options.onDiagnostic?.({code: "variable-example-used", path: entry.path, message: entry.message, id: entry.id});
1448
+ }
1449
+ return result.presentation;
1450
+ }
1451
+
1261
1452
  function assertValidBoundary(presentation) {
1262
1453
  const result = validatePresentation(presentation);
1263
1454
  if (!result.valid) {
@@ -1441,7 +1632,7 @@ function configurePresentation(pptx, presentation, context) {
1441
1632
  height: context.dimensions.heightInches
1442
1633
  });
1443
1634
  pptx.layout = context.layoutName;
1444
- pptx.author = normalizeAuthor(presentation.author) ?? "OpenPresentation";
1635
+ pptx.author = normalizeAuthor(presentation.author) ?? DEFAULT_AUTHOR;
1445
1636
  pptx.company = "OpenPresentation";
1446
1637
  pptx.subject = presentation.description ?? "";
1447
1638
  pptx.title = presentation.name ?? presentation.filename ?? "OPF Presentation";
@@ -1467,7 +1658,8 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1467
1658
  }
1468
1659
  if (backgroundDefinition?.type === 'image') {
1469
1660
  const imagePath = `${backgroundPath}.image`;
1470
- const outcome = {};
1661
+ // An SVG background is its PNG raster at the slide's size: a slide background picture fill carries no SVG.
1662
+ const outcome = { box: { w: slideContext.dimensions.widthInches, h: slideContext.dimensions.heightInches }, cover: (backgroundDefinition.image?.fit ?? 'cover') !== 'contain' };
1471
1663
  const resolved = await resolveImage(backgroundDefinition.image, presentation, options, imagePath, outcome);
1472
1664
  if (resolved) {
1473
1665
  // PptxGenJS embeds the raster and its relationship; packaging replaces
@@ -1497,7 +1689,10 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1497
1689
  // Core composition resolves one alignment per composed item for every engine
1498
1690
  // (FF-29); the design fallback keeps cores published before item.alignment working.
1499
1691
  const alignmentFor=item=>item.alignment??fieldAlignment(item.field);
1500
- const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, presentation, slideIndex, fonts: slideContext.fonts, contentAlignment, titleAlignment, textRasterPadding:options.textRasterPadding, contentBox:opfSlide.design?.contentBox??presentation.design?.contentBox, darkBackground: isDarkHex(slideContext.colors.background), textMeasurement: options.textMeasurement, date: options.date, socialPlatforms: socialPlatformRecords(presentation, options) });
1692
+ 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) };
1693
+ const geometry = composeSlide(opfSlide, composeOptions);
1694
+ // The default footer band (date left, text center, number right): where a footer added natively lands (RR-11).
1695
+ if (slideIndex === 0) context.defaultFooterOptions = composeOptions;
1501
1696
  for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
1502
1697
  // The content topology (groups, regions, root form, block ids) with the leaf boxes this geometry draws.
1503
1698
  recordContentTopology(context.documentProvenance, opfSlide, slideIndex, geometry.items);
@@ -1530,30 +1725,42 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1530
1725
  ...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),
1531
1726
  ...nativeFontOptions(item.textStyle),
1532
1727
  color: item.field === "tag" ? tagColor(itemContext) : itemContext.textColor,
1533
- align: alignmentFor(item),
1728
+ align: physicalAlignment(alignmentFor(item), item.text.directions?.[0]),
1729
+ ...(item.text.directions?.[0] === 'rtl' ? {rtlMode: true} : {}),
1534
1730
  objectName,
1535
1731
  breakLine: false
1536
1732
  });
1537
1733
  } else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
1538
- await addMeasuredList(slide,item.text,itemContext,item.path,item.bulletImage,presentation,slideIndex,options);
1734
+ await addMeasuredList(slide,item.text,itemContext,item.path,item.bulletImage,presentation,slideIndex,options,item.payload?.numbering!==undefined);
1539
1735
  } else if (item.field === "text" && item.text?.richLines) {
1540
- const alignment=item.text.placement?.alignment??alignmentFor(item)??'left';
1736
+ const logicalAlignment=item.text.placement?.alignment??alignmentFor(item)??'left';
1541
1737
  for(const [index,line] of item.text.richLines.entries()){
1542
1738
  const runs=line.fragments.map(fragment=>{
1543
1739
  const runColor=exportColor(fragment.run.color,itemContext,itemContext.colors.text);
1544
1740
  const color=nativeColor(fragment.run.color,runColor,itemContext,itemContext.textColor);
1545
- return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color,underline:fragment.run.underline?{style:'sng',color}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/fragment.fontSize*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}};
1741
+ 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}};
1546
1742
  });
1547
- const placed=item.text.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
1743
+ // Logical alignment (RR-05): a right-to-left paragraph starts at the right edge; every wrapped line shares its paragraph's direction.
1744
+ const placed=item.text.placement?.lines[index],alignment=placed?.alignment??physicalAlignment(logicalAlignment,item.text.directions?.[index]),factor=alignment==='right'?1:alignment==='center'?.5:0;
1548
1745
  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};
1746
+ // PptxGenJS reads the paragraph direction from the first run's options, not from the shape options.
1747
+ if(item.text.directions?.[index]==='rtl')for(const run of runs)run.options.rtlMode=true;
1549
1748
  if(runs.length)slide.addText(runs,{...textBoxOptions(area,itemContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
1550
1749
  }
1551
1750
  } else if (item.field === "text" && typeof item.value === "string") {
1552
- slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:alignmentFor(item)});
1751
+ 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])});
1553
1752
  } else {
1554
- await addPayload(slide, presentation, item.payload, region, item.path, { ...itemContext, composition: item.composition, contentAlignment: alignmentFor(item) ?? "left" }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
1753
+ // 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.
1754
+ const mediaNames = item.caption ? new Set([...context.imagePlacements.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()]) : undefined;
1755
+ 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);
1756
+ if (item.caption) {
1757
+ 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));
1758
+ addCaption(slide, item, mediaName, itemContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: item.caption.path}));
1759
+ }
1555
1760
  }
1556
1761
  }
1762
+ // RR-34: the footnote area core reserved above the footer band (rule plus one tagged text box per listed line).
1763
+ addFootnotes(slide, geometry.footnotes, slideIndex, slideContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: `slides.${slideIndex}`}));
1557
1764
  // Core composes furniture above all content and opf-render paints it last, so
1558
1765
  // spTree order (PowerPoint's z-order) matches: header/footer parts come after
1559
1766
  // every content item, and an overlapping footer stays visible over content.
@@ -1622,10 +1829,10 @@ async function addPayload(slide, presentation, payload, region, path, context, o
1622
1829
  addTablePayload(slide, payload.table, region, context, options, path);
1623
1830
  break;
1624
1831
  case "code":
1625
- addCodePayload(slide, payload.code, codeLayout, region, context, path);
1832
+ addCodePayload(slide, payload.code, codeLayout, region, context, path, options);
1626
1833
  break;
1627
1834
  case "metric":
1628
- addMetricPayload(slide, payload.metric,metricLayout,context,path);
1835
+ addMetricPayload(slide, payload.metric,metricLayout,context,path,options);
1629
1836
  break;
1630
1837
  case "quote":
1631
1838
  addQuotePayload(slide, payload.quote, quoteLayout, context, options, path);
@@ -1665,7 +1872,7 @@ function richLineRuns(line,color,native,context) {
1665
1872
  const fallback=color.replace(/^#/,'');
1666
1873
  return line.fragments.map(fragment=>{
1667
1874
  const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context,native);
1668
- return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color,underline:fragment.run.underline?{style:'sng',color}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/fragment.fontSize*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}};
1875
+ 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}};
1669
1876
  });
1670
1877
  }
1671
1878
  // Every native line of a list, marker paragraph or not, is named for the list's
@@ -1677,13 +1884,17 @@ function richLineRuns(line,color,native,context) {
1677
1884
  // PptxGenJS embeds the media and its relationship through one picture per slide (OPF bullet image); packaging removes
1678
1885
  // that picture and points each marker paragraph at the relationship with a:buBlip. An icon that cannot be embedded
1679
1886
  // keeps the character bullets, as the preview does.
1680
- async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,slideIndex,options) {
1887
+ async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,slideIndex,options,numbered=false) {
1888
+ // RR-33: a numbered list writes native auto-numbers (a:buAutoNum) at core's marker geometry. Core older than the numbering
1889
+ // release composes bullet markers, so nothing numbered can be written; say so instead of exporting bullets silently.
1890
+ if(numbered&&!fit.listEntries.some(entry=>entry.marker.number))options.onDiagnostic?.({code:'numbering-unsupported-core',path,message:'The installed @openpresentation/opf does not compose numbered lists (numbering), so the list is exported with bullets. Use a core release that supports numbering.'});
1681
1891
  let picture=false;
1682
1892
  if(bulletImage){
1683
1893
  const part=`ppt/slides/slide${slideIndex+1}.xml`;
1684
1894
  if(context.bulletImages.has(part))picture=context.bulletImages.get(part);
1685
1895
  else{
1686
- const outcome={};
1896
+ // A picture bullet (a:buBlip) can only reference a raster: an SVG icon is its PNG fallback.
1897
+ const outcome={box:{w:.5,h:.5}};
1687
1898
  const resolved=await resolveImage(bulletImage.source,presentation,options,bulletImage.path,outcome);
1688
1899
  if(resolved){
1689
1900
  slide.addImage({...resolved,objectName:bulletImageName(),x:0,y:0,w:.1,h:.1,altText:''});
@@ -1697,16 +1908,22 @@ async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,s
1697
1908
  const addLines=(text,box,color,native,withBullet)=>{
1698
1909
  text.richLines.forEach((line,index)=>{
1699
1910
  const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
1700
- const region={x:(box.x-inset)/96,y:(box.y+line.y)/96,w:(box.width+inset)/96,h:line.height/96};
1911
+ // Right to left (RR-05): the bullet column is at the right, so the first line's box extends right to hold it and the paragraph
1912
+ // is rtl with its text aligned to the right; marL/indent are the start-side margin and hanging indent in PowerPoint.
1913
+ const entryRtl=entry.direction==='rtl';
1914
+ const region={x:(entryRtl?box.x:box.x-inset)/96,y:(box.y+line.y)/96,w:(box.width+inset)/96,h:line.height/96};
1915
+ const lineDirection=(text.directions?.[index])??entry.direction;
1916
+ const lineAlign=physicalAlignment('left',lineDirection);
1701
1917
  const objectName=`OPF list ${path} line ${lineNumber++}`;
1702
- if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:pptxColor(context.textColor),picture});
1703
- const paragraph=first?{bullet:{characterCode:entry.marker.text.codePointAt(0).toString(16).padStart(4,'0'),indent:entry.marker.indent*.75},indentLevel:level}:{bullet:false};
1918
+ const number=first?entry.marker.number:undefined;
1919
+ if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:pptxColor(context.textColor),picture:number?false:picture});
1920
+ const paragraph=first?{bullet:number?{type:'number',style:autoNumScheme(number.style,number.suffix),startAt:number.value,indent:entry.marker.indent*.75}:{characterCode:entry.marker.text.codePointAt(0).toString(16).padStart(4,'0'),indent:entry.marker.indent*.75},indentLevel:level}:{bullet:false};
1704
1921
  const runs=richLineRuns(line,color,native,context);
1705
1922
  if(!runs.length)runs.push({text:'',options:{}});
1706
1923
  // Keep paragraph intent identical across runs. ZIP normalization below
1707
1924
  // removes the duplicate paragraph-property nodes emitted by PptxGenJS.
1708
- for(const run of runs)Object.assign(run.options,paragraph);
1709
- slide.addText(runs,{...textBoxOptions(region,context,text.fontSize*.75),fontFace:nativeFontOptions(entry.marker.style).fontFace,objectName,align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,...paragraph});
1925
+ for(const run of runs)Object.assign(run.options,paragraph,lineDirection==='rtl'?{rtlMode:true}:{});
1926
+ slide.addText(runs,{...textBoxOptions(region,context,text.fontSize*.75),fontFace:nativeFontOptions(entry.marker.style).fontFace,objectName,align:lineAlign,fit:'none',wrap:false,lineSpacingMultiple:1,...paragraph});
1710
1927
  });
1711
1928
  };
1712
1929
  addLines(entry.text,entry.textBox,context.colors.text,context.textColor,true);
@@ -1754,13 +1971,15 @@ function addListPayload(slide, items, region, context) {
1754
1971
  }
1755
1972
 
1756
1973
  async function addImagePayload(slide, presentation, asset, region, path, context, options) {
1757
- const resolved = await resolveImage(asset, presentation, options, path);
1974
+ const outcome = { box: region, cover: context.imageFill === 'crop' };
1975
+ const resolved = await resolveImage(asset, presentation, options, path, outcome);
1758
1976
  if (!resolved) {
1759
1977
  addImagePlaceholder(slide, presentation, asset, region, path, context, options);
1760
1978
  return;
1761
1979
  }
1762
1980
  const objectName = `OPF image ${context.imagePlacements.size + 1}`;
1763
1981
  context.imagePlacements.set(objectName, { region, mode: context.imageFill, path });
1982
+ if (outcome.svg) context.svgPictures.set(objectName, outcome.svg);
1764
1983
  context.pictureText.set(objectName, pictureText(assetAlt(asset, presentation), asset, presentation));
1765
1984
  slide.addImage({
1766
1985
  ...resolved,
@@ -1789,16 +2008,18 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
1789
2008
  notExported('The watermark needs an image source (a string, or an object with src); no watermark was exported for this slide.');
1790
2009
  return;
1791
2010
  }
1792
- const outcome = {};
2011
+ const {widthInches, heightInches} = slideContext.dimensions;
2012
+ const box = watermarkBox(widthInches, heightInches);
2013
+ const outcome = {box};
1793
2014
  const resolved = await resolveImage(asset, presentation, options, path, outcome);
1794
2015
  if (!resolved) {
1795
2016
  if (!outcome.reported) options.onDiagnostic?.({code: 'unresolved-asset', path, message: 'The watermark image needs an embedded raster, a declared asset or a host imageResolver; no watermark was exported for this slide.'});
1796
2017
  return;
1797
2018
  }
1798
- const {widthInches, heightInches} = slideContext.dimensions;
1799
- const box = watermarkBox(widthInches, heightInches);
1800
2019
  const opacity = watermarkOpacity(watermark);
1801
2020
  context.watermarks.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, box, opacity, path});
2021
+ // Opacity is an a:alphaModFix on the blip, which PowerPoint applies to an SVG picture (native check 2026-10-01).
2022
+ if (outcome.svg) context.svgPictures.set(`ppt/slides/slide${slideIndex + 1}.xml|${watermarkName()}`, outcome.svg);
1802
2023
  slide.addImage({...resolved, objectName: watermarkName(), ...box, altText: assetAlt(asset, presentation) ?? 'Watermark'});
1803
2024
  }
1804
2025
 
@@ -1808,7 +2029,7 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
1808
2029
  // PptxGenJS embeds the bytes. An unresolved source draws the preview's "Image unavailable" panel in the logo box.
1809
2030
  async function addLogo(slide, presentation, logo, slideIndex, slideContext, context, options) {
1810
2031
  const region = {x: logo.box.x / 96, y: logo.box.y / 96, w: logo.box.width / 96, h: logo.box.height / 96};
1811
- const outcome = {};
2032
+ const outcome = {box: region};
1812
2033
  const resolved = await resolveImage(logo.source, presentation, options, logo.path, outcome);
1813
2034
  if (!resolved) {
1814
2035
  if (!outcome.reported) options.onDiagnostic?.({code: 'unresolved-asset', path: logo.path, message: 'The logo needs an embedded raster, a declared asset or a host imageResolver; the logo panel shows "Image unavailable" instead.'});
@@ -1816,7 +2037,8 @@ async function addLogo(slide, presentation, logo, slideIndex, slideContext, cont
1816
2037
  if (prefix) context.logoPlaceholderTags.set(prefix, {v: 1, role: 'placeholder', slide: `slides.${slideIndex}`, path: logo.path});
1817
2038
  return;
1818
2039
  }
1819
- context.logos.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, box: region, path: logo.path, variant: logo.variant});
2040
+ context.logos.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, box: region, path: logo.path, variant: logo.variant, anchor: logo.anchor ?? 'left'});
2041
+ if (outcome.svg) context.svgPictures.set(`ppt/slides/slide${slideIndex + 1}.xml|${logoName()}`, outcome.svg);
1820
2042
  slide.addImage({...resolved, objectName: logoName(), ...region, altText: assetAlt(logo.source, presentation) ?? 'Logo'});
1821
2043
  }
1822
2044
 
@@ -1824,7 +2046,8 @@ async function addLogo(slide, presentation, logo, slideIndex, slideContext, cont
1824
2046
  // Crop/fit and treatments are written after PptxGenJS embeds the bytes.
1825
2047
  async function addSlideImage(slide, presentation, image, slideIndex, context, options) {
1826
2048
  const box = { x: image.box.x / 96, y: image.box.y / 96, w: image.box.width / 96, h: image.box.height / 96 };
1827
- const resolved = await resolveImage(image.value, presentation, options, image.sourcePath);
2049
+ const outcome = { box, cover: image.fill === 'crop' };
2050
+ const resolved = await resolveImage(image.value, presentation, options, image.sourcePath, outcome);
1828
2051
  if (!resolved) {
1829
2052
  addImagePlaceholder(slide, presentation, image.value, box, image.sourcePath, context, options);
1830
2053
  return;
@@ -1849,6 +2072,14 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
1849
2072
  // FF-53: a root `image` with the slide image's source is the slide image (core `replacesContent`); the
1850
2073
  // manifest records that so an unchanged picture imports back as both design.slideImage and slide.image.
1851
2074
  context.slideImages.set(objectName, { slide: `slides.${slideIndex}`, box, fill: image.fill, path: image.sourcePath, treatment: { ...treatment, position: image.position }, effects, content: image.replacesContent === true });
2075
+ // PowerPoint applies opacity (a:alphaModFix), grayscale (a:grayscl) and the border (a:ln) to an SVG picture (native check
2076
+ // 2026-10-01). A duotone recolor and a non-rectangular mask are not confirmed on an SVG picture: with either the SVG stays its
2077
+ // PNG raster, so the effect applies as in the preview.
2078
+ if (outcome.svg) {
2079
+ const treated = [effects.recolor?.type === 'duotone' && 'duotone recolor', effects.shape && effects.shape.preset !== 'rect' && 'shape'].filter(Boolean);
2080
+ if (treated.length) options.onDiagnostic?.({ code: 'svg-image-rasterized', path: image.sourcePath, message: `An SVG slide image with ${treated.join(', ')} exports as its PNG raster, so the effect applies as in the preview; PowerPoint has not been confirmed to apply it to an SVG picture.` });
2081
+ else context.svgPictures.set(objectName, outcome.svg);
2082
+ }
1852
2083
  const slideImageAlt = image.alt ?? assetAlt(image.value, presentation);
1853
2084
  context.pictureText.set(objectName, pictureText(slideImageAlt, image.value, presentation));
1854
2085
  slide.addImage({ ...resolved, objectName, ...box, altText: slideImageAlt });
@@ -1862,9 +2093,14 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
1862
2093
  // FF-62: chart text is one size. The preview draws every chart label (axis, legend, data label) at
1863
2094
  // max(14, composition.minFontSize ?? 16) px scaled by the slide's shorter side over 720 px (renderer charts.js, `fontPx`),
1864
2095
  // the readability floor: 12 pt on a 13.33 x 7.5 in slide. The export writes that size, in hundredths of a point (DrawingML `sz`).
2096
+ // RR-16: core composes every font size on PowerPoint's 0.01 pt grid (1/75 px), so the preview draws the grid size, not the raw
2097
+ // product: the floor rounds up (core `snapFontSizeUp`) and an unfloored request of 14 px rounds down (`snapFontSizeDown`).
2098
+ // Math.round differed from the preview by 0.01 pt on slide sizes whose scale is not a whole number (A4, Letter, 16:10).
1865
2099
  function chartTextSize(context) {
1866
2100
  const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
1867
- return Math.round(Math.max(14, context.composition?.minFontSize ?? 16) * scale * 75);
2101
+ const floor = context.composition?.minFontSize ?? 16;
2102
+ const hundredths = Math.max(14, floor) * scale * 75;
2103
+ return floor >= 14 ? Math.ceil(hundredths - 1e-6) : Math.floor(hundredths + 1e-6);
1868
2104
  }
1869
2105
 
1870
2106
  // Every c:txPr of a generated classic chart carries the chart text size: PptxGenJS writes no size on the legend (PowerPoint's
@@ -1883,6 +2119,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1883
2119
  return;
1884
2120
  }
1885
2121
  for (const {adaptation, message} of chartData.adaptations ?? []) options.onDiagnostic?.({code: "chart-data-adapted", path, message, adaptation});
2122
+ // RR-35: axis titles, legend position and data labels (core resolves them against what the chart type can show).
2123
+ const chartOptions = resolveChartOptionsFor(chart);
2124
+ reportChartOptionDiagnostics(chartOptions, path, options.onDiagnostic);
1886
2125
 
1887
2126
  // Keep the resolved palette surface (including alpha) explicit in native
1888
2127
  // chart/plot areas, so inherited labels are assessed against their own panel.
@@ -1893,19 +2132,27 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1893
2132
  const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
1894
2133
  const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
1895
2134
  const textSize = chartTextSize(context);
1896
- context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor,spec:chartData.spec,textSize});
2135
+ context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor,spec:chartData.spec,textSize,
2136
+ options:chartOptions,kind:chartTargetFor(chart.type)?.kind,pointCount:chart.data.rows.length});
1897
2137
  context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
1898
- const palette = CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`)));
2138
+ const preferredPalette = CHART_COLORS.map(color=>`#${color}`);
2139
+ const palette = (typeof opfComposition.chartPaletteForFill === "function" ? opfComposition.chartPaletteForFill(panelFill, preferredPalette) : preferredPalette.map(color=>chartColorForFill(panelFill,color))).map(color=>normalizeHex(color));
2140
+ context.chartPalettes.set(objectName,palette);
1899
2141
  if (chartData.chartex) {
1900
2142
  // The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
1901
2143
  // PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
1902
2144
  const series = chartData.series.map((entry) => ({name: entry.name, labels: [...entry.labels], values: [...entry.values]}));
1903
- context.chartex.set(objectName, {spec: chartData.chartex, series, hasCategories: chartData.hasCategories, fill, labelColor, gridColor: context.colors.border, font: context.fonts.body, palette, textSize});
2145
+ context.chartex.set(objectName, {spec: chartData.chartex, series, hasCategories: chartData.hasCategories, fill, labelColor, gridColor: context.colors.border, font: context.fonts.body, palette, textSize, options: chartOptions});
1904
2146
  if (chartData.chartex.layoutId === 'regionMap') {
1905
2147
  options.onDiagnostic?.({code: "chart-map-geodata", path, message: `The '${stringifyText(chart.type)}' chart is exported as a native PowerPoint map (chartex regionMap) without cached geography (no cx:geoCache; provider data is never fabricated): PowerPoint must fetch the region shapes from its online map service when the deck is opened, and until it does it shows "There was a problem getting the information for your map chart" and draws nothing. The clustered column fallback shows the same values in readers without chartex support.`});
1906
2148
  }
1907
2149
  }
1908
2150
  const percent = chartData.spec.grouping === 'percentStacked';
2151
+ // RR-36: a series has one colour, as the preview draws it (opf-render paints series j with palette colour j; only pie,
2152
+ // doughnut and treemap slices take a colour per category). PptxGenJS writes a c:dPt per point, cycling the palette, for a
2153
+ // bar chart with one series whenever chartColors is a custom array of more than one colour, so PowerPoint drew each
2154
+ // column of a single-series column or bar chart in a different colour. One colour for that series writes no c:dPt.
2155
+ const chartColors = chartData.type === 'bar' && chartData.series.length === 1 ? palette.slice(0, 1) : palette;
1909
2156
  slide.addChart(chartData.type, chartData.series, {
1910
2157
  objectName,
1911
2158
  x: region.x,
@@ -1913,8 +2160,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1913
2160
  w: region.w,
1914
2161
  h: region.h,
1915
2162
  showLegend: circular || chartData.series.length > (chartData.type === 'scatter' ? 2 : 1),
2163
+ ...classicChartOptions(chartOptions, {labelColor, font: context.fonts.body, textSize}),
1916
2164
  showTitle: false,
1917
- chartColors: palette,
2165
+ chartColors,
1918
2166
  chartArea: {fill:{...fill},roundedCorners:false},
1919
2167
  // Paint alpha once in the chart area, rather than stacking two alpha fills.
1920
2168
  plotArea: {fill:{color:null}},
@@ -1933,6 +2181,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1933
2181
  valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
1934
2182
  barDir: chartData.barDir,
1935
2183
  barGrouping: chartData.barGrouping,
2184
+ // Right to left (RR-05): column, line and area charts reverse their categories (c:catAx orientation maxMin), which also moves the
2185
+ // value axis to the right, as the preview draws them. Bar charts keep their vertical category axis.
2186
+ ...(context.direction === 'rtl' && ((chartData.type === 'bar' && chartData.barDir === 'col') || chartData.type === 'line' || chartData.type === 'area') ? { catAxisOrientation: 'maxMin' } : {}),
1936
2187
  ...(percent ? { valAxisLabelFormatCode: '0%' } : {}),
1937
2188
  ...(chartData.spec.markers === undefined ? {} : { lineDataSymbol: chartData.spec.markers ? 'circle' : 'none' }),
1938
2189
  ...(chartData.spec.radarStyle ? { radarStyle: chartData.spec.radarStyle } : {}),
@@ -1952,7 +2203,8 @@ function addTablePayload(slide, table, region, context, options, path) {
1952
2203
  }
1953
2204
 
1954
2205
  const layout = layoutTable(table, {x:region.x*96,y:region.y*96,width:region.w*96,height:region.h*96}, {
1955
- scale, minFontSize:context.composition?.minFontSize, fontFamily:context.fonts.body, textMeasurement:options.textMeasurement, path
2206
+ scale, minFontSize:context.composition?.minFontSize, fontFamily:context.fonts.body, textMeasurement:options.textMeasurement, path,
2207
+ ...(context.direction === 'rtl' ? {direction: 'rtl'} : {})
1956
2208
  });
1957
2209
  const columnCount = layout.columnCount;
1958
2210
  const rows = layout.rows.map(row => row.cells.map(cell => {
@@ -2001,7 +2253,7 @@ function addTablePayload(slide, table, region, context, options, path) {
2001
2253
  ...(rich ? {bold:false,italic:false} : {}),
2002
2254
  lineSpacing: fit.lineHeight * 0.75,
2003
2255
  paraSpaceAfter: 0,
2004
- align: cellStyle.align ?? context.contentAlignment,
2256
+ align: physicalAlignment(cellStyle.align ?? context.contentAlignment, cell.direction),
2005
2257
  valign: cellStyle.verticalAlign ?? 'top',
2006
2258
  ...(cell.colSpan > 1 ? {colspan:cell.colSpan} : {}),
2007
2259
  ...(cell.rowSpan > 1 ? {rowspan:cell.rowSpan} : {}),
@@ -2060,7 +2312,8 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
2060
2312
  const link = config.links?.[sourceLineIndex];
2061
2313
  if (fit.sourceLines?.[index]?.boundary === 'hard') sourceLineIndex += 1;
2062
2314
  if (!line&&!config.heading&&!config.sourceText&&!config.timeline&&!config.quote&&!config.keepEmpty) continue;
2063
- const alignment=fit.placement?.alignment??config.align??context.contentAlignment??'left',placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
2315
+ // Logical alignment (RR-05): a right-to-left line starts at the right edge; furniture and metrics pass physical alignments and report no line directions.
2316
+ const placed=fit.placement?.lines[index],alignment=placed?.alignment??physicalAlignment(fit.placement?.alignment??config.align??context.contentAlignment??'left',fit.directions?.[index]),factor=alignment==='right'?1:alignment==='center'?.5:0;
2064
2317
  const sourceLine=fit.sourceLines?.[index],boundary=sourceLine?{boundary:sourceLine.boundary,separator:String(text).slice(sourceLine.end,sourceLine.nextStart)}:{};
2065
2318
  const objectName=config.heading?`OPF heading ${config.path} line ${index}`:config.timeline?`OPF timeline ${config.timeline.group} part ${config.timeline.part} line ${index}`:config.quote?`OPF quote ${config.quote.group} part ${config.quote.part} line ${index}`:config.sourceText?`OPF text ${config.path} line ${index}`:config.objectName?`${config.objectName} line ${index}`:undefined;
2066
2319
  if(config.heading)context.headingTags.set(objectName,{v:1,group:config.path,field:config.heading,line:index,count:fit.lines.length,...boundary});
@@ -2079,6 +2332,7 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
2079
2332
  ...textBoxOptions(area, context, fit.fontSize * .75),
2080
2333
  ...nativeFontOptions(style),
2081
2334
  color: lineColor, align: alignment,
2335
+ ...(fit.directions?.[index]==='rtl'?{rtlMode:true}:{}),
2082
2336
  // Reuse the frame's existing relationship to cover blank caption boxes.
2083
2337
  // PptxGenJS also inherits it into runs; keep their native text styling.
2084
2338
  hyperlink: config.hyperlink,
@@ -2090,6 +2344,17 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
2090
2344
  }
2091
2345
  }
2092
2346
 
2347
+ // The default footer band core composes for a deck with no footer of its own: where a footer added natively lands.
2348
+ function defaultFooterParts(composeOptions) {
2349
+ try {
2350
+ const slide = {design: {footer: {left: {date: '2026-01-01'}, center: {text: 'Footer'}, right: {slideNumber: true}}}};
2351
+ // Only the zone boxes and line height are used, and they do not depend on text widths: estimated measurement keeps the host's measurer (and its call count) out of this.
2352
+ return (composeSlide(slide, {...composeOptions, textMeasurement: undefined}).furniture?.parts ?? []).filter(part => part.kind === 'footer' && part.type === 'text');
2353
+ } catch {
2354
+ return undefined;
2355
+ }
2356
+ }
2357
+
2093
2358
  async function addFurniture(slide,presentation,source,layout,context,options,slideIndex) {
2094
2359
  if(!layout){
2095
2360
  if(['header','footer'].some(kind=>(source.design?.[kind]??presentation.design?.[kind])))throw new OPFPptxError('missing-furniture-layout','Header/footer export requires coordinated core furniture geometry.',{path:`slides.${slideIndex}.design`});
@@ -2098,6 +2363,8 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
2098
2363
  return;
2099
2364
  }
2100
2365
  const staticDates = new Map();
2366
+ // RR-11: the first footer text, date and slide number that fit one line are native placeholders (src/native-furniture.js).
2367
+ const natives = nativeFurnitureParts(layout);
2101
2368
  for(const [index,part]of layout.parts.entries()){
2102
2369
  if(part.type==='image'){
2103
2370
  const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
@@ -2125,15 +2392,16 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
2125
2392
  }
2126
2393
  }
2127
2394
  addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.mutedColor,keepEmpty:true,objectName:`OPF furniture ${slideIndex} part ${index}`,furniture:{group:String(slideIndex),part:manifestPartIndex(layout.parts,index)},liveFields,links:part.links});
2395
+ if(natives.has(index))context.nativeFurniture.set(`OPF furniture ${slideIndex} part ${index} line 0`,natives.get(index));
2128
2396
  }
2129
2397
  }
2130
- const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates);
2398
+ const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates, natives);
2131
2399
  if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
2132
2400
  }
2133
2401
 
2134
2402
  const bulletImageName = () => 'OPF bullet image';
2135
2403
 
2136
- function addCodePayload(slide, value, layout, region, context, path) {
2404
+ function addCodePayload(slide, value, layout, region, context, path, options) {
2137
2405
  if (!layout) throw new OPFPptxError('missing-code-layout', 'Code export requires a coordinated core build with shared code geometry.', {path});
2138
2406
  for (const part of layout.parts) {
2139
2407
  // XML 1.0 Char excludes controls and unpaired UTF-16 surrogates. The u flag
@@ -2144,6 +2412,7 @@ function addCodePayload(slide, value, layout, region, context, path) {
2144
2412
  const group = String(context.codeTags.size + 1), panelName = `OPF code ${group} panel`;
2145
2413
  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});
2146
2414
  context.codeTags.set(panelName,codeManifest(value,layout,group));
2415
+ const syntax = codeSyntax(value, layout, context, path, options);
2147
2416
  slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', width: .75}, objectName:panelName});
2148
2417
  for (const [partIndex,part] of layout.parts.entries()) {
2149
2418
  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});
@@ -2151,19 +2420,37 @@ function addCodePayload(slide, value, layout, region, context, path) {
2151
2420
  const tabStops=line.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'}));
2152
2421
  const objectName = `OPF code ${group} ${part.role} line ${index+1}`;
2153
2422
  context.codeTags.set(objectName,{v:1,group,role:'line',part:partIndex,line:index});
2154
- slide.addText(part.text.slice(line.start,line.end),{
2423
+ // RR-07: the same token ranges and palette the preview paints; the text of the runs is the line text, unchanged.
2424
+ const runs = syntax && part.role==='body' ? opfCore.codeLineRuns(syntax.tokens,line.start,line.end,part.text) : undefined;
2425
+ const lineText = part.text.slice(line.start,line.end);
2426
+ const lineOptions = {
2155
2427
  ...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),
2156
2428
  ...nativeFontOptions(part.style),
2157
2429
  color:part.role==='body'?'E5E7EB':'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
2158
2430
  tabStops:tabStops.length?tabStops:undefined,objectName,
2159
- });
2431
+ };
2432
+ // 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.
2433
+ 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);
2160
2434
  }
2161
2435
  }
2162
2436
  }
2163
2437
 
2164
- function addMetricPayload(slide,value,layout,context,path) {
2438
+ // Token ranges and palette for the code body, from core (the preview calls the same functions); undefined for plain code.
2439
+ function codeSyntax(value, layout, context, path, options) {
2440
+ const body = layout.parts.find(part => part.role === 'body');
2441
+ const language = typeof value?.language === 'string' ? value.language : layout.parts.find(part => part.role === 'language')?.text;
2442
+ if (!body || !language) return undefined;
2443
+ if (typeof opfCore.tokenizeCode !== 'function') return undefined;
2444
+ const tokens = opfCore.tokenizeCode(body.text, language);
2445
+ return tokens.length ? {tokens, palette: opfCore.codeSyntaxPaletteForScheme(context.colorScheme)} : undefined;
2446
+ }
2447
+
2448
+ function addMetricPayload(slide,value,layout,context,path,options) {
2165
2449
  if (!layout) throw new OPFPptxError('missing-metric-layout','Metric export requires coordinated core metric geometry.',{path});
2166
2450
  const group=String(context.metricTags.size+1),manifest=metricManifest(value,layout,group);
2451
+ // RR-07: a trend colours the trend and delta text and adds one native arrow shape, from core's accepted geometry.
2452
+ const trendMark=typeof opfCore.metricTrendMark==='function'?opfCore.metricTrendMark(layout,{background:`#${normalizeHex(context.colors.background)}`}):undefined;
2453
+ const partColor=part=>trendMark&&(part.role==='trend'||part.role==='delta')?trendMark.color.slice(1):part.role==='value'?context.colors.accent:context.textColor;
2167
2454
  for (const [partIndex,part] of layout.parts.entries()) {
2168
2455
  const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
2169
2456
  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});
@@ -2181,11 +2468,17 @@ function addMetricPayload(slide,value,layout,context,path) {
2181
2468
  slide.addText(part.text.slice(line.start,line.end),{
2182
2469
  ...textBoxOptions({x:(anchor-part.box.width*factor)/96,y:(origin.baseline-part.fit.fontSize)/96,w:part.box.width/96,h:part.fit.lineHeight/96},context,part.fit.fontSize*.75),
2183
2470
  ...nativeFontOptions(part.style),
2184
- color:part.role==='value'?context.colors.accent:context.textColor,align:layout.alignment,fit:'none',wrap:false,lineSpacingMultiple:1,
2471
+ color:partColor(part),align:layout.alignment,...(part.fit.directions?.[index]==='rtl'?{rtlMode:true}:{}),fit:'none',wrap:false,lineSpacingMultiple:1,
2185
2472
  tabStops:tabStops.length?tabStops:undefined,objectName,
2186
2473
  });
2187
2474
  }
2188
2475
  }
2476
+ if(trendMark) {
2477
+ const objectName=`OPF metric ${group} trend mark`;
2478
+ context.metricTags.set(objectName,{v:1,group,role:'mark'});
2479
+ context.metricDescriptions.set(objectName,trendMark.ariaLabel);
2480
+ slide.addShape(trendMark.shape,{objectName,x:trendMark.box.x/96,y:trendMark.box.y/96,w:trendMark.box.width/96,h:trendMark.box.height/96,fill:{color:trendMark.color.slice(1)},line:{transparency:100},altText:trendMark.ariaLabel});
2481
+ }
2189
2482
  }
2190
2483
 
2191
2484
  function addQuotePayload(slide, value, layout, context, options, path) {
@@ -2411,6 +2704,9 @@ function textBoxOptions(region, context, fontSize) {
2411
2704
  };
2412
2705
  }
2413
2706
 
2707
+ // The private text helpers annotation-export.js writes captions and footnote lines with (RR-34).
2708
+ const exportHelpers = {textBoxOptions, nativeFontOptions, exportColor, nativeColor};
2709
+
2414
2710
  function textRuns(value, context, fallbackFontSize) {
2415
2711
  const runs = Array.isArray(value) ? value : [value];
2416
2712
  return runs.map((run) => {
@@ -2558,17 +2854,32 @@ function histogramBins(values) {
2558
2854
  }
2559
2855
  }
2560
2856
 
2561
- // Embedded bytes must be a raster the exporter can measure and PptxGenJS can embed (PNG, JPEG, GIF, WebP). An SVG, or
2562
- // bytes that are no readable image, would otherwise fail deep inside packaging. Like an unresolved asset it is not
2563
- // exported as a picture: the caller draws the preview's placeholder (or omits the picture) and one `unresolved-asset`
2564
- // diagnostic names the cause; `strictAssets` keeps the `unsupported-image-dimensions` error. `outcome.reported` tells a
2565
- // caller that the diagnostic was already emitted. Hosts that need SVG art supply a raster through `imageResolver`
2566
- // (for example opf-render's `svgToPng`). Local paths are read by PptxGenJS and checked after embedding.
2857
+ // Embedded bytes must be a raster the exporter can measure and PptxGenJS can embed (PNG, JPEG, GIF, WebP), or an SVG,
2858
+ // which exports as a native SVG picture over a PNG fallback raster (see resolveSvgImage). Bytes that are no readable
2859
+ // image would otherwise fail deep inside packaging. Like an unresolved asset they are not exported as a picture: the
2860
+ // caller draws the preview's placeholder (or omits the picture) and one `unresolved-asset` diagnostic names the cause;
2861
+ // `strictAssets` keeps the `unsupported-image-dimensions` error. `outcome.reported` tells a caller that the diagnostic
2862
+ // was already emitted. `outcome.box` (inches, in) sizes an SVG's fallback raster; `outcome.svg` (out) carries the SVG to
2863
+ // attach to the picture the caller adds. Local paths are read by PptxGenJS and checked after embedding.
2567
2864
  async function resolveImage(asset, presentation, options, path, outcome = {}) {
2568
2865
  const resolved = await resolveImageSource(asset, presentation, options, path);
2866
+ // A local SVG file is read here (PptxGenJS cannot size it); a raster path is read by PptxGenJS itself.
2867
+ if (resolved?.path && /\.svg$/i.test(resolved.path)) {
2868
+ let file;
2869
+ try { file = await readLocalFile(resolved.path); }
2870
+ catch (error) {
2871
+ if (options.strictAssets) throw new OPFPptxError("invalid-svg-image", `The SVG file could not be read (${errorMessage(error)}).`, { path, reason: "svg-unreadable" });
2872
+ options.onDiagnostic?.({ code: "unresolved-asset", path, reason: "svg-unreadable", message: `The SVG file could not be read (${errorMessage(error)}). The image exports as the unavailable-image placeholder.` });
2873
+ outcome.reported = true;
2874
+ return null;
2875
+ }
2876
+ return resolveSvgImage(file, options, path, outcome);
2877
+ }
2569
2878
  if (!resolved?.data) return resolved;
2570
2879
  const bytes = dataUriBytes(resolved.data);
2571
2880
  if (bytes && rasterMetadata(bytes)) return resolved;
2881
+ const svg = svgDataUriBytes(resolved.data);
2882
+ if (svg) return resolveSvgImage(svg, options, path, outcome);
2572
2883
  const message = `The image is not a readable PNG, JPEG, GIF or WebP (${dataUriMediaType(resolved.data) ?? "unknown type"}); supply a raster through imageResolver (for example opf-render svgToPng).`;
2573
2884
  if (options.strictAssets) throw new OPFPptxError("unsupported-image-dimensions", `Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. ${message}`, { path });
2574
2885
  options.onDiagnostic?.({ code: "unresolved-asset", path, reason: "unsupported-format", message });
@@ -2576,6 +2887,44 @@ async function resolveImage(asset, presentation, options, path, outcome = {}) {
2576
2887
  return null;
2577
2888
  }
2578
2889
 
2890
+ // An SVG picture is PowerPoint 2016's native form: the SVG itself (sanitized, never executed or fetched) plus a PNG
2891
+ // fallback raster drawn from it at 192 dpi of the displayed size by options.svgRasterizer (default: opf-render's resvg,
2892
+ // deterministic, with its bundled fonts). The caller adds the PNG as the picture and attaches `outcome.svg` at packaging.
2893
+ // A malformed SVG, an SVG without an intrinsic size or without a rasterizer exports like any unreadable image.
2894
+ async function resolveSvgImage(source, options, path, outcome) {
2895
+ const unresolved = (code, reason, message) => {
2896
+ if (options.strictAssets) throw new OPFPptxError(code, message, { path, reason });
2897
+ options.onDiagnostic?.({ code: "unresolved-asset", path, reason, message: `${message} The image exports as the unavailable-image placeholder.` });
2898
+ outcome.reported = true;
2899
+ return null;
2900
+ };
2901
+ if (source.error) return unresolved("invalid-svg-image", "svg-malformed", source.error);
2902
+ const prepared = prepareSvg(source);
2903
+ if (prepared.error) return unresolved("invalid-svg-image", prepared.error.reason, prepared.error.message);
2904
+ if (prepared.removed.length) {
2905
+ options.onDiagnostic?.({ code: "svg-sanitized", path, message: `The SVG had ${prepared.removed.join(", ")} removed before it was embedded: an exported SVG never runs code or refers to anything outside the file.` });
2906
+ }
2907
+ const scale = svgRasterScale(prepared, outcome.box, outcome.cover === true);
2908
+ const width = Math.max(1, Math.round(prepared.width * scale)), height = Math.max(1, Math.round(prepared.height * scale));
2909
+ const cacheKey = `${scale}|${prepared.text}`;
2910
+ let png = options.svgRasters.get(cacheKey);
2911
+ if (!png) {
2912
+ try {
2913
+ const rasterize = options.svgRasterizer ?? ((svg, hint) => svgToPng(svg, hint));
2914
+ png = await rasterize(prepared.text, { width, height, scale, text: prepared.hasText });
2915
+ if (!(png instanceof Uint8Array) || rasterMetadata(png)?.mediaType !== "image/png") throw Object.assign(new Error("The rasterizer did not return a PNG."), { code: "svg-render-failed" });
2916
+ } catch (error) {
2917
+ const reason = error?.code === "svg-rasterizer-unavailable" ? "svg-rasterizer-unavailable" : "svg-render-failed";
2918
+ return unresolved(reason, reason, reason === "svg-rasterizer-unavailable"
2919
+ ? "An SVG image needs a rasterizer for its PNG fallback: install the optional peer @openpresentation/opf-render, or pass options.svgRasterizer (or an imageResolver that returns a raster)."
2920
+ : `The SVG image could not be rasterized for its PNG fallback (${errorMessage(error)}).`);
2921
+ }
2922
+ options.svgRasters.set(cacheKey, png);
2923
+ }
2924
+ outcome.svg = { bytes: prepared.bytes, width: prepared.width, height: prepared.height };
2925
+ return { data: `data:image/png;base64,${bytesToBase64(png)}` };
2926
+ }
2927
+
2579
2928
  function dataUriMediaType(uri) {
2580
2929
  return /^data:([^;,]+)/i.exec(uri)?.[1]?.toLowerCase();
2581
2930
  }
@@ -2812,6 +3161,27 @@ function isDarkHex(value) {
2812
3161
  return (red * 299 + green * 587 + blue * 114) / 1000 < 128;
2813
3162
  }
2814
3163
 
3164
+ // The master, layout and notes master half of the native header/footer (RR-11): placeholders and p:hf on the finished parts.
3165
+ // Every deck gets them (flags off when no slide uses a type), so Insert > Header & Footer works on a deck with no footer too.
3166
+ function writeNativeFurnitureMasters(output, context) {
3167
+ const size = /<p:sldSz\b[^>]*\bcx="(\d+)"[^>]*\bcy="(\d+)"/.exec(decodeText(output['ppt/presentation.xml'][0]));
3168
+ const defaults = new Map((context.defaultFooterOptions ? defaultFooterParts(context.defaultFooterOptions) ?? [] : []).flatMap(part => {
3169
+ const ph = {date: 'dt', text: 'ftr', slideNumber: 'sldNum'}[part.field];
3170
+ const geometry = ph && defaultPlaceholderGeometry(part);
3171
+ return geometry ? [[ph, geometry]] : [];
3172
+ }));
3173
+ const info = {used: context.nativePlaceholders.used, first: context.nativePlaceholders.first, names: new Set(context.nativeFurniture.keys()), defaults,
3174
+ slideSize: {width: size ? Number(size[1]) : 12192000, height: size ? Number(size[2]) : 6858000}, dateText: nativeDateText(context.hostDate)};
3175
+ const paths = Object.keys(output);
3176
+ try {
3177
+ // The placeholders carry the deck's language and direction like every other generated part (partScriptFonts is idempotent).
3178
+ const written = (path, bytes) => { output[path] = [context.scriptFonts ? encodeText(partScriptFonts(path, decodeText(bytes), context.scriptFonts, 0)) : bytes, output[path][1]]; };
3179
+ writeNativeMasters(paths, path => output[path][0], written, info);
3180
+ } catch (error) {
3181
+ throw new OPFPptxError('packaging-failed', 'Native header/footer placeholders could not be written.', {cause: errorMessage(error)});
3182
+ }
3183
+ }
3184
+
2815
3185
  async function normalizePptxZip(raw, context) {
2816
3186
  let entries;
2817
3187
  try {
@@ -2823,15 +3193,18 @@ async function normalizePptxZip(raw, context) {
2823
3193
  }
2824
3194
 
2825
3195
  attachCodeTags(entries, context.codeTags);
2826
- attachMetricTags(entries,context.metricTags);
3196
+ attachMetricTags(entries,context.metricTags,context.metricDescriptions);
2827
3197
  attachCardTags(entries,context.cardTags);
2828
3198
  attachMediaTags(entries,context.mediaTags);
2829
3199
  attachHeadingTags(entries,context.headingTags);
2830
3200
  attachPlainTextTags(entries,context.plainTextTags);
2831
3201
  attachTimelineTags(entries,context.timelineTags);
2832
3202
  attachQuoteTags(entries,context.quoteTags);
3203
+ attachAnnotationTags(entries,context);
2833
3204
  attachFurnitureFields(entries,context.furnitureFields);
2834
3205
  attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests,context.furnitureLogoTags);
3206
+ // RR-11: the recorded footer shapes become PowerPoint's date, footer and slide-number placeholders.
3207
+ context.nativePlaceholders = attachNativePlaceholders(entries,context.nativeFurniture);
2835
3208
  // The "Image unavailable" panel of an unresolved logo: identity only, so import does not read it as content.
2836
3209
  attachTextTags(entries,context.logoPlaceholderTags,LOGO_TAG,'opfLogoPlaceholder','logo placeholder');
2837
3210
  for(const [part,bytes]of Object.entries(entries)){
@@ -2849,6 +3222,9 @@ async function normalizePptxZip(raw, context) {
2849
3222
  // Normalize only our generated chart text styles; point/series fills stay intact.
2850
3223
  entries[chartPart]=encodeText(decodeText(entries[chartPart]).replace(/<c:txPr>[\s\S]*?<\/c:txPr>/g,properties=>properties.replace(/<a:solidFill>[\s\S]*?<\/a:solidFill>/g,()=>`<a:solidFill><a:srgbClr val="${labelColor}"/></a:solidFill>`)));
2851
3224
  entries[chartPart]=encodeText(applyChartTextSize(decodeText(entries[chartPart]),textSize));
3225
+ // RR-35: data labels last, so their own text colours (contrast inside a mark) are not normalized away.
3226
+ const {options:chartOptions,kind:optionKind,pointCount}=context.chartHeadings.get(name);
3227
+ 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}));
2852
3228
  }
2853
3229
  }
2854
3230
  applyChartFonts(entries,context.chartFonts,parseRelationships);
@@ -2878,10 +3254,14 @@ async function normalizePptxZip(raw, context) {
2878
3254
  const backgroundId = background && decodeText(bytes).match(/<p:bg>[\s\S]*?<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
2879
3255
  if (backgroundId) imageSources.set(relationships.get(backgroundId)?.path, background.path);
2880
3256
  }
3257
+ // SVG pictures: the media part, its relationship and the asvg:svgBlip extension beside the PNG fallback blip. Fitting then
3258
+ // uses the SVG's own proportions (the fallback raster only approximates them at integer pixels).
3259
+ const svgSizes = attachSvgPictures(entries, context.svgPictures);
2881
3260
  const imageMetadata = new Map();
2882
3261
  for (const [part, bytes] of Object.entries(entries)) {
2883
3262
  if (!part.startsWith('ppt/media/')) continue;
2884
3263
  let metadata = rasterMetadata(bytes);
3264
+ if (metadata && svgSizes.has(part)) metadata = {...metadata, ...svgSizes.get(part)};
2885
3265
  if (metadata?.mediaType === 'image/webp' && context.imageFormat === 'compatible') {
2886
3266
  try {
2887
3267
  if (metadata.width * metadata.height > 40_000_000) throw new Error('Image dimensions exceed the 40 megapixel conversion limit.');
@@ -2941,6 +3321,7 @@ async function normalizePptxZip(raw, context) {
2941
3321
  }];
2942
3322
  }
2943
3323
 
3324
+ writeNativeFurnitureMasters(output, context);
2944
3325
  finalizeFontsUsed(output);
2945
3326
  // Document references record evidence from the final normalized parts.
2946
3327
  if (context.documentProvenance) {
@@ -3140,6 +3521,9 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3140
3521
  if(!marker)return shape;
3141
3522
  if(marker.picture&&bulletRelationship)return shape.replace(/<a:buChar\b[^>]*\/>/g,`<a:buBlip><a:blip r:embed="${bulletRelationship}"/></a:buBlip>`);
3142
3523
  const family=marker.fontFamily.replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[char]));
3524
+ // RR-33: an auto-number takes the measured size, colour and family like a character bullet, and startAt only when it is not 1.
3525
+ const autoNumber=/<a:buSzPct val="100000"\/><a:buFont typeface="\+mj-lt"\/><a:buAutoNum type="([A-Za-z]+)" startAt="(\d+)"\/>/;
3526
+ if(autoNumber.test(shape))return shape.replace(autoNumber,(_,type,startAt)=>`<a:buClr>${solidColorXml(marker.color)}</a:buClr><a:buSzPts val="${Math.round(marker.fontSize*100)}"/><a:buFont typeface="${family}"/><a:buAutoNum type="${type}"${startAt==='1'?'':` startAt="${startAt}"`}/>`);
3143
3527
  return shape.replace(/<a:buSzPct val="100000"\/>/g,`<a:buClr>${solidColorXml(marker.color)}</a:buClr><a:buSzPts val="${Math.round(marker.fontSize*100)}"/><a:buFont typeface="${family}"/>`);
3144
3528
  });
3145
3529
  // PptxGenJS gives shapes no alternative text. An unavailable image's panel
@@ -3371,7 +3755,7 @@ function numericValue(value) {
3371
3755
 
3372
3756
  function normalizeAuthor(author) {
3373
3757
  if (typeof author === "string") return author;
3374
- if (Array.isArray(author)) return author.join("; ");
3758
+ if (Array.isArray(author)) return joinAuthors(author);
3375
3759
  return null;
3376
3760
  }
3377
3761