@openpresentation/opf-pptx 0.11.9 → 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,20 +1,27 @@
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';
20
27
  import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, writeNativeMasters} from './native-furniture.js';
@@ -27,11 +34,13 @@ import {placeLogos, importLogo, importLogoPlaceholders, logoName, LOGO_TAG} from
27
34
  import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
28
35
  import {importBackground} from './background-import.js';
29
36
  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';
37
+ import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, reconcileLanguage} from './script-fonts.js';
31
38
  import { webpToPng, svgToPng, readLocalFile } from '#image-fallback';
32
39
  import { prepareSvg, svgDataUriBytes, svgRasterScale, svgBlipRelationship, attachSvgPictures } from './svg-image.js';
33
40
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
34
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";
35
44
  import { colorContext, resolveColorRefValue, resolveExportColor, resolveVariableColors } from "./color-ref.js";
36
45
  import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
37
46
  import { unzipSync, zipSync } from "fflate";
@@ -40,6 +49,11 @@ import {
40
49
  catalogs as bundledCatalogs,
41
50
  validatePresentation
42
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";
43
57
 
44
58
  export {checkPptxTypefaces, inventoryPptxTypefaces, packageFontsUsed, THEME_SCRIPT_SUPPLEMENTS} from './typeface-inventory.js';
45
59
  export {DEFAULT_SIGNAL_LIMITS, SIGNALS_VERSION, isMonospaceFamily} from './import-signals.js';
@@ -205,7 +219,7 @@ export async function toPptx(input, options = {}) {
205
219
  if (options.provenance !== undefined && !['full', 'references-only', false].includes(options.provenance)) {
206
220
  throw new OPFPptxError('invalid-provenance-option', "provenance must be 'full', 'references-only' or false.", {path: 'options.provenance'});
207
221
  }
208
- const presentation = parseInput(input);
222
+ const presentation = resolveTemplateInput(parseInput(input), options);
209
223
  assertValidBoundary(presentation);
210
224
  options = {...options, textMeasurement: chosenFamilyMeasurement(options.textMeasurement), svgRasters: new Map()};
211
225
 
@@ -238,9 +252,13 @@ export async function toPptx(input, options = {}) {
238
252
  context.nativeFurniture = new Map();
239
253
  context.hostDate = options.date;
240
254
  context.codeTags = new Map();
255
+ context.captionTags = new Map();
256
+ context.footnoteTags = new Map();
241
257
  context.metricTags = new Map();
258
+ context.metricDescriptions = new Map();
242
259
  context.chartHeadings = new Map();
243
260
  context.chartFonts = new Map();
261
+ context.chartPalettes = new Map();
244
262
  context.chartex = new Map();
245
263
  context.imageFormat = options.imageFormat ?? "compatible";
246
264
  // Chartex export mode (FF-22b): 'auto' (default) writes native chartex parts for the constructs the native PowerPoint
@@ -327,7 +345,7 @@ export async function fromPptx(input, options = {}) {
327
345
  });
328
346
  if (observedLanguage.language !== undefined) imported.language = observedLanguage.language;
329
347
  if (core.description) imported.description = core.description;
330
- if (core.author) imported.author = core.author;
348
+ if (core.author) imported.author = splitAuthors(core.author);
331
349
  const themeDesign = importThemeDesign(entries, presentationRoot, presentationRels);
332
350
  const design = {...themeDesign.design, ...(dimensions ? {dimensions} : {})};
333
351
  if (Object.keys(design).length) imported.design = design;
@@ -353,7 +371,7 @@ export async function fromPptx(input, options = {}) {
353
371
  const mediaRegistry = Object.create(null);
354
372
  for (let index = 0; index < slidePaths.length; index += 1) {
355
373
  furnitureContexts[index].mediaRegistry = mediaRegistry;
356
- 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]));
357
375
  }
358
376
  // Native sections (PowerPoint's own section list, `Default Section` = none)
359
377
  // are reconciled with the footer text and the stored value in restoreDocumentProvenance.
@@ -396,7 +414,11 @@ export async function fromPptx(input, options = {}) {
396
414
  report({code: "invalid-document-provenance", path: "", message: `${errorMessage(error)} Ordinary import keeps the values observed in the PPTX.`});
397
415
  }
398
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);
399
420
  restoreLogoFallback(imported, furnitureContexts);
421
+ applyRunColors(imported, slideProvenance, restoredGroups, report, options);
400
422
  if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
401
423
  languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
402
424
 
@@ -418,6 +440,24 @@ export async function fromPptx(input, options = {}) {
418
440
  return {document: imported, signals};
419
441
  }
420
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);
459
+ }
460
+
421
461
  // A consumed logo picture (OPF_LOGO_V1) never becomes content. Its own image restores the logo only when nothing else
422
462
  // did: the stored document and slide design (or the organization) win and carry LogoSet variants, so a package exported
423
463
  // without provenance still keeps its logo. A slide-level logo path restores slide design; every other path the deck's.
@@ -639,10 +679,32 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
639
679
  item.text = current.map(paragraph => paragraph.text).join('\n');
640
680
  }
641
681
  }
642
- const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items))
643
- .map((item) => ({payload: payloadFromSlideItem(item), bounds: item.visualBounds ?? item.bounds, sources: item.sources ?? []}))
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 ?? []}))
644
685
  .filter((entry) => entry.payload);
645
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
+ }
646
708
  if (blocks.length > 0) slide.blocks = blocks;
647
709
  // Native bounds of each block in reference px, for content topology matching (document provenance).
648
710
  // Which native shapes fed each field and block (import signals); never part of the document.
@@ -664,6 +726,12 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
664
726
  const shapes = nativeContext.shapes;
665
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.'});
666
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
+ });
667
735
  // Native shape keys (indexes in nativeTextShapes / nativePictures / frame order) for import signals.
668
736
  const shapeKeys = new Map(shapes.map((shape, index) => [shape, `sp:${index}`]));
669
737
  const sourcesOf = group => group.flatMap(shape => shapeKeys.has(shape) ? [shapeKeys.get(shape)] : []);
@@ -681,6 +749,9 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
681
749
  const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
682
750
  const timelines=importTimelineGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
683
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;
684
755
  for(const group of [...headings.items,...plainText.items]) {
685
756
  const item=importShape(group.shapes[0],dimensions,group.paragraphs,true);
686
757
  if(item){
@@ -707,6 +778,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
707
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;
708
779
  if (role) roles.set(shapeKeys.get(shape), role);
709
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; }
710
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;
711
783
  const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
712
784
  const item = importShape(shape, dimensions, paragraphs[index], ordinaryBody || furniture.taggedText.has(index) || media.captionShapes.has(shape));
@@ -718,12 +790,14 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
718
790
  }
719
791
 
720
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}`});
721
795
  const tables = frames.some(frame => frame['a:graphic']?.['a:graphicData']?.['a:tbl'])
722
796
  ? importTableFrames(slidePath, {
723
797
  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) : [];
798
+ }, relationships, (frame, cell, code, message) => options.onDiagnostic?.({code, message, path: `slides.${slideIndex}.tables.${frame}${cell ? '.' + cell : ''}`}), dimensions, {rtlDeck: options.rtlDeck === true}) : [];
725
799
  for (const [index, frame] of frames.entries()) {
726
- const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index]);
800
+ const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index], chartReport(`charts.${index}`));
727
801
  if (item) items.push({...item, sources: [`frame:${index}`]});
728
802
  }
729
803
  // A chartex chart is an mc:AlternateContent: the choice frame references the cx:chartSpace part; the fallback is a classic chart frame
@@ -731,8 +805,8 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
731
805
  for (const [alternateIndex, alternate] of asArray(tree?.["mc:AlternateContent"]).entries()) {
732
806
  const choice = asArray(alternate?.["mc:Choice"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
733
807
  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;
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;
736
810
  if (item && item.kind !== "unknown") items.push({...item, sources: [`alt:${alternateIndex}`]});
737
811
  }
738
812
 
@@ -774,7 +848,7 @@ function importShape(shape, dimensions, paragraphs = readParagraphs(shape["p:txB
774
848
  };
775
849
  }
776
850
 
777
- function importGraphicFrame(entries, frame, slidePath, relationships, importedTable) {
851
+ function importGraphicFrame(entries, frame, slidePath, relationships, importedTable, report) {
778
852
  const bounds = shapeBounds(frame["p:xfrm"]);
779
853
  const name = scalarText(frame["p:nvGraphicFramePr"]?.["p:cNvPr"]?.name).trim();
780
854
  const graphicData = frame["a:graphic"]?.["a:graphicData"];
@@ -795,8 +869,8 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
795
869
  const chartRelId = graphicData?.["c:chart"]?.["r:id"] ?? chartExRelId;
796
870
  if (chartRelId) {
797
871
  const chart = chartExRelId
798
- ? chartexFromRelationship(entries, relationships, chartExRelId)
799
- : chartFromRelationship(entries, slidePath, relationships, chartRelId);
872
+ ? chartexFromRelationship(entries, relationships, chartExRelId, report)
873
+ : chartFromRelationship(entries, slidePath, relationships, chartRelId, report);
800
874
  return {
801
875
  kind: "chart",
802
876
  bounds,
@@ -879,11 +953,17 @@ function importPicture(entries, picture, slidePath, relationships, report) {
879
953
  }
880
954
 
881
955
  function readParagraphs(txBody) {
882
- return asArray(txBody?.["a:p"])
956
+ return withDisplayedNumbers(asArray(txBody?.["a:p"])
883
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"])]);
884
963
  const runs = [
885
964
  ...asArray(paragraph?.["a:r"]),
886
- ...asArray(paragraph?.["a:fld"])
965
+ ...asArray(paragraph?.["a:fld"]),
966
+ ...fallbackRuns
887
967
  ];
888
968
  const texts = [];
889
969
  const sizes = [];
@@ -893,13 +973,29 @@ function readParagraphs(txBody) {
893
973
  const size = Number(run?.["a:rPr"]?.sz);
894
974
  if (Number.isFinite(size)) sizes.push(size / 100);
895
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);
896
978
  return {
897
979
  text: texts.join(""),
898
980
  bullet: asArray(paragraph?.["a:pPr"]).some(props => props?.["a:buChar"] !== undefined || props?.["a:buBlip"] !== undefined || props?.["a:buAutoNum"] !== undefined),
899
981
  level: Number(asArray(paragraph?.["a:pPr"])[0]?.lvl ?? 0),
900
- 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])
901
985
  };
902
- });
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("");
903
999
  }
904
1000
 
905
1001
  function shapePlaceholderType(shape) {
@@ -966,7 +1062,10 @@ function joinRich(a, b) {
966
1062
  }
967
1063
  return joined;
968
1064
  }
969
- 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 = () => {}) {
970
1069
  const lists = new Map();
971
1070
  for (const item of items) {
972
1071
  const match = /^OPF list (.+) line (\d+)$/.exec(item.name ?? '');
@@ -980,17 +1079,23 @@ function mergeOpfListShapes(items) {
980
1079
  entries.sort((a, b) => a.line - b.line);
981
1080
  if (!entries[0].item.paragraphs[0].bullet) continue;
982
1081
  const paragraphs = [];
1082
+ const recorded = breaks?.get(path), applied = new Set();
983
1083
  let bounds, size = 0;
984
- for (const {item} of entries) {
1084
+ for (const {item, line} of entries) {
985
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]);
986
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 */ }
987
1090
  else if (paragraph.maxFontSize < size - 0.01) {
988
1091
  const text = paragraph.richText ?? paragraph.text;
989
- 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); }
990
1094
  } else {
991
1095
  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);
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);
994
1099
  }
995
1100
  const b = item.visualBounds ?? item.bounds;
996
1101
  if (b) {
@@ -999,6 +1104,7 @@ function mergeOpfListShapes(items) {
999
1104
  }
1000
1105
  consumed.add(item);
1001
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.`});
1002
1108
  const first = entries[0].item;
1003
1109
  merged.set(first, {...first, paragraphs, text: paragraphs.map(paragraph => paragraph.text).join('\n'), bounds, visualBounds: undefined, sources: entries.flatMap(({item}) => item.sources ?? [])});
1004
1110
  }
@@ -1025,17 +1131,23 @@ function mergeAdjacentBulletShapes(items) {
1025
1131
  return result;
1026
1132
  }
1027
1133
 
1028
- function payloadFromSlideItem(item) {
1134
+ function payloadFromSlideItem(item, report) {
1029
1135
  if (item.payload) return item.payload;
1030
1136
  if (item.kind === "text") {
1031
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);
1032
1141
  return {
1033
1142
  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
- ))
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} : {})
1039
1151
  };
1040
1152
  }
1041
1153
  return { type: "text", text: joinNativeParagraphs(item.paragraphs) };
@@ -1044,7 +1156,7 @@ function payloadFromSlideItem(item) {
1044
1156
  return null;
1045
1157
  }
1046
1158
 
1047
- function chartFromRelationship(entries, slidePath, relationships, relId) {
1159
+ function chartFromRelationship(entries, slidePath, relationships, relId, report) {
1048
1160
  const relationship = relationships.get(relId);
1049
1161
  if (!relationship?.path || !entries[relationship.path]) return null;
1050
1162
  const doc = parseRequiredXml(entries, relationship.path);
@@ -1061,7 +1173,13 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
1061
1173
 
1062
1174
  const budget = { cells: 0 };
1063
1175
  const cachePath = (index, role) => `${relationship.path}#c:ser[${index}]/${role}`;
1064
- 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));
1065
1183
  const labels = cachedValues(series[0]?.["c:cat"], cachePath(0, 'c:cat'), budget);
1066
1184
  const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, 'c:tx'), budget) ?? `Series ${index + 1}`);
1067
1185
  const values = series.map((entry, index) => {
@@ -1082,13 +1200,13 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
1082
1200
  ]);
1083
1201
  }
1084
1202
 
1085
- return {
1203
+ return withOptions({
1086
1204
  type: chartNode.type,
1087
1205
  data: {
1088
1206
  columns: [readChartCategoryHeading(entries,relationship.path) ?? "Category", ...names],
1089
1207
  rows
1090
1208
  }
1091
- };
1209
+ });
1092
1210
  }
1093
1211
 
1094
1212
  // Scatter charts share X values (c:xVal) across Y series (c:yVal). OPF keeps
@@ -1114,7 +1232,7 @@ function scatterFromSeries(entries, chartPart, series, budget, cachePath) {
1114
1232
  // A chartex part (cx:chartSpace): the series layoutIds name the kept OPF chart
1115
1233
  // type and the cached dimensions restore the category-major data, under the
1116
1234
  // same bounds as the classic chart cache.
1117
- function chartexFromRelationship(entries, relationships, relId) {
1235
+ function chartexFromRelationship(entries, relationships, relId, report) {
1118
1236
  const relationship = relationships.get(relId);
1119
1237
  if (!relationship?.path || !entries[relationship.path]) return null;
1120
1238
  const doc = parseRequiredXml(entries, relationship.path);
@@ -1122,6 +1240,7 @@ function chartexFromRelationship(entries, relationships, relId) {
1122
1240
  heading: readChartCategoryHeading(entries, relationship.path),
1123
1241
  limits: {points: MAX_CHART_CACHE_POINTS, cells: MAX_CHART_CACHE_CELLS},
1124
1242
  path: relationship.path,
1243
+ report,
1125
1244
  invalid: (message, location) => {
1126
1245
  throw new OPFPptxError('invalid-chart-cache', `Invalid chart cache: ${message}. Repair the chart data before importing.`, {path: location ?? relationship.path});
1127
1246
  }
@@ -1306,6 +1425,30 @@ function parseInput(input) {
1306
1425
  throw new OPFPptxError("invalid-input", "OPF input must be a parsed object, JSON string, or Uint8Array.");
1307
1426
  }
1308
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
+
1309
1452
  function assertValidBoundary(presentation) {
1310
1453
  const result = validatePresentation(presentation);
1311
1454
  if (!result.valid) {
@@ -1489,7 +1632,7 @@ function configurePresentation(pptx, presentation, context) {
1489
1632
  height: context.dimensions.heightInches
1490
1633
  });
1491
1634
  pptx.layout = context.layoutName;
1492
- pptx.author = normalizeAuthor(presentation.author) ?? "OpenPresentation";
1635
+ pptx.author = normalizeAuthor(presentation.author) ?? DEFAULT_AUTHOR;
1493
1636
  pptx.company = "OpenPresentation";
1494
1637
  pptx.subject = presentation.description ?? "";
1495
1638
  pptx.title = presentation.name ?? presentation.filename ?? "OPF Presentation";
@@ -1582,30 +1725,42 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1582
1725
  ...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),
1583
1726
  ...nativeFontOptions(item.textStyle),
1584
1727
  color: item.field === "tag" ? tagColor(itemContext) : itemContext.textColor,
1585
- align: alignmentFor(item),
1728
+ align: physicalAlignment(alignmentFor(item), item.text.directions?.[0]),
1729
+ ...(item.text.directions?.[0] === 'rtl' ? {rtlMode: true} : {}),
1586
1730
  objectName,
1587
1731
  breakLine: false
1588
1732
  });
1589
1733
  } 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);
1734
+ await addMeasuredList(slide,item.text,itemContext,item.path,item.bulletImage,presentation,slideIndex,options,item.payload?.numbering!==undefined);
1591
1735
  } else if (item.field === "text" && item.text?.richLines) {
1592
- const alignment=item.text.placement?.alignment??alignmentFor(item)??'left';
1736
+ const logicalAlignment=item.text.placement?.alignment??alignmentFor(item)??'left';
1593
1737
  for(const [index,line] of item.text.richLines.entries()){
1594
1738
  const runs=line.fragments.map(fragment=>{
1595
1739
  const runColor=exportColor(fragment.run.color,itemContext,itemContext.colors.text);
1596
1740
  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}};
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}};
1598
1742
  });
1599
- 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;
1600
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;
1601
1748
  if(runs.length)slide.addText(runs,{...textBoxOptions(area,itemContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
1602
1749
  }
1603
1750
  } 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)});
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])});
1605
1752
  } 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);
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
+ }
1607
1760
  }
1608
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}`}));
1609
1764
  // Core composes furniture above all content and opf-render paints it last, so
1610
1765
  // spTree order (PowerPoint's z-order) matches: header/footer parts come after
1611
1766
  // every content item, and an overlapping footer stays visible over content.
@@ -1674,10 +1829,10 @@ async function addPayload(slide, presentation, payload, region, path, context, o
1674
1829
  addTablePayload(slide, payload.table, region, context, options, path);
1675
1830
  break;
1676
1831
  case "code":
1677
- addCodePayload(slide, payload.code, codeLayout, region, context, path);
1832
+ addCodePayload(slide, payload.code, codeLayout, region, context, path, options);
1678
1833
  break;
1679
1834
  case "metric":
1680
- addMetricPayload(slide, payload.metric,metricLayout,context,path);
1835
+ addMetricPayload(slide, payload.metric,metricLayout,context,path,options);
1681
1836
  break;
1682
1837
  case "quote":
1683
1838
  addQuotePayload(slide, payload.quote, quoteLayout, context, options, path);
@@ -1717,7 +1872,7 @@ function richLineRuns(line,color,native,context) {
1717
1872
  const fallback=color.replace(/^#/,'');
1718
1873
  return line.fragments.map(fragment=>{
1719
1874
  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}};
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}};
1721
1876
  });
1722
1877
  }
1723
1878
  // Every native line of a list, marker paragraph or not, is named for the list's
@@ -1729,7 +1884,10 @@ function richLineRuns(line,color,native,context) {
1729
1884
  // PptxGenJS embeds the media and its relationship through one picture per slide (OPF bullet image); packaging removes
1730
1885
  // that picture and points each marker paragraph at the relationship with a:buBlip. An icon that cannot be embedded
1731
1886
  // keeps the character bullets, as the preview does.
1732
- 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.'});
1733
1891
  let picture=false;
1734
1892
  if(bulletImage){
1735
1893
  const part=`ppt/slides/slide${slideIndex+1}.xml`;
@@ -1750,16 +1908,22 @@ async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,s
1750
1908
  const addLines=(text,box,color,native,withBullet)=>{
1751
1909
  text.richLines.forEach((line,index)=>{
1752
1910
  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};
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);
1754
1917
  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};
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};
1757
1921
  const runs=richLineRuns(line,color,native,context);
1758
1922
  if(!runs.length)runs.push({text:'',options:{}});
1759
1923
  // Keep paragraph intent identical across runs. ZIP normalization below
1760
1924
  // 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});
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});
1763
1927
  });
1764
1928
  };
1765
1929
  addLines(entry.text,entry.textBox,context.colors.text,context.textColor,true);
@@ -1873,7 +2037,7 @@ async function addLogo(slide, presentation, logo, slideIndex, slideContext, cont
1873
2037
  if (prefix) context.logoPlaceholderTags.set(prefix, {v: 1, role: 'placeholder', slide: `slides.${slideIndex}`, path: logo.path});
1874
2038
  return;
1875
2039
  }
1876
- 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'});
1877
2041
  if (outcome.svg) context.svgPictures.set(`ppt/slides/slide${slideIndex + 1}.xml|${logoName()}`, outcome.svg);
1878
2042
  slide.addImage({...resolved, objectName: logoName(), ...region, altText: assetAlt(logo.source, presentation) ?? 'Logo'});
1879
2043
  }
@@ -1929,9 +2093,14 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
1929
2093
  // FF-62: chart text is one size. The preview draws every chart label (axis, legend, data label) at
1930
2094
  // max(14, composition.minFontSize ?? 16) px scaled by the slide's shorter side over 720 px (renderer charts.js, `fontPx`),
1931
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).
1932
2099
  function chartTextSize(context) {
1933
2100
  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);
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);
1935
2104
  }
1936
2105
 
1937
2106
  // Every c:txPr of a generated classic chart carries the chart text size: PptxGenJS writes no size on the legend (PowerPoint's
@@ -1950,6 +2119,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1950
2119
  return;
1951
2120
  }
1952
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);
1953
2125
 
1954
2126
  // Keep the resolved palette surface (including alpha) explicit in native
1955
2127
  // chart/plot areas, so inherited labels are assessed against their own panel.
@@ -1960,19 +2132,27 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1960
2132
  const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
1961
2133
  const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
1962
2134
  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});
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});
1964
2137
  context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
1965
- 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);
1966
2141
  if (chartData.chartex) {
1967
2142
  // The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
1968
2143
  // PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
1969
2144
  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});
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});
1971
2146
  if (chartData.chartex.layoutId === 'regionMap') {
1972
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.`});
1973
2148
  }
1974
2149
  }
1975
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;
1976
2156
  slide.addChart(chartData.type, chartData.series, {
1977
2157
  objectName,
1978
2158
  x: region.x,
@@ -1980,8 +2160,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1980
2160
  w: region.w,
1981
2161
  h: region.h,
1982
2162
  showLegend: circular || chartData.series.length > (chartData.type === 'scatter' ? 2 : 1),
2163
+ ...classicChartOptions(chartOptions, {labelColor, font: context.fonts.body, textSize}),
1983
2164
  showTitle: false,
1984
- chartColors: palette,
2165
+ chartColors,
1985
2166
  chartArea: {fill:{...fill},roundedCorners:false},
1986
2167
  // Paint alpha once in the chart area, rather than stacking two alpha fills.
1987
2168
  plotArea: {fill:{color:null}},
@@ -2000,6 +2181,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2000
2181
  valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
2001
2182
  barDir: chartData.barDir,
2002
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' } : {}),
2003
2187
  ...(percent ? { valAxisLabelFormatCode: '0%' } : {}),
2004
2188
  ...(chartData.spec.markers === undefined ? {} : { lineDataSymbol: chartData.spec.markers ? 'circle' : 'none' }),
2005
2189
  ...(chartData.spec.radarStyle ? { radarStyle: chartData.spec.radarStyle } : {}),
@@ -2019,7 +2203,8 @@ function addTablePayload(slide, table, region, context, options, path) {
2019
2203
  }
2020
2204
 
2021
2205
  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
2206
+ scale, minFontSize:context.composition?.minFontSize, fontFamily:context.fonts.body, textMeasurement:options.textMeasurement, path,
2207
+ ...(context.direction === 'rtl' ? {direction: 'rtl'} : {})
2023
2208
  });
2024
2209
  const columnCount = layout.columnCount;
2025
2210
  const rows = layout.rows.map(row => row.cells.map(cell => {
@@ -2068,7 +2253,7 @@ function addTablePayload(slide, table, region, context, options, path) {
2068
2253
  ...(rich ? {bold:false,italic:false} : {}),
2069
2254
  lineSpacing: fit.lineHeight * 0.75,
2070
2255
  paraSpaceAfter: 0,
2071
- align: cellStyle.align ?? context.contentAlignment,
2256
+ align: physicalAlignment(cellStyle.align ?? context.contentAlignment, cell.direction),
2072
2257
  valign: cellStyle.verticalAlign ?? 'top',
2073
2258
  ...(cell.colSpan > 1 ? {colspan:cell.colSpan} : {}),
2074
2259
  ...(cell.rowSpan > 1 ? {rowspan:cell.rowSpan} : {}),
@@ -2127,7 +2312,8 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
2127
2312
  const link = config.links?.[sourceLineIndex];
2128
2313
  if (fit.sourceLines?.[index]?.boundary === 'hard') sourceLineIndex += 1;
2129
2314
  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;
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;
2131
2317
  const sourceLine=fit.sourceLines?.[index],boundary=sourceLine?{boundary:sourceLine.boundary,separator:String(text).slice(sourceLine.end,sourceLine.nextStart)}:{};
2132
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;
2133
2319
  if(config.heading)context.headingTags.set(objectName,{v:1,group:config.path,field:config.heading,line:index,count:fit.lines.length,...boundary});
@@ -2146,6 +2332,7 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
2146
2332
  ...textBoxOptions(area, context, fit.fontSize * .75),
2147
2333
  ...nativeFontOptions(style),
2148
2334
  color: lineColor, align: alignment,
2335
+ ...(fit.directions?.[index]==='rtl'?{rtlMode:true}:{}),
2149
2336
  // Reuse the frame's existing relationship to cover blank caption boxes.
2150
2337
  // PptxGenJS also inherits it into runs; keep their native text styling.
2151
2338
  hyperlink: config.hyperlink,
@@ -2214,7 +2401,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
2214
2401
 
2215
2402
  const bulletImageName = () => 'OPF bullet image';
2216
2403
 
2217
- function addCodePayload(slide, value, layout, region, context, path) {
2404
+ function addCodePayload(slide, value, layout, region, context, path, options) {
2218
2405
  if (!layout) throw new OPFPptxError('missing-code-layout', 'Code export requires a coordinated core build with shared code geometry.', {path});
2219
2406
  for (const part of layout.parts) {
2220
2407
  // XML 1.0 Char excludes controls and unpaired UTF-16 surrogates. The u flag
@@ -2225,6 +2412,7 @@ function addCodePayload(slide, value, layout, region, context, path) {
2225
2412
  const group = String(context.codeTags.size + 1), panelName = `OPF code ${group} panel`;
2226
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});
2227
2414
  context.codeTags.set(panelName,codeManifest(value,layout,group));
2415
+ const syntax = codeSyntax(value, layout, context, path, options);
2228
2416
  slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', width: .75}, objectName:panelName});
2229
2417
  for (const [partIndex,part] of layout.parts.entries()) {
2230
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});
@@ -2232,19 +2420,37 @@ function addCodePayload(slide, value, layout, region, context, path) {
2232
2420
  const tabStops=line.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'}));
2233
2421
  const objectName = `OPF code ${group} ${part.role} line ${index+1}`;
2234
2422
  context.codeTags.set(objectName,{v:1,group,role:'line',part:partIndex,line:index});
2235
- 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 = {
2236
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),
2237
2428
  ...nativeFontOptions(part.style),
2238
2429
  color:part.role==='body'?'E5E7EB':'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
2239
2430
  tabStops:tabStops.length?tabStops:undefined,objectName,
2240
- });
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);
2241
2434
  }
2242
2435
  }
2243
2436
  }
2244
2437
 
2245
- 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) {
2246
2449
  if (!layout) throw new OPFPptxError('missing-metric-layout','Metric export requires coordinated core metric geometry.',{path});
2247
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;
2248
2454
  for (const [partIndex,part] of layout.parts.entries()) {
2249
2455
  const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
2250
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});
@@ -2262,11 +2468,17 @@ function addMetricPayload(slide,value,layout,context,path) {
2262
2468
  slide.addText(part.text.slice(line.start,line.end),{
2263
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),
2264
2470
  ...nativeFontOptions(part.style),
2265
- 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,
2266
2472
  tabStops:tabStops.length?tabStops:undefined,objectName,
2267
2473
  });
2268
2474
  }
2269
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
+ }
2270
2482
  }
2271
2483
 
2272
2484
  function addQuotePayload(slide, value, layout, context, options, path) {
@@ -2492,6 +2704,9 @@ function textBoxOptions(region, context, fontSize) {
2492
2704
  };
2493
2705
  }
2494
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
+
2495
2710
  function textRuns(value, context, fallbackFontSize) {
2496
2711
  const runs = Array.isArray(value) ? value : [value];
2497
2712
  return runs.map((run) => {
@@ -2978,13 +3193,14 @@ async function normalizePptxZip(raw, context) {
2978
3193
  }
2979
3194
 
2980
3195
  attachCodeTags(entries, context.codeTags);
2981
- attachMetricTags(entries,context.metricTags);
3196
+ attachMetricTags(entries,context.metricTags,context.metricDescriptions);
2982
3197
  attachCardTags(entries,context.cardTags);
2983
3198
  attachMediaTags(entries,context.mediaTags);
2984
3199
  attachHeadingTags(entries,context.headingTags);
2985
3200
  attachPlainTextTags(entries,context.plainTextTags);
2986
3201
  attachTimelineTags(entries,context.timelineTags);
2987
3202
  attachQuoteTags(entries,context.quoteTags);
3203
+ attachAnnotationTags(entries,context);
2988
3204
  attachFurnitureFields(entries,context.furnitureFields);
2989
3205
  attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests,context.furnitureLogoTags);
2990
3206
  // RR-11: the recorded footer shapes become PowerPoint's date, footer and slide-number placeholders.
@@ -3006,6 +3222,9 @@ async function normalizePptxZip(raw, context) {
3006
3222
  // Normalize only our generated chart text styles; point/series fills stay intact.
3007
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>`)));
3008
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}));
3009
3228
  }
3010
3229
  }
3011
3230
  applyChartFonts(entries,context.chartFonts,parseRelationships);
@@ -3302,6 +3521,9 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3302
3521
  if(!marker)return shape;
3303
3522
  if(marker.picture&&bulletRelationship)return shape.replace(/<a:buChar\b[^>]*\/>/g,`<a:buBlip><a:blip r:embed="${bulletRelationship}"/></a:buBlip>`);
3304
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}"`}/>`);
3305
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}"/>`);
3306
3528
  });
3307
3529
  // PptxGenJS gives shapes no alternative text. An unavailable image's panel
@@ -3533,7 +3755,7 @@ function numericValue(value) {
3533
3755
 
3534
3756
  function normalizeAuthor(author) {
3535
3757
  if (typeof author === "string") return author;
3536
- if (Array.isArray(author)) return author.join("; ");
3758
+ if (Array.isArray(author)) return joinAuthors(author);
3537
3759
  return null;
3538
3760
  }
3539
3761