@openpresentation/opf-pptx 0.11.9 → 0.12.1

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,20 +1,28 @@
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';
6
+ import {giveNotesMastersOwnThemes} from './master-themes.js';
5
7
  import {readChartCategoryHeading,writeChartCategoryHeading} from './chart-workbook.js';
6
8
  import {CHARTEX_FALLBACK,resolveChartType,chartTypeFromNative,applyChartConstruct,NATIVE_CHART_ELEMENTS} from './chart-types.js';
7
9
  import {attachChartexParts,chartFromChartex,CHARTEX_GRAPHIC_DATA_URI} from './chartex.js';
10
+ import {applyDataLabels,chartOptionsFromClassic,chartTargetFor,classicChartOptions,reportChartOptionDiagnostics,resolveChartOptionsFor} from './chart-options.js';
8
11
  import {attachCodeTags, attachTextTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
9
12
  import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
10
13
  import {attachCardTags,importCardFrames} from './card-provenance.js';
11
14
  import {attachMediaTags,importMediaGroups,mediaCaption,mediaFrameRecord,mediaTextFingerprint} from './media-provenance.js';
15
+ // RR-34: captions and footnote areas as tagged text boxes; citation markers round-trip through the tags and the marker runs.
16
+ import {addCaption, addFootnotes} from './annotation-export.js';
17
+ import {attachAnnotationTags, captionValue, importAnnotations, restoreCitations} from './annotation-provenance.js';
12
18
  import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
13
19
  import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
14
20
  import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
15
21
  import {attachQuoteTags,quoteManifest,importQuoteGroups} from './quote-provenance.js';
16
22
  import {attachFurnitureTags, furnitureManifest, importFurniture, manifestPartIndex, staticDateFallback} from './furniture-provenance.js';
17
- import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, recordContentTopology, restoreDocumentProvenance} from './document-provenance.js';
23
+ import {restoreRunColors} from './run-colors.js';
24
+ import {joinWrappedText} from './content-topology.js';
25
+ import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, recordContentTopology, restoreDocumentProvenance, joinAuthors, splitAuthors, slideListBreaks, DEFAULT_AUTHOR} from './document-provenance.js';
18
26
  import {INVALID_XML_CHARACTER, nativeSections, writeSectionList} from './sections.js';
19
27
  import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
20
28
  import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, writeNativeMasters} from './native-furniture.js';
@@ -27,11 +35,13 @@ import {placeLogos, importLogo, importLogoPlaceholders, logoName, LOGO_TAG} from
27
35
  import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
28
36
  import {importBackground} from './background-import.js';
29
37
  import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
30
- import {languageDiagnostics, observeLanguage, partScriptFonts, planScriptFonts, reconcileLanguage} from './script-fonts.js';
38
+ import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, reconcileLanguage, stripRunScriptFonts, themeEastAsianFromLatin} from './script-fonts.js';
31
39
  import { webpToPng, svgToPng, readLocalFile } from '#image-fallback';
32
40
  import { prepareSvg, svgDataUriBytes, svgRasterScale, svgBlipRelationship, attachSvgPictures } from './svg-image.js';
33
41
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
34
42
  import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
43
+ // chartPaletteForFill ships with core RR-29 (opf#270); an older published core still loads and clamps each colour on its own.
44
+ import * as opfComposition from "@openpresentation/opf/composition";
35
45
  import { colorContext, resolveColorRefValue, resolveExportColor, resolveVariableColors } from "./color-ref.js";
36
46
  import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
37
47
  import { unzipSync, zipSync } from "fflate";
@@ -40,6 +50,11 @@ import {
40
50
  catalogs as bundledCatalogs,
41
51
  validatePresentation
42
52
  } from "@openpresentation/opf";
53
+ // Optional core exports (RR-07 syntax colours, trend marks) are read from the namespace so an older published core still loads.
54
+
55
+ // Optional core exports are read from the namespace so an older published core still loads;
56
+ // resolveVariables ships with core RR-32.
57
+ import * as opfCore from "@openpresentation/opf";
43
58
 
44
59
  export {checkPptxTypefaces, inventoryPptxTypefaces, packageFontsUsed, THEME_SCRIPT_SUPPLEMENTS} from './typeface-inventory.js';
45
60
  export {DEFAULT_SIGNAL_LIMITS, SIGNALS_VERSION, isMonospaceFamily} from './import-signals.js';
@@ -205,7 +220,7 @@ export async function toPptx(input, options = {}) {
205
220
  if (options.provenance !== undefined && !['full', 'references-only', false].includes(options.provenance)) {
206
221
  throw new OPFPptxError('invalid-provenance-option', "provenance must be 'full', 'references-only' or false.", {path: 'options.provenance'});
207
222
  }
208
- const presentation = parseInput(input);
223
+ const presentation = resolveTemplateInput(parseInput(input), options);
209
224
  assertValidBoundary(presentation);
210
225
  options = {...options, textMeasurement: chosenFamilyMeasurement(options.textMeasurement), svgRasters: new Map()};
211
226
 
@@ -238,9 +253,13 @@ export async function toPptx(input, options = {}) {
238
253
  context.nativeFurniture = new Map();
239
254
  context.hostDate = options.date;
240
255
  context.codeTags = new Map();
256
+ context.captionTags = new Map();
257
+ context.footnoteTags = new Map();
241
258
  context.metricTags = new Map();
259
+ context.metricDescriptions = new Map();
242
260
  context.chartHeadings = new Map();
243
261
  context.chartFonts = new Map();
262
+ context.chartPalettes = new Map();
244
263
  context.chartex = new Map();
245
264
  context.imageFormat = options.imageFormat ?? "compatible";
246
265
  // Chartex export mode (FF-22b): 'auto' (default) writes native chartex parts for the constructs the native PowerPoint
@@ -327,7 +346,7 @@ export async function fromPptx(input, options = {}) {
327
346
  });
328
347
  if (observedLanguage.language !== undefined) imported.language = observedLanguage.language;
329
348
  if (core.description) imported.description = core.description;
330
- if (core.author) imported.author = core.author;
349
+ if (core.author) imported.author = splitAuthors(core.author);
331
350
  const themeDesign = importThemeDesign(entries, presentationRoot, presentationRels);
332
351
  const design = {...themeDesign.design, ...(dimensions ? {dimensions} : {})};
333
352
  if (Object.keys(design).length) imported.design = design;
@@ -353,7 +372,7 @@ export async function fromPptx(input, options = {}) {
353
372
  const mediaRegistry = Object.create(null);
354
373
  for (let index = 0; index < slidePaths.length; index += 1) {
355
374
  furnitureContexts[index].mediaRegistry = mediaRegistry;
356
- imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options, furniture.slides[index], furnitureContexts[index]));
375
+ imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, {...options, rtlDeck: observedRtl(observedLanguage)}, furniture.slides[index], furnitureContexts[index]));
357
376
  }
358
377
  // Native sections (PowerPoint's own section list, `Default Section` = none)
359
378
  // are reconciled with the footer text and the stored value in restoreDocumentProvenance.
@@ -396,7 +415,11 @@ export async function fromPptx(input, options = {}) {
396
415
  report({code: "invalid-document-provenance", path: "", message: `${errorMessage(error)} Ordinary import keeps the values observed in the PPTX.`});
397
416
  }
398
417
  if (slideProvenance.length !== imported.slides.length) throw new OPFPptxError("invalid-import-opf", "Slide provenance does not match the imported slides.");
418
+ // RR-34: marker runs become cite/footnote on the runs before them, and the references list is rebuilt
419
+ // from the stored record and the footnote boxes (an edited note keeps its edited text).
420
+ restoreCitations(imported, furnitureContexts.map(context => context.annotations?.notes ?? []), report);
399
421
  restoreLogoFallback(imported, furnitureContexts);
422
+ applyRunColors(imported, slideProvenance, restoredGroups, report, options);
400
423
  if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
401
424
  languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
402
425
 
@@ -418,6 +441,24 @@ export async function fromPptx(input, options = {}) {
418
441
  return {document: imported, signals};
419
442
  }
420
443
 
444
+ // RR-08: run colours named by a ColorRef (variable, scheme slot, role) come back as that name where the document's own
445
+ // colour resolution still gives the run's colour; see run-colors.js.
446
+ function applyRunColors(imported, slideProvenance, restoredGroups, report, options) {
447
+ const restoredContent = new Set(restoredGroups.filter(group => group.applied && group.contentPaths).map(group => group.contentPaths.slide));
448
+ const info = slideProvenance.map((entry, index) => entry.structure === "untagged" ? undefined : {record: entry.record, structure: entry.structure === "match", content: restoredContent.has(index)});
449
+ const contexts = new Map();
450
+ const resolve = (slide, name) => {
451
+ try {
452
+ const key = slide.design ?? imported.design ?? null;
453
+ if (!contexts.has(key)) contexts.set(key, resolvePresentationContext(slide.design ? {...imported, design: {...imported.design, ...slide.design}} : imported, options));
454
+ return resolveColorRefValue(name, colorContext(contexts.get(key)));
455
+ } catch {
456
+ return undefined;
457
+ }
458
+ };
459
+ restoreRunColors(imported.slides, info, resolve, report);
460
+ }
461
+
421
462
  // A consumed logo picture (OPF_LOGO_V1) never becomes content. Its own image restores the logo only when nothing else
422
463
  // did: the stored document and slide design (or the organization) win and carry LogoSet variants, so a package exported
423
464
  // without provenance still keeps its logo. A slide-level logo path restores slide design; every other path the deck's.
@@ -639,10 +680,32 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
639
680
  item.text = current.map(paragraph => paragraph.text).join('\n');
640
681
  }
641
682
  }
642
- const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items))
643
- .map((item) => ({payload: payloadFromSlideItem(item), bounds: item.visualBounds ?? item.bounds, sources: item.sources ?? []}))
683
+ const listBreaks = slideListBreaks(entries, slidePath, slideRoot, relationships);
684
+ const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items, listBreaks, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`})))
685
+ .map((item) => ({payload: payloadFromSlideItem(item, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}`})), bounds: item.visualBounds ?? item.bounds, sources: item.sources ?? []}))
644
686
  .filter((entry) => entry.payload);
645
687
  const blocks = content.map((entry) => entry.payload);
688
+ // RR-34: a tagged caption re-attaches to the block whose native media shape it names (never by position).
689
+ const annotations = nativeContext.annotations;
690
+ if (annotations?.captions.size) {
691
+ const tree = slideRoot['p:cSld']?.['p:spTree'];
692
+ const shapeName = node => scalarText(node?.['p:nvSpPr']?.['p:cNvPr']?.name ?? node?.['p:nvPicPr']?.['p:cNvPr']?.name ?? node?.['p:nvGraphicFramePr']?.['p:cNvPr']?.name).trim();
693
+ const keyByName = new Map();
694
+ nativeContext.shapes.forEach((shape, index) => keyByName.set(shapeName(shape), `sp:${index}`));
695
+ nativeContext.pictures.forEach((picture, index) => keyByName.set(shapeName(picture), `pic:${index}`));
696
+ asArray(tree?.['p:graphicFrame']).forEach((frame, index) => keyByName.set(shapeName(frame), `frame:${index}`));
697
+ 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}`); });
698
+ for (const [mediaName, caption] of annotations.captions) {
699
+ const key = keyByName.get(mediaName);
700
+ const entry = key === undefined ? undefined : content.find(item => item.sources.includes(key));
701
+ if (!entry || !['image', 'chart', 'table', 'video'].includes(inferPayloadKind(entry.payload))) {
702
+ 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.`});
703
+ blocks.push({type: 'text', text: caption.value});
704
+ continue;
705
+ }
706
+ entry.payload.caption = captionValue(caption);
707
+ }
708
+ }
646
709
  if (blocks.length > 0) slide.blocks = blocks;
647
710
  // Native bounds of each block in reference px, for content topology matching (document provenance).
648
711
  // Which native shapes fed each field and block (import signals); never part of the document.
@@ -664,6 +727,12 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
664
727
  const shapes = nativeContext.shapes;
665
728
  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.'});
666
729
  const paragraphs = nativeContext.paragraphs;
730
+ // FF-45: OMML equations (a14:m math zones) have no OPF model. Their fallback text is imported as plain text; the equation
731
+ // layout (fractions, radicals, limits, matrices) is lost, and that loss is diagnosed per shape instead of passing silently.
732
+ paragraphs.forEach((list, index) => {
733
+ const zones = list.reduce((count, paragraph) => count + (paragraph.math?.zones ?? 0), 0);
734
+ 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.`});
735
+ });
667
736
  // Native shape keys (indexes in nativeTextShapes / nativePictures / frame order) for import signals.
668
737
  const shapeKeys = new Map(shapes.map((shape, index) => [shape, `sp:${index}`]));
669
738
  const sourcesOf = group => group.flatMap(shape => shapeKeys.has(shape) ? [shapeKeys.get(shape)] : []);
@@ -681,6 +750,9 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
681
750
  const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
682
751
  const timelines=importTimelineGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
683
752
  const quotes=importQuoteGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.quote`}));
753
+ // RR-34: tagged caption and footnote lines are not content; captions re-attach to their media block and notes rebuild cite/footnote.
754
+ const annotations=importAnnotations({shapes,paragraphs,relationships,entries,slideIndex,readBody,report:diagnostic=>options.onDiagnostic?.(diagnostic)});
755
+ nativeContext.annotations=annotations;
684
756
  for(const group of [...headings.items,...plainText.items]) {
685
757
  const item=importShape(group.shapes[0],dimensions,group.paragraphs,true);
686
758
  if(item){
@@ -707,6 +779,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
707
779
  : 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;
708
780
  if (role) roles.set(shapeKeys.get(shape), role);
709
781
  if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index)) continue;
782
+ if (annotations.consumed.has(shape)) { roles.set(shapeKeys.get(shape), 'annotation'); continue; }
710
783
  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;
711
784
  const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
712
785
  const item = importShape(shape, dimensions, paragraphs[index], ordinaryBody || furniture.taggedText.has(index) || media.captionShapes.has(shape));
@@ -718,12 +791,14 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
718
791
  }
719
792
 
720
793
  const frames = asArray(tree?.["p:graphicFrame"]);
794
+ // RR-35: what a chart's axis titles, legend and data labels cannot express in OPF is reported on the chart's path.
795
+ const chartReport = label => diagnostic => options.onDiagnostic?.({code: 'chart-option-adapted', ...diagnostic, path: `slides.${slideIndex}.${label}.${diagnostic.option}`});
721
796
  const tables = frames.some(frame => frame['a:graphic']?.['a:graphicData']?.['a:tbl'])
722
797
  ? importTableFrames(slidePath, {
723
798
  part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
724
- }, relationships, (frame, cell, code, message) => options.onDiagnostic?.({code, message, path: `slides.${slideIndex}.tables.${frame}${cell ? '.' + cell : ''}`}), dimensions) : [];
799
+ }, relationships, (frame, cell, code, message) => options.onDiagnostic?.({code, message, path: `slides.${slideIndex}.tables.${frame}${cell ? '.' + cell : ''}`}), dimensions, {rtlDeck: options.rtlDeck === true}) : [];
725
800
  for (const [index, frame] of frames.entries()) {
726
- const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index]);
801
+ const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index], chartReport(`charts.${index}`));
727
802
  if (item) items.push({...item, sources: [`frame:${index}`]});
728
803
  }
729
804
  // A chartex chart is an mc:AlternateContent: the choice frame references the cx:chartSpace part; the fallback is a classic chart frame
@@ -731,8 +806,8 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
731
806
  for (const [alternateIndex, alternate] of asArray(tree?.["mc:AlternateContent"]).entries()) {
732
807
  const choice = asArray(alternate?.["mc:Choice"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
733
808
  const fallback = asArray(alternate?.["mc:Fallback"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
734
- const chosen = choice ? importGraphicFrame(entries, choice, slidePath, relationships) : null;
735
- const item = chosen?.payload?.type === "chart" ? chosen : fallback ? importGraphicFrame(entries, fallback, slidePath, relationships) : chosen;
809
+ const chosen = choice ? importGraphicFrame(entries, choice, slidePath, relationships, undefined, chartReport(`charts.alt${alternateIndex}`)) : null;
810
+ const item = chosen?.payload?.type === "chart" ? chosen : fallback ? importGraphicFrame(entries, fallback, slidePath, relationships, undefined, chartReport(`charts.alt${alternateIndex}`)) : chosen;
736
811
  if (item && item.kind !== "unknown") items.push({...item, sources: [`alt:${alternateIndex}`]});
737
812
  }
738
813
 
@@ -774,7 +849,7 @@ function importShape(shape, dimensions, paragraphs = readParagraphs(shape["p:txB
774
849
  };
775
850
  }
776
851
 
777
- function importGraphicFrame(entries, frame, slidePath, relationships, importedTable) {
852
+ function importGraphicFrame(entries, frame, slidePath, relationships, importedTable, report) {
778
853
  const bounds = shapeBounds(frame["p:xfrm"]);
779
854
  const name = scalarText(frame["p:nvGraphicFramePr"]?.["p:cNvPr"]?.name).trim();
780
855
  const graphicData = frame["a:graphic"]?.["a:graphicData"];
@@ -795,8 +870,8 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
795
870
  const chartRelId = graphicData?.["c:chart"]?.["r:id"] ?? chartExRelId;
796
871
  if (chartRelId) {
797
872
  const chart = chartExRelId
798
- ? chartexFromRelationship(entries, relationships, chartExRelId)
799
- : chartFromRelationship(entries, slidePath, relationships, chartRelId);
873
+ ? chartexFromRelationship(entries, relationships, chartExRelId, report)
874
+ : chartFromRelationship(entries, slidePath, relationships, chartRelId, report);
800
875
  return {
801
876
  kind: "chart",
802
877
  bounds,
@@ -879,11 +954,17 @@ function importPicture(entries, picture, slidePath, relationships, report) {
879
954
  }
880
955
 
881
956
  function readParagraphs(txBody) {
882
- return asArray(txBody?.["a:p"])
957
+ return withDisplayedNumbers(asArray(txBody?.["a:p"])
883
958
  .map((paragraph) => {
959
+ // FF-45: an a14:m math zone (OMML equation) inside mc:AlternateContent has no OPF model; its mc:Fallback runs are read as
960
+ // text (or, without them, the equation's m:t text), and `math` records the zones so the importer can diagnose the loss.
961
+ const alternates = asArray(paragraph?.["mc:AlternateContent"]);
962
+ const zones = alternates.flatMap(alternate => asArray(alternate?.["mc:Choice"])).filter(choice => choice?.["a14:m"] !== undefined);
963
+ const fallbackRuns = alternates.flatMap(alternate => asArray(alternate?.["mc:Fallback"])).flatMap(fallback => [...asArray(fallback?.["a:r"]), ...asArray(fallback?.["a:fld"])]);
884
964
  const runs = [
885
965
  ...asArray(paragraph?.["a:r"]),
886
- ...asArray(paragraph?.["a:fld"])
966
+ ...asArray(paragraph?.["a:fld"]),
967
+ ...fallbackRuns
887
968
  ];
888
969
  const texts = [];
889
970
  const sizes = [];
@@ -893,13 +974,29 @@ function readParagraphs(txBody) {
893
974
  const size = Number(run?.["a:rPr"]?.sz);
894
975
  if (Number.isFinite(size)) sizes.push(size / 100);
895
976
  }
977
+ const linear = zones.map(choice => keyedMathText(choice["a14:m"])).join("");
978
+ if (zones.length && !fallbackRuns.some(run => scalarText(run?.["a:t"]))) texts.push(linear);
896
979
  return {
897
980
  text: texts.join(""),
898
981
  bullet: asArray(paragraph?.["a:pPr"]).some(props => props?.["a:buChar"] !== undefined || props?.["a:buBlip"] !== undefined || props?.["a:buAutoNum"] !== undefined),
899
982
  level: Number(asArray(paragraph?.["a:pPr"])[0]?.lvl ?? 0),
900
- maxFontSize: sizes.length > 0 ? Math.max(...sizes) : 0
983
+ maxFontSize: sizes.length > 0 ? Math.max(...sizes) : 0,
984
+ ...(zones.length ? {math: {zones: zones.length, text: linear}} : {}),
985
+ ...autoNumberOf(asArray(paragraph?.["a:pPr"])[0])
901
986
  };
902
- });
987
+ }));
988
+ }
989
+ // RR-33: the native auto-number of a paragraph's properties, when its last bullet element is one.
990
+ function autoNumberOf(properties) {
991
+ if (properties?.["a:buAutoNum"] === undefined) return {};
992
+ const attributes = asArray(properties["a:buAutoNum"])[0] ?? {};
993
+ return {autoNum: {type: attributes.type, startAt: attributes.startAt}};
994
+ }
995
+ /** The m:t text of a keyed OMML tree (an a14:m zone) in document order: the equation's linear reading without its layout. */
996
+ function keyedMathText(node) {
997
+ if (Array.isArray(node)) return node.map(keyedMathText).join("");
998
+ if (!node || typeof node !== "object") return "";
999
+ return Object.entries(node).map(([key, value]) => key === "m:t" ? asArray(value).map(scalarText).join("") : keyedMathText(value)).join("");
903
1000
  }
904
1001
 
905
1002
  function shapePlaceholderType(shape) {
@@ -966,7 +1063,10 @@ function joinRich(a, b) {
966
1063
  }
967
1064
  return joined;
968
1065
  }
969
- function mergeOpfListShapes(items) {
1066
+ // `breaks` (slideListBreaks) holds the whitespace of the hard breaks the export recorded, by list path and line number: the
1067
+ // continuation line after a break joins with it, so a newline inside an item (or a description) returns. A blank line is a
1068
+ // native shape with no text: it is never a description. Without a record the lines join without a separator, as before.
1069
+ function mergeOpfListShapes(items, breaks = null, report = () => {}) {
970
1070
  const lists = new Map();
971
1071
  for (const item of items) {
972
1072
  const match = /^OPF list (.+) line (\d+)$/.exec(item.name ?? '');
@@ -980,17 +1080,23 @@ function mergeOpfListShapes(items) {
980
1080
  entries.sort((a, b) => a.line - b.line);
981
1081
  if (!entries[0].item.paragraphs[0].bullet) continue;
982
1082
  const paragraphs = [];
1083
+ const recorded = breaks?.get(path), applied = new Set();
983
1084
  let bounds, size = 0;
984
- for (const {item} of entries) {
1085
+ for (const {item, line} of entries) {
985
1086
  const [paragraph] = item.paragraphs, current = paragraphs.at(-1);
1087
+ const gap = recorded?.get(line);
1088
+ const join = (a, b) => gap === undefined ? joinRich(a, b) : joinWrappedText([a, b], [gap]);
986
1089
  if (paragraph.bullet) { paragraphs.push({...paragraph}); size = paragraph.maxFontSize; }
1090
+ else if (paragraph.text === '') { /* a blank line of the entry: no text, no description */ }
987
1091
  else if (paragraph.maxFontSize < size - 0.01) {
988
1092
  const text = paragraph.richText ?? paragraph.text;
989
- current.description = current.description === undefined ? text : joinRich(current.description, text);
1093
+ if (current.description === undefined) current.description = text;
1094
+ else { current.description = join(current.description, text); applied.add(line); }
990
1095
  } else {
991
1096
  const before = current.text;
992
- current.text += paragraph.text;
993
- if (current.richText !== undefined || paragraph.richText !== undefined) current.richText = joinRich(current.richText ?? before, paragraph.richText ?? paragraph.text);
1097
+ current.text += (gap === undefined ? '' : Array.isArray(gap) ? gap[0] : gap) + paragraph.text;
1098
+ if (current.richText !== undefined || paragraph.richText !== undefined) current.richText = join(current.richText ?? before, paragraph.richText ?? paragraph.text);
1099
+ applied.add(line);
994
1100
  }
995
1101
  const b = item.visualBounds ?? item.bounds;
996
1102
  if (b) {
@@ -999,6 +1105,7 @@ function mergeOpfListShapes(items) {
999
1105
  }
1000
1106
  consumed.add(item);
1001
1107
  }
1108
+ 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.`});
1002
1109
  const first = entries[0].item;
1003
1110
  merged.set(first, {...first, paragraphs, text: paragraphs.map(paragraph => paragraph.text).join('\n'), bounds, visualBounds: undefined, sources: entries.flatMap(({item}) => item.sources ?? [])});
1004
1111
  }
@@ -1025,17 +1132,23 @@ function mergeAdjacentBulletShapes(items) {
1025
1132
  return result;
1026
1133
  }
1027
1134
 
1028
- function payloadFromSlideItem(item) {
1135
+ function payloadFromSlideItem(item, report) {
1029
1136
  if (item.payload) return item.payload;
1030
1137
  if (item.kind === "text") {
1031
1138
  if (item.paragraphs.some(p=>p.bullet)) {
1139
+ // RR-33: native auto-numbers come back as `numbering` and the entry `start` values that restore their numbers.
1140
+ const {numbering, starts, diagnostics} = deriveListNumbering(item.paragraphs);
1141
+ for (const diagnostic of diagnostics) report?.(diagnostic);
1032
1142
  return {
1033
1143
  type: "list",
1034
- items: item.paragraphs.map((paragraph) => (
1035
- paragraph.level > 0
1036
- ? { text: paragraph.richText ?? paragraph.text, level: paragraph.level, ...(paragraph.description !== undefined ? {description: paragraph.description} : {}) }
1037
- : paragraph.description !== undefined ? { text: paragraph.richText ?? paragraph.text, description: paragraph.description } : (paragraph.richText ?? paragraph.text)
1038
- ))
1144
+ items: item.paragraphs.map((paragraph, index) => {
1145
+ const start = starts.get(index) === undefined ? {} : {start: starts.get(index)};
1146
+ return paragraph.level > 0
1147
+ ? { text: paragraph.richText ?? paragraph.text, level: paragraph.level, ...(paragraph.description !== undefined ? {description: paragraph.description} : {}), ...start }
1148
+ : paragraph.description !== undefined ? { text: paragraph.richText ?? paragraph.text, description: paragraph.description, ...start }
1149
+ : start.start !== undefined ? { text: paragraph.richText ?? paragraph.text, ...start } : (paragraph.richText ?? paragraph.text);
1150
+ }),
1151
+ ...(numbering !== undefined ? {numbering} : {})
1039
1152
  };
1040
1153
  }
1041
1154
  return { type: "text", text: joinNativeParagraphs(item.paragraphs) };
@@ -1044,7 +1157,7 @@ function payloadFromSlideItem(item) {
1044
1157
  return null;
1045
1158
  }
1046
1159
 
1047
- function chartFromRelationship(entries, slidePath, relationships, relId) {
1160
+ function chartFromRelationship(entries, slidePath, relationships, relId, report) {
1048
1161
  const relationship = relationships.get(relId);
1049
1162
  if (!relationship?.path || !entries[relationship.path]) return null;
1050
1163
  const doc = parseRequiredXml(entries, relationship.path);
@@ -1061,7 +1174,13 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
1061
1174
 
1062
1175
  const budget = { cells: 0 };
1063
1176
  const cachePath = (index, role) => `${relationship.path}#c:ser[${index}]/${role}`;
1064
- if (chartNode.type === 'scatter') return scatterFromSeries(entries, relationship.path, series, budget, cachePath);
1177
+ // RR-35: axis titles, legend position and data labels read back into the chart's option fields.
1178
+ const withOptions = chart => {
1179
+ 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'});
1180
+ for (const note of notes) report?.(note);
1181
+ return chart && Object.keys(options).length ? {...chart, ...options} : chart;
1182
+ };
1183
+ if (chartNode.type === 'scatter') return withOptions(scatterFromSeries(entries, relationship.path, series, budget, cachePath));
1065
1184
  const labels = cachedValues(series[0]?.["c:cat"], cachePath(0, 'c:cat'), budget);
1066
1185
  const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, 'c:tx'), budget) ?? `Series ${index + 1}`);
1067
1186
  const values = series.map((entry, index) => {
@@ -1082,13 +1201,13 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
1082
1201
  ]);
1083
1202
  }
1084
1203
 
1085
- return {
1204
+ return withOptions({
1086
1205
  type: chartNode.type,
1087
1206
  data: {
1088
1207
  columns: [readChartCategoryHeading(entries,relationship.path) ?? "Category", ...names],
1089
1208
  rows
1090
1209
  }
1091
- };
1210
+ });
1092
1211
  }
1093
1212
 
1094
1213
  // Scatter charts share X values (c:xVal) across Y series (c:yVal). OPF keeps
@@ -1114,7 +1233,7 @@ function scatterFromSeries(entries, chartPart, series, budget, cachePath) {
1114
1233
  // A chartex part (cx:chartSpace): the series layoutIds name the kept OPF chart
1115
1234
  // type and the cached dimensions restore the category-major data, under the
1116
1235
  // same bounds as the classic chart cache.
1117
- function chartexFromRelationship(entries, relationships, relId) {
1236
+ function chartexFromRelationship(entries, relationships, relId, report) {
1118
1237
  const relationship = relationships.get(relId);
1119
1238
  if (!relationship?.path || !entries[relationship.path]) return null;
1120
1239
  const doc = parseRequiredXml(entries, relationship.path);
@@ -1122,6 +1241,7 @@ function chartexFromRelationship(entries, relationships, relId) {
1122
1241
  heading: readChartCategoryHeading(entries, relationship.path),
1123
1242
  limits: {points: MAX_CHART_CACHE_POINTS, cells: MAX_CHART_CACHE_CELLS},
1124
1243
  path: relationship.path,
1244
+ report,
1125
1245
  invalid: (message, location) => {
1126
1246
  throw new OPFPptxError('invalid-chart-cache', `Invalid chart cache: ${message}. Repair the chart data before importing.`, {path: location ?? relationship.path});
1127
1247
  }
@@ -1306,6 +1426,30 @@ function parseInput(input) {
1306
1426
  throw new OPFPptxError("invalid-input", "OPF input must be a parsed object, JSON string, or Uint8Array.");
1307
1427
  }
1308
1428
 
1429
+ // Template variables (core resolveVariables, RR-32): a deck that uses content variables, or a template, is resolved to a
1430
+ // concrete deck before export, so the PPTX holds exactly the text, numbers, dates and images the preview shows. A template
1431
+ // exports with each unfilled variable's example (reported as variable-example-used); a normal deck with an unfilled
1432
+ // required variable is refused. Decks without content variables are returned untouched. The package stores the resolved
1433
+ // deck, not the template form, so fromPptx returns the filled deck.
1434
+ function resolveTemplateInput(presentation, options) {
1435
+ if (typeof opfCore.resolveVariables !== "function" || !isPlainObject(presentation)) return presentation;
1436
+ const values = options.variables;
1437
+ if (values !== undefined && !isPlainObject(values)) {
1438
+ throw new OPFPptxError("invalid-variables", "The variables option must be an object keyed by variable id.", {path: "options.variables"});
1439
+ }
1440
+ const template = opfCore.isTemplate(presentation);
1441
+ if (!template && !opfCore.hasContentVariables(presentation) && !(values && Object.keys(values).length)) return presentation;
1442
+ const result = opfCore.resolveVariables(presentation, values ?? {}, {examples: template});
1443
+ const errors = result.diagnostics.filter(entry => entry.severity === "error");
1444
+ if (errors.length) {
1445
+ throw new OPFPptxError(errors.some(entry => entry.code === "variable-unfilled") ? "unfilled-variables" : "invalid-variables", errors[0].message, {issues: errors, path: errors[0].path});
1446
+ }
1447
+ for (const entry of result.diagnostics) {
1448
+ if (entry.code === "variable-example-used") options.onDiagnostic?.({code: "variable-example-used", path: entry.path, message: entry.message, id: entry.id});
1449
+ }
1450
+ return result.presentation;
1451
+ }
1452
+
1309
1453
  function assertValidBoundary(presentation) {
1310
1454
  const result = validatePresentation(presentation);
1311
1455
  if (!result.valid) {
@@ -1489,7 +1633,7 @@ function configurePresentation(pptx, presentation, context) {
1489
1633
  height: context.dimensions.heightInches
1490
1634
  });
1491
1635
  pptx.layout = context.layoutName;
1492
- pptx.author = normalizeAuthor(presentation.author) ?? "OpenPresentation";
1636
+ pptx.author = normalizeAuthor(presentation.author) ?? DEFAULT_AUTHOR;
1493
1637
  pptx.company = "OpenPresentation";
1494
1638
  pptx.subject = presentation.description ?? "";
1495
1639
  pptx.title = presentation.name ?? presentation.filename ?? "OPF Presentation";
@@ -1582,30 +1726,42 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1582
1726
  ...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),
1583
1727
  ...nativeFontOptions(item.textStyle),
1584
1728
  color: item.field === "tag" ? tagColor(itemContext) : itemContext.textColor,
1585
- align: alignmentFor(item),
1729
+ align: physicalAlignment(alignmentFor(item), item.text.directions?.[0]),
1730
+ ...(item.text.directions?.[0] === 'rtl' ? {rtlMode: true} : {}),
1586
1731
  objectName,
1587
1732
  breakLine: false
1588
1733
  });
1589
1734
  } else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
1590
- await addMeasuredList(slide,item.text,itemContext,item.path,item.bulletImage,presentation,slideIndex,options);
1735
+ await addMeasuredList(slide,item.text,itemContext,item.path,item.bulletImage,presentation,slideIndex,options,item.payload?.numbering!==undefined);
1591
1736
  } else if (item.field === "text" && item.text?.richLines) {
1592
- const alignment=item.text.placement?.alignment??alignmentFor(item)??'left';
1737
+ const logicalAlignment=item.text.placement?.alignment??alignmentFor(item)??'left';
1593
1738
  for(const [index,line] of item.text.richLines.entries()){
1594
1739
  const runs=line.fragments.map(fragment=>{
1595
1740
  const runColor=exportColor(fragment.run.color,itemContext,itemContext.colors.text);
1596
1741
  const color=nativeColor(fragment.run.color,runColor,itemContext,itemContext.textColor);
1597
- 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}};
1742
+ 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}};
1598
1743
  });
1599
- const placed=item.text.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
1744
+ // Logical alignment (RR-05): a right-to-left paragraph starts at the right edge; every wrapped line shares its paragraph's direction.
1745
+ const placed=item.text.placement?.lines[index],alignment=placed?.alignment??physicalAlignment(logicalAlignment,item.text.directions?.[index]),factor=alignment==='right'?1:alignment==='center'?.5:0;
1600
1746
  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};
1747
+ // PptxGenJS reads the paragraph direction from the first run's options, not from the shape options.
1748
+ if(item.text.directions?.[index]==='rtl')for(const run of runs)run.options.rtlMode=true;
1601
1749
  if(runs.length)slide.addText(runs,{...textBoxOptions(area,itemContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
1602
1750
  }
1603
1751
  } else if (item.field === "text" && typeof item.value === "string") {
1604
- slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:alignmentFor(item)});
1752
+ 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])});
1605
1753
  } else {
1606
- 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);
1754
+ // 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.
1755
+ const mediaNames = item.caption ? new Set([...context.imagePlacements.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()]) : undefined;
1756
+ 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);
1757
+ if (item.caption) {
1758
+ 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));
1759
+ addCaption(slide, item, mediaName, itemContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: item.caption.path}));
1760
+ }
1607
1761
  }
1608
1762
  }
1763
+ // RR-34: the footnote area core reserved above the footer band (rule plus one tagged text box per listed line).
1764
+ addFootnotes(slide, geometry.footnotes, slideIndex, slideContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: `slides.${slideIndex}`}));
1609
1765
  // Core composes furniture above all content and opf-render paints it last, so
1610
1766
  // spTree order (PowerPoint's z-order) matches: header/footer parts come after
1611
1767
  // every content item, and an overlapping footer stays visible over content.
@@ -1674,10 +1830,10 @@ async function addPayload(slide, presentation, payload, region, path, context, o
1674
1830
  addTablePayload(slide, payload.table, region, context, options, path);
1675
1831
  break;
1676
1832
  case "code":
1677
- addCodePayload(slide, payload.code, codeLayout, region, context, path);
1833
+ addCodePayload(slide, payload.code, codeLayout, region, context, path, options);
1678
1834
  break;
1679
1835
  case "metric":
1680
- addMetricPayload(slide, payload.metric,metricLayout,context,path);
1836
+ addMetricPayload(slide, payload.metric,metricLayout,context,path,options);
1681
1837
  break;
1682
1838
  case "quote":
1683
1839
  addQuotePayload(slide, payload.quote, quoteLayout, context, options, path);
@@ -1717,7 +1873,7 @@ function richLineRuns(line,color,native,context) {
1717
1873
  const fallback=color.replace(/^#/,'');
1718
1874
  return line.fragments.map(fragment=>{
1719
1875
  const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context,native);
1720
- 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}};
1876
+ 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}};
1721
1877
  });
1722
1878
  }
1723
1879
  // Every native line of a list, marker paragraph or not, is named for the list's
@@ -1729,7 +1885,10 @@ function richLineRuns(line,color,native,context) {
1729
1885
  // PptxGenJS embeds the media and its relationship through one picture per slide (OPF bullet image); packaging removes
1730
1886
  // that picture and points each marker paragraph at the relationship with a:buBlip. An icon that cannot be embedded
1731
1887
  // keeps the character bullets, as the preview does.
1732
- async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,slideIndex,options) {
1888
+ async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,slideIndex,options,numbered=false) {
1889
+ // RR-33: a numbered list writes native auto-numbers (a:buAutoNum) at core's marker geometry. Core older than the numbering
1890
+ // release composes bullet markers, so nothing numbered can be written; say so instead of exporting bullets silently.
1891
+ 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.'});
1733
1892
  let picture=false;
1734
1893
  if(bulletImage){
1735
1894
  const part=`ppt/slides/slide${slideIndex+1}.xml`;
@@ -1750,16 +1909,22 @@ async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,s
1750
1909
  const addLines=(text,box,color,native,withBullet)=>{
1751
1910
  text.richLines.forEach((line,index)=>{
1752
1911
  const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
1753
- const region={x:(box.x-inset)/96,y:(box.y+line.y)/96,w:(box.width+inset)/96,h:line.height/96};
1912
+ // 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
1913
+ // is rtl with its text aligned to the right; marL/indent are the start-side margin and hanging indent in PowerPoint.
1914
+ const entryRtl=entry.direction==='rtl';
1915
+ 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};
1916
+ const lineDirection=(text.directions?.[index])??entry.direction;
1917
+ const lineAlign=physicalAlignment('left',lineDirection);
1754
1918
  const objectName=`OPF list ${path} line ${lineNumber++}`;
1755
- if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:pptxColor(context.textColor),picture});
1756
- const paragraph=first?{bullet:{characterCode:entry.marker.text.codePointAt(0).toString(16).padStart(4,'0'),indent:entry.marker.indent*.75},indentLevel:level}:{bullet:false};
1919
+ const number=first?entry.marker.number:undefined;
1920
+ if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:pptxColor(context.textColor),picture:number?false:picture});
1921
+ 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};
1757
1922
  const runs=richLineRuns(line,color,native,context);
1758
1923
  if(!runs.length)runs.push({text:'',options:{}});
1759
1924
  // Keep paragraph intent identical across runs. ZIP normalization below
1760
1925
  // removes the duplicate paragraph-property nodes emitted by PptxGenJS.
1761
- for(const run of runs)Object.assign(run.options,paragraph);
1762
- 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});
1926
+ for(const run of runs)Object.assign(run.options,paragraph,lineDirection==='rtl'?{rtlMode:true}:{});
1927
+ 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});
1763
1928
  });
1764
1929
  };
1765
1930
  addLines(entry.text,entry.textBox,context.colors.text,context.textColor,true);
@@ -1873,7 +2038,7 @@ async function addLogo(slide, presentation, logo, slideIndex, slideContext, cont
1873
2038
  if (prefix) context.logoPlaceholderTags.set(prefix, {v: 1, role: 'placeholder', slide: `slides.${slideIndex}`, path: logo.path});
1874
2039
  return;
1875
2040
  }
1876
- context.logos.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, box: region, path: logo.path, variant: logo.variant});
2041
+ context.logos.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, box: region, path: logo.path, variant: logo.variant, anchor: logo.anchor ?? 'left'});
1877
2042
  if (outcome.svg) context.svgPictures.set(`ppt/slides/slide${slideIndex + 1}.xml|${logoName()}`, outcome.svg);
1878
2043
  slide.addImage({...resolved, objectName: logoName(), ...region, altText: assetAlt(logo.source, presentation) ?? 'Logo'});
1879
2044
  }
@@ -1929,9 +2094,14 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
1929
2094
  // FF-62: chart text is one size. The preview draws every chart label (axis, legend, data label) at
1930
2095
  // max(14, composition.minFontSize ?? 16) px scaled by the slide's shorter side over 720 px (renderer charts.js, `fontPx`),
1931
2096
  // 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`).
2097
+ // 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
2098
+ // product: the floor rounds up (core `snapFontSizeUp`) and an unfloored request of 14 px rounds down (`snapFontSizeDown`).
2099
+ // Math.round differed from the preview by 0.01 pt on slide sizes whose scale is not a whole number (A4, Letter, 16:10).
1932
2100
  function chartTextSize(context) {
1933
2101
  const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
1934
- return Math.round(Math.max(14, context.composition?.minFontSize ?? 16) * scale * 75);
2102
+ const floor = context.composition?.minFontSize ?? 16;
2103
+ const hundredths = Math.max(14, floor) * scale * 75;
2104
+ return floor >= 14 ? Math.ceil(hundredths - 1e-6) : Math.floor(hundredths + 1e-6);
1935
2105
  }
1936
2106
 
1937
2107
  // Every c:txPr of a generated classic chart carries the chart text size: PptxGenJS writes no size on the legend (PowerPoint's
@@ -1950,6 +2120,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1950
2120
  return;
1951
2121
  }
1952
2122
  for (const {adaptation, message} of chartData.adaptations ?? []) options.onDiagnostic?.({code: "chart-data-adapted", path, message, adaptation});
2123
+ // RR-35: axis titles, legend position and data labels (core resolves them against what the chart type can show).
2124
+ const chartOptions = resolveChartOptionsFor(chart);
2125
+ reportChartOptionDiagnostics(chartOptions, path, options.onDiagnostic);
1953
2126
 
1954
2127
  // Keep the resolved palette surface (including alpha) explicit in native
1955
2128
  // chart/plot areas, so inherited labels are assessed against their own panel.
@@ -1960,19 +2133,27 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1960
2133
  const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
1961
2134
  const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
1962
2135
  const textSize = chartTextSize(context);
1963
- context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor,spec:chartData.spec,textSize});
2136
+ context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor,spec:chartData.spec,textSize,
2137
+ options:chartOptions,kind:chartTargetFor(chart.type)?.kind,pointCount:chart.data.rows.length});
1964
2138
  context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
1965
- const palette = CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`)));
2139
+ const preferredPalette = CHART_COLORS.map(color=>`#${color}`);
2140
+ const palette = (typeof opfComposition.chartPaletteForFill === "function" ? opfComposition.chartPaletteForFill(panelFill, preferredPalette) : preferredPalette.map(color=>chartColorForFill(panelFill,color))).map(color=>normalizeHex(color));
2141
+ context.chartPalettes.set(objectName,palette);
1966
2142
  if (chartData.chartex) {
1967
2143
  // The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
1968
2144
  // PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
1969
2145
  const series = chartData.series.map((entry) => ({name: entry.name, labels: [...entry.labels], values: [...entry.values]}));
1970
- context.chartex.set(objectName, {spec: chartData.chartex, series, hasCategories: chartData.hasCategories, fill, labelColor, gridColor: context.colors.border, font: context.fonts.body, palette, textSize});
2146
+ 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});
1971
2147
  if (chartData.chartex.layoutId === 'regionMap') {
1972
2148
  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.`});
1973
2149
  }
1974
2150
  }
1975
2151
  const percent = chartData.spec.grouping === 'percentStacked';
2152
+ // RR-36: a series has one colour, as the preview draws it (opf-render paints series j with palette colour j; only pie,
2153
+ // doughnut and treemap slices take a colour per category). PptxGenJS writes a c:dPt per point, cycling the palette, for a
2154
+ // bar chart with one series whenever chartColors is a custom array of more than one colour, so PowerPoint drew each
2155
+ // column of a single-series column or bar chart in a different colour. One colour for that series writes no c:dPt.
2156
+ const chartColors = chartData.type === 'bar' && chartData.series.length === 1 ? palette.slice(0, 1) : palette;
1976
2157
  slide.addChart(chartData.type, chartData.series, {
1977
2158
  objectName,
1978
2159
  x: region.x,
@@ -1980,8 +2161,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1980
2161
  w: region.w,
1981
2162
  h: region.h,
1982
2163
  showLegend: circular || chartData.series.length > (chartData.type === 'scatter' ? 2 : 1),
2164
+ ...classicChartOptions(chartOptions, {labelColor, font: context.fonts.body, textSize}),
1983
2165
  showTitle: false,
1984
- chartColors: palette,
2166
+ chartColors,
1985
2167
  chartArea: {fill:{...fill},roundedCorners:false},
1986
2168
  // Paint alpha once in the chart area, rather than stacking two alpha fills.
1987
2169
  plotArea: {fill:{color:null}},
@@ -2000,6 +2182,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2000
2182
  valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
2001
2183
  barDir: chartData.barDir,
2002
2184
  barGrouping: chartData.barGrouping,
2185
+ // Right to left (RR-05): column, line and area charts reverse their categories (c:catAx orientation maxMin), which also moves the
2186
+ // value axis to the right, as the preview draws them. Bar charts keep their vertical category axis.
2187
+ ...(context.direction === 'rtl' && ((chartData.type === 'bar' && chartData.barDir === 'col') || chartData.type === 'line' || chartData.type === 'area') ? { catAxisOrientation: 'maxMin' } : {}),
2003
2188
  ...(percent ? { valAxisLabelFormatCode: '0%' } : {}),
2004
2189
  ...(chartData.spec.markers === undefined ? {} : { lineDataSymbol: chartData.spec.markers ? 'circle' : 'none' }),
2005
2190
  ...(chartData.spec.radarStyle ? { radarStyle: chartData.spec.radarStyle } : {}),
@@ -2019,7 +2204,8 @@ function addTablePayload(slide, table, region, context, options, path) {
2019
2204
  }
2020
2205
 
2021
2206
  const layout = layoutTable(table, {x:region.x*96,y:region.y*96,width:region.w*96,height:region.h*96}, {
2022
- scale, minFontSize:context.composition?.minFontSize, fontFamily:context.fonts.body, textMeasurement:options.textMeasurement, path
2207
+ scale, minFontSize:context.composition?.minFontSize, fontFamily:context.fonts.body, textMeasurement:options.textMeasurement, path,
2208
+ ...(context.direction === 'rtl' ? {direction: 'rtl'} : {})
2023
2209
  });
2024
2210
  const columnCount = layout.columnCount;
2025
2211
  const rows = layout.rows.map(row => row.cells.map(cell => {
@@ -2068,7 +2254,7 @@ function addTablePayload(slide, table, region, context, options, path) {
2068
2254
  ...(rich ? {bold:false,italic:false} : {}),
2069
2255
  lineSpacing: fit.lineHeight * 0.75,
2070
2256
  paraSpaceAfter: 0,
2071
- align: cellStyle.align ?? context.contentAlignment,
2257
+ align: physicalAlignment(cellStyle.align ?? context.contentAlignment, cell.direction),
2072
2258
  valign: cellStyle.verticalAlign ?? 'top',
2073
2259
  ...(cell.colSpan > 1 ? {colspan:cell.colSpan} : {}),
2074
2260
  ...(cell.rowSpan > 1 ? {rowspan:cell.rowSpan} : {}),
@@ -2127,7 +2313,8 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
2127
2313
  const link = config.links?.[sourceLineIndex];
2128
2314
  if (fit.sourceLines?.[index]?.boundary === 'hard') sourceLineIndex += 1;
2129
2315
  if (!line&&!config.heading&&!config.sourceText&&!config.timeline&&!config.quote&&!config.keepEmpty) continue;
2130
- const alignment=fit.placement?.alignment??config.align??context.contentAlignment??'left',placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
2316
+ // 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.
2317
+ 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;
2131
2318
  const sourceLine=fit.sourceLines?.[index],boundary=sourceLine?{boundary:sourceLine.boundary,separator:String(text).slice(sourceLine.end,sourceLine.nextStart)}:{};
2132
2319
  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;
2133
2320
  if(config.heading)context.headingTags.set(objectName,{v:1,group:config.path,field:config.heading,line:index,count:fit.lines.length,...boundary});
@@ -2146,6 +2333,7 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
2146
2333
  ...textBoxOptions(area, context, fit.fontSize * .75),
2147
2334
  ...nativeFontOptions(style),
2148
2335
  color: lineColor, align: alignment,
2336
+ ...(fit.directions?.[index]==='rtl'?{rtlMode:true}:{}),
2149
2337
  // Reuse the frame's existing relationship to cover blank caption boxes.
2150
2338
  // PptxGenJS also inherits it into runs; keep their native text styling.
2151
2339
  hyperlink: config.hyperlink,
@@ -2214,7 +2402,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
2214
2402
 
2215
2403
  const bulletImageName = () => 'OPF bullet image';
2216
2404
 
2217
- function addCodePayload(slide, value, layout, region, context, path) {
2405
+ function addCodePayload(slide, value, layout, region, context, path, options) {
2218
2406
  if (!layout) throw new OPFPptxError('missing-code-layout', 'Code export requires a coordinated core build with shared code geometry.', {path});
2219
2407
  for (const part of layout.parts) {
2220
2408
  // XML 1.0 Char excludes controls and unpaired UTF-16 surrogates. The u flag
@@ -2225,6 +2413,7 @@ function addCodePayload(slide, value, layout, region, context, path) {
2225
2413
  const group = String(context.codeTags.size + 1), panelName = `OPF code ${group} panel`;
2226
2414
  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});
2227
2415
  context.codeTags.set(panelName,codeManifest(value,layout,group));
2416
+ const syntax = codeSyntax(value, layout, context, path, options);
2228
2417
  slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', width: .75}, objectName:panelName});
2229
2418
  for (const [partIndex,part] of layout.parts.entries()) {
2230
2419
  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});
@@ -2232,19 +2421,37 @@ function addCodePayload(slide, value, layout, region, context, path) {
2232
2421
  const tabStops=line.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'}));
2233
2422
  const objectName = `OPF code ${group} ${part.role} line ${index+1}`;
2234
2423
  context.codeTags.set(objectName,{v:1,group,role:'line',part:partIndex,line:index});
2235
- slide.addText(part.text.slice(line.start,line.end),{
2424
+ // RR-07: the same token ranges and palette the preview paints; the text of the runs is the line text, unchanged.
2425
+ const runs = syntax && part.role==='body' ? opfCore.codeLineRuns(syntax.tokens,line.start,line.end,part.text) : undefined;
2426
+ const lineText = part.text.slice(line.start,line.end);
2427
+ const lineOptions = {
2236
2428
  ...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),
2237
2429
  ...nativeFontOptions(part.style),
2238
2430
  color:part.role==='body'?'E5E7EB':'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
2239
2431
  tabStops:tabStops.length?tabStops:undefined,objectName,
2240
- });
2432
+ };
2433
+ // 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.
2434
+ 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);
2241
2435
  }
2242
2436
  }
2243
2437
  }
2244
2438
 
2245
- function addMetricPayload(slide,value,layout,context,path) {
2439
+ // Token ranges and palette for the code body, from core (the preview calls the same functions); undefined for plain code.
2440
+ function codeSyntax(value, layout, context, path, options) {
2441
+ const body = layout.parts.find(part => part.role === 'body');
2442
+ const language = typeof value?.language === 'string' ? value.language : layout.parts.find(part => part.role === 'language')?.text;
2443
+ if (!body || !language) return undefined;
2444
+ if (typeof opfCore.tokenizeCode !== 'function') return undefined;
2445
+ const tokens = opfCore.tokenizeCode(body.text, language);
2446
+ return tokens.length ? {tokens, palette: opfCore.codeSyntaxPaletteForScheme(context.colorScheme)} : undefined;
2447
+ }
2448
+
2449
+ function addMetricPayload(slide,value,layout,context,path,options) {
2246
2450
  if (!layout) throw new OPFPptxError('missing-metric-layout','Metric export requires coordinated core metric geometry.',{path});
2247
2451
  const group=String(context.metricTags.size+1),manifest=metricManifest(value,layout,group);
2452
+ // RR-07: a trend colours the trend and delta text and adds one native arrow shape, from core's accepted geometry.
2453
+ const trendMark=typeof opfCore.metricTrendMark==='function'?opfCore.metricTrendMark(layout,{background:`#${normalizeHex(context.colors.background)}`}):undefined;
2454
+ const partColor=part=>trendMark&&(part.role==='trend'||part.role==='delta')?trendMark.color.slice(1):part.role==='value'?context.colors.accent:context.textColor;
2248
2455
  for (const [partIndex,part] of layout.parts.entries()) {
2249
2456
  const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
2250
2457
  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});
@@ -2262,11 +2469,17 @@ function addMetricPayload(slide,value,layout,context,path) {
2262
2469
  slide.addText(part.text.slice(line.start,line.end),{
2263
2470
  ...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),
2264
2471
  ...nativeFontOptions(part.style),
2265
- color:part.role==='value'?context.colors.accent:context.textColor,align:layout.alignment,fit:'none',wrap:false,lineSpacingMultiple:1,
2472
+ color:partColor(part),align:layout.alignment,...(part.fit.directions?.[index]==='rtl'?{rtlMode:true}:{}),fit:'none',wrap:false,lineSpacingMultiple:1,
2266
2473
  tabStops:tabStops.length?tabStops:undefined,objectName,
2267
2474
  });
2268
2475
  }
2269
2476
  }
2477
+ if(trendMark) {
2478
+ const objectName=`OPF metric ${group} trend mark`;
2479
+ context.metricTags.set(objectName,{v:1,group,role:'mark'});
2480
+ context.metricDescriptions.set(objectName,trendMark.ariaLabel);
2481
+ 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});
2482
+ }
2270
2483
  }
2271
2484
 
2272
2485
  function addQuotePayload(slide, value, layout, context, options, path) {
@@ -2492,6 +2705,9 @@ function textBoxOptions(region, context, fontSize) {
2492
2705
  };
2493
2706
  }
2494
2707
 
2708
+ // The private text helpers annotation-export.js writes captions and footnote lines with (RR-34).
2709
+ const exportHelpers = {textBoxOptions, nativeFontOptions, exportColor, nativeColor};
2710
+
2495
2711
  function textRuns(value, context, fallbackFontSize) {
2496
2712
  const runs = Array.isArray(value) ? value : [value];
2497
2713
  return runs.map((run) => {
@@ -2978,13 +3194,14 @@ async function normalizePptxZip(raw, context) {
2978
3194
  }
2979
3195
 
2980
3196
  attachCodeTags(entries, context.codeTags);
2981
- attachMetricTags(entries,context.metricTags);
3197
+ attachMetricTags(entries,context.metricTags,context.metricDescriptions);
2982
3198
  attachCardTags(entries,context.cardTags);
2983
3199
  attachMediaTags(entries,context.mediaTags);
2984
3200
  attachHeadingTags(entries,context.headingTags);
2985
3201
  attachPlainTextTags(entries,context.plainTextTags);
2986
3202
  attachTimelineTags(entries,context.timelineTags);
2987
3203
  attachQuoteTags(entries,context.quoteTags);
3204
+ attachAnnotationTags(entries,context);
2988
3205
  attachFurnitureFields(entries,context.furnitureFields);
2989
3206
  attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests,context.furnitureLogoTags);
2990
3207
  // RR-11: the recorded footer shapes become PowerPoint's date, footer and slide-number placeholders.
@@ -3006,6 +3223,9 @@ async function normalizePptxZip(raw, context) {
3006
3223
  // Normalize only our generated chart text styles; point/series fills stay intact.
3007
3224
  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>`)));
3008
3225
  entries[chartPart]=encodeText(applyChartTextSize(decodeText(entries[chartPart]),textSize));
3226
+ // RR-35: data labels last, so their own text colours (contrast inside a mark) are not normalized away.
3227
+ const {options:chartOptions,kind:optionKind,pointCount}=context.chartHeadings.get(name);
3228
+ 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}));
3009
3229
  }
3010
3230
  }
3011
3231
  applyChartFonts(entries,context.chartFonts,parseRelationships);
@@ -3103,6 +3323,7 @@ async function normalizePptxZip(raw, context) {
3103
3323
  }
3104
3324
 
3105
3325
  writeNativeFurnitureMasters(output, context);
3326
+ giveNotesMastersOwnThemes(output);
3106
3327
  finalizeFontsUsed(output);
3107
3328
  // Document references record evidence from the final normalized parts.
3108
3329
  if (context.documentProvenance) {
@@ -3181,6 +3402,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3181
3402
  if (/^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(path)) xml = themeMasterBulletFonts(xml);
3182
3403
  if (context.masterBackground && path === 'ppt/slideMasters/slideMaster1.xml') xml = writeMasterBackground(xml, context.masterBackground);
3183
3404
  if (context.masterBackground && /^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(path)) xml = inheritLayoutBackground(xml);
3405
+ if (path === 'ppt/presentation.xml') xml = presentationInSchemaOrder(xml);
3184
3406
  if (path === 'ppt/theme/theme1.xml') xml = writeThemeColors(xml, {colors: context.themeColors, schemeName: context.schemeName, themeName: context.themeName});
3185
3407
  if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) {
3186
3408
  xml = writeLinkSentinels(xml, context.linkSentinels);
@@ -3302,6 +3524,9 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3302
3524
  if(!marker)return shape;
3303
3525
  if(marker.picture&&bulletRelationship)return shape.replace(/<a:buChar\b[^>]*\/>/g,`<a:buBlip><a:blip r:embed="${bulletRelationship}"/></a:buBlip>`);
3304
3526
  const family=marker.fontFamily.replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[char]));
3527
+ // RR-33: an auto-number takes the measured size, colour and family like a character bullet, and startAt only when it is not 1.
3528
+ const autoNumber=/<a:buSzPct val="100000"\/><a:buFont typeface="\+mj-lt"\/><a:buAutoNum type="([A-Za-z]+)" startAt="(\d+)"\/>/;
3529
+ 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}"`}/>`);
3305
3530
  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}"/>`);
3306
3531
  });
3307
3532
  // PptxGenJS gives shapes no alternative text. An unavailable image's panel
@@ -3322,6 +3547,8 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3322
3547
  }
3323
3548
  // theme1.xml: FF-24 colors and names above, then FF-07 fonts here; they touch disjoint elements.
3324
3549
  if (context.scriptFonts) xml = partScriptFonts(path, xml, context.scriptFonts, context.partSlides.get(path) ?? 0);
3550
+ else if (/^ppt\/theme\/theme\d+\.xml$/.test(path)) xml = themeEastAsianFromLatin(xml);
3551
+ if (/^ppt\/(?:slides\/slide|notesSlides\/notesSlide)\d+\.xml$/.test(path)) xml = stripRunScriptFonts(xml);
3325
3552
  return encodeText(normalizePartReferences(xml, renameMaps));
3326
3553
  }
3327
3554
  return bytes;
@@ -3340,6 +3567,16 @@ function themeMasterBulletFonts(xml) {
3340
3567
  bodyStyle.split(VENDOR_MASTER_BULLET_FONT).join(THEME_MINOR_BULLET_FONT));
3341
3568
  }
3342
3569
 
3570
+ // FF-05: PptxGenJS 4.0.1 writes p:notesMasterIdLst after p:sldIdLst, but CT_Presentation is a sequence
3571
+ // (sldMasterIdLst, notesMasterIdLst, handoutMasterIdLst, sldIdLst, sldSz, ...). PowerPoint reads the out-of-order
3572
+ // list as no notes master and lists a default-theme font (Aptos) in Presentation.Fonts; schema order restores
3573
+ // the exporter's own notes master. Only the list's position changes.
3574
+ function presentationInSchemaOrder(xml) {
3575
+ const notes = /<p:notesMasterIdLst>[\s\S]*?<\/p:notesMasterIdLst>/.exec(xml)?.[0];
3576
+ if (!notes || xml.indexOf(notes) < xml.indexOf('</p:sldMasterIdLst>')) return xml;
3577
+ return xml.replace(notes, '').replace('</p:sldMasterIdLst>', `</p:sldMasterIdLst>${notes}`);
3578
+ }
3579
+
3343
3580
  function isXmlPart(path) {
3344
3581
  return path.endsWith(".xml") || path.endsWith(".rels") || path === "[Content_Types].xml";
3345
3582
  }
@@ -3533,7 +3770,7 @@ function numericValue(value) {
3533
3770
 
3534
3771
  function normalizeAuthor(author) {
3535
3772
  if (typeof author === "string") return author;
3536
- if (Array.isArray(author)) return author.join("; ");
3773
+ if (Array.isArray(author)) return joinAuthors(author);
3537
3774
  return null;
3538
3775
  }
3539
3776