@openpresentation/opf-pptx 0.20.3 → 0.22.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,4 +1,5 @@
1
1
  import {nativeBodyReader, joinNativeParagraphs} from './body-text-import.js';
2
+ import {addNativeBody, nativeBodyDefaults, nativeTitleAutofit, planNativeBody} from './native-body.js';
2
3
  import {autoNumScheme, deriveListNumbering, withDisplayedNumbers} from './numbered-list.js';
3
4
  import {isFaceStyleSuffix} from './font-weights.js';
4
5
  import {importTableFrames} from './table-import.js';
@@ -29,15 +30,15 @@ import {restoreRunColors} from './run-colors.js';
29
30
  import {joinWrappedText} from './content-topology.js';
30
31
  import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, recordContentTopology, recordListRuns, restoreDocumentProvenance, joinAuthors, splitAuthors, slideListRecord, storedMetadata, DEFAULT_AUTHOR, DEFAULT_TITLE} from './document-provenance.js';
31
32
  import {INVALID_XML_CHARACTER, nativeSections, writeSectionList} from './sections.js';
32
- import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
33
+ import {attachFurnitureFields, furniturePartFields, isBareSlideNumber, lineFields, nativeFieldType} from './furniture-fields.js';
33
34
  import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, nativePlaceholderForPart, writeNativeMasters} from './native-furniture.js';
34
35
  import {liftMasterFurniture} from './master-furniture.js';
35
36
  import {gallery} from '@openpresentation/gallery';
36
37
  import {applyTemplateLayouts, deckTemplates, foreignLayoutReference, placeholderRegion, readLayoutTag, slideTemplate, writeTemplateLayouts} from './template-layouts.js';
37
38
  import {importImageOrientation} from './image-import.js';
38
39
  import {extractSignals, normalizeSignalOptions, themeFactsFor} from './import-signals.js';
39
- import {placeImages, importImages, importImagePlaceholders, isImagePlaceholderGroup, imageName, imageOverlayName, imagePlaceholderGroupName, backgroundImageName, backgroundOverlayName, IMAGE_TREATMENT_KEYS, BACKGROUND_IMAGE_KEYS, IMAGE_PLACEHOLDER_TAG} from './image-provenance.js';
40
- import {dedupeMedia} from './media-dedupe.js';
40
+ import {recordableImageSource, placeImages, importImages, importImagePlaceholders, isImagePlaceholderGroup, samePictureAspect, imageName, imageOverlayName, imagePlaceholderGroupName, backgroundImageName, backgroundOverlayName, IMAGE_TREATMENT_KEYS, BACKGROUND_IMAGE_KEYS, IMAGE_PLACEHOLDER_TAG} from './image-provenance.js';
41
+ import {dedupeMedia, mediaKey} from './media-dedupe.js';
41
42
  import {placeWatermarks, importWatermark, importTextWatermark, tagTextWatermarks, watermarkName, watermarkTextName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
42
43
  import {placeLogos, importLogo, importLogoPlaceholders, logoName, LOGO_TAG} from './logo-provenance.js';
43
44
  import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
@@ -48,11 +49,12 @@ import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFil
48
49
  import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, planSlideThemes, reconcileLanguage, reportPerSlideNotesScriptFonts, runLanguageFonts, runLanguageTag, stripRunScriptFonts, themeEastAsianFromLatin} from './script-fonts.js';
49
50
  import { webpToPng, svgToPng, readLocalFile, localFileReadable } from '#image-fallback';
50
51
  import { prepareSvg, svgDataUriBytes, svgRasterScale, svgBlipRelationship, attachSvgPictures } from './svg-image.js';
52
+ import { chartPaletteSchemes, applyChartSchemeColors } from './chart-scheme-colors.js';
51
53
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
52
54
  import {
53
- catalogRecords, chartPaletteForFill, codeHighlightBands, codeHighlightColors, codeHighlightLines, codeLineNumbers, codeLineRuns, codeSyntaxPaletteForScheme, composeSlide,
55
+ catalogRecords, chartPaletteForFill, chartSeriesPalette, codeHighlightBands, codeHighlightColors, codeHighlightLines, codeLineNumbers, codeLineRuns, codeSyntaxPaletteForScheme, composeSlide,
54
56
  ENGINE_DEFAULT_FONT_SCHEME, defaultSlideBackground, fitImage, fitText, layoutTable, layoutWatermark, metricTrendMark, resolveCanvasDimensions, resolveColorRoles, resolveFontFamilies,
55
- resolveReference, resolveScriptFonts, resolveTextStyle, textColorForFill, textWidthMeasurer, timelineMarkerShapes, timelineTextColor, tokenizeCode
57
+ resolveChartColors, resolveReference, resolveScriptFonts, resolveTextStyle, textColorForFill, textWidthMeasurer, timelineMarkerShapes, timelineTextColor, tokenizeCode
56
58
  } from "@openpresentation/opf/composition";
57
59
  import { colorContext, resolveColorRefValue, resolveExportColor, resolveVariableColors } from "./color-ref.js";
58
60
  import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
@@ -68,6 +70,9 @@ import {
68
70
  validate
69
71
  } from "@openpresentation/opf";
70
72
  import {checkFormat, isValidFormat} from "./format-check.js";
73
+ import {OPFPptxError} from './errors.js';
74
+ import {readInputZip} from './input-safety.js';
75
+ export {OPFPptxError} from './errors.js';
71
76
 
72
77
  export {checkTypefaces, inventoryTypefaces, packageFontsUsed, THEME_SCRIPT_SUPPLEMENTS} from './typeface-inventory.js';
73
78
  export {DEFAULT_SIGNAL_LIMITS, SIGNALS_VERSION, isMonospaceFamily} from './import-signals.js';
@@ -91,18 +96,6 @@ export const runtimePolicy = Object.freeze({
91
96
  deterministicLocalExecution: true
92
97
  });
93
98
 
94
- export class OPFPptxError extends Error {
95
- constructor(code, message, details = {}) {
96
- super(message);
97
- this.name = "OPFPptxError";
98
- this.code = code;
99
- this.details = details;
100
- if (details.findings) this.findings = details.findings;
101
- if (details.diagnostics) this.diagnostics = details.diagnostics;
102
- if (details.path) this.path = details.path;
103
- }
104
- }
105
-
106
99
  const FIXED_TIMESTAMP = "1980-01-01T00:00:00Z";
107
100
  // fflate derives the DOS zip date from local-time fields, so build this from
108
101
  // local components: a UTC instant rolls back to 1979 west of UTC (below
@@ -208,21 +201,6 @@ const DIMENSION_PRESETS = Object.freeze({
208
201
  a4: Object.freeze({ widthInches: 11.69, heightInches: 8.27 })
209
202
  });
210
203
 
211
- const CHART_COLORS = [
212
- "2874A6",
213
- "1B4F72",
214
- "5499C7",
215
- "7BDBB2",
216
- "3AC67A",
217
- "24A89E",
218
- "F59E0B",
219
- "EF4444",
220
- "8B5CF6",
221
- "14B8A6",
222
- "0F172A",
223
- "64748B"
224
- ];
225
-
226
204
  // The `catalogs` option is core's registered catalogs (`Catalog[]`). A value that is no array is core's
227
205
  // OPFCatalogsOptionError (code `invalid-catalogs`) up front; core checks each entry where it resolves a reference.
228
206
  function checkCatalogs(entry, catalogs) {
@@ -230,12 +208,21 @@ function checkCatalogs(entry, catalogs) {
230
208
  }
231
209
 
232
210
  export async function toPptx(input, options = {}) {
211
+ if (options.bodyText !== undefined && !['measured', 'native'].includes(options.bodyText)) {
212
+ throw new OPFPptxError('invalid-body-text-mode', 'bodyText must be measured or native.', {path: 'options.bodyText'});
213
+ }
233
214
  if (options.imageFormat !== undefined && !['compatible', 'preserve'].includes(options.imageFormat)) {
234
215
  throw new OPFPptxError('invalid-image-format', 'imageFormat must be compatible or preserve.', {path: 'options.imageFormat'});
235
216
  }
217
+ if (options.slideNumberFields !== undefined && !['static', 'live'].includes(options.slideNumberFields)) {
218
+ throw new OPFPptxError('invalid-slide-number-option', "slideNumberFields must be 'static' or 'live'.", {path: 'options.slideNumberFields'});
219
+ }
236
220
  if (options.provenance !== undefined && !['full', 'references-only', false].includes(options.provenance)) {
237
221
  throw new OPFPptxError('invalid-provenance-option', "provenance must be 'full', 'references-only' or false.", {path: 'options.provenance'});
238
222
  }
223
+ if (options.footerText !== undefined && !['native', 'shape'].includes(options.footerText)) {
224
+ throw new OPFPptxError('invalid-footer-text-option', "footerText must be 'native' or 'shape'.", {path: 'options.footerText'});
225
+ }
239
226
  // OPF 0.15 (FA-23): the host's registered catalogs (core `Catalog[]`, matched by `source`, never fetched) reach every core
240
227
  // call that resolves a reference. Strict export (`strictReferences`, default `strictAssets`) fails on a reference that
241
228
  // resolves nowhere instead of composing automatically or drawing with the engine default.
@@ -281,6 +268,8 @@ export async function toPptx(input, options = {}) {
281
268
  context.provenanceMode = options.provenance ?? "full";
282
269
  context.headingTags = new Map();
283
270
  context.plainTextTags = new Map();
271
+ context.nativeBodies = new Map();
272
+ context.nativeTitles = new Set();
284
273
  context.timelineTags = new Map();
285
274
  context.quoteTags = new Map();
286
275
  context.quotePhotos = new Map();
@@ -290,10 +279,12 @@ export async function toPptx(input, options = {}) {
290
279
  context.furnitureManifests = new Map();
291
280
  context.nativeFurniture = new Map();
292
281
  context.nativeFurnitureBands = new Map();
282
+ context.footerText = options.footerText ?? 'native';
293
283
  // RR-72: each slide's furniture parts and the object names of their shapes, for the slide master and layouts.
294
284
  context.furnitureParts = [];
295
285
  // RR-81: the 0.19 template each slide uses (its native slide layout) and the objects bound to a region placeholder.
296
286
  context.templateSlides = [];
287
+ context.staticSlideNumbers = options.slideNumberFields !== 'live';
297
288
  context.automaticSlides = [];
298
289
  context.placeholderBindings = new Map();
299
290
  context.hostDate = options.date;
@@ -472,7 +463,7 @@ export async function fromPptx(input, options = {}) {
472
463
  return match ? shapeBounds(match['p:spPr']?.['a:xfrm']) ?? undefined : undefined;
473
464
  } : undefined,
474
465
  // RR-11: native footer placeholders resolve their position through the layout and master.
475
- path: slidePath, slideWidth: Number(presentationRoot['p:sldSz']?.cx), relationshipsOf: cachedRelationships, readPart: cachedPart,
466
+ path: slidePath, slideWidth: Number(presentationRoot['p:sldSz']?.cx), slideHeight: Number(presentationRoot['p:sldSz']?.cy), defaultTextStyle: presentationRoot['p:defaultTextStyle'], relationshipsOf: cachedRelationships, readPart: cachedPart,
476
467
  paragraphs: nativeShapeParagraphs(decodeText(entries[slidePath])),
477
468
  readPicture: picture => importPicture(entries, picture, slidePath, relationships,
478
469
  diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${index}.design`})),
@@ -642,8 +633,9 @@ function readPptxZip(input) {
642
633
  }
643
634
 
644
635
  try {
645
- return unzipSync(bytes);
636
+ return readInputZip(bytes);
646
637
  } catch (error) {
638
+ if (error instanceof OPFPptxError) throw error;
647
639
  throw new OPFPptxError("invalid-pptx", "PPTX input is not a readable ZIP archive.", {
648
640
  cause: errorMessage(error)
649
641
  });
@@ -809,6 +801,7 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
809
801
  // The recorded crop, fit and focus are re-derived from the restored background.
810
802
  if (diagnostic.code !== 'unsupported-image-crop') options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.design.background`});
811
803
  }),
804
+ mediaKeyOf: picture => { const bytes = entries[relationships.get(picture?.['p:blipFill']?.['a:blip']?.['r:embed'])?.path]; return bytes ? mediaKey(bytes) : undefined; },
812
805
  valid: (kind, value) => isValidFormat({slides: [kind === 'background' ? {design: {background: value}} : {blocks: [value]}]}),
813
806
  report: diagnostic => options.onDiagnostic?.(diagnostic)});
814
807
  const imageBackground = images.background ?? (background?.type === 'image' && images.backgroundOverlay ? {...background, overlay: images.backgroundOverlay} : background);
@@ -819,6 +812,7 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
819
812
  if (imageBackground) slide.design = {background: imageBackground};
820
813
  if (Object.keys(furniture.design).length) slide.design = {...slide.design, ...furniture.design};
821
814
  nativeContext.imageBlocks = images.blocks;
815
+ nativeContext.imageSources = images.sources;
822
816
  nativeContext.imagePictures = images.consumed;
823
817
  // An overlay of a background that was not recovered stays an ordinary shape.
824
818
  nativeContext.imageShapes = images.consumedShapes;
@@ -875,8 +869,10 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
875
869
  for (const item of items) if (item.readNativeBody) {
876
870
  const current = item.readNativeBody();
877
871
  if (current) {
878
- item.paragraphs = current.map((paragraph, index) => ({...item.paragraphs[index], ...paragraph}));
872
+ item.paragraphs = withDisplayedNumbers(current.map((paragraph, index) => ({...item.paragraphs[index], ...paragraph})));
879
873
  item.text = current.map(paragraph => paragraph.text).join('\n');
874
+ if (item.paragraphs.some(paragraph => paragraph.bullet) && item.paragraphs.some(paragraph => !paragraph.bullet))
875
+ options.onDiagnostic?.({code: 'mixed-body-paragraphs', path: `slides.${slideIndex}`, message: 'A native body mixes bulleted and plain paragraphs; current text is retained as list entries because OPF has no mixed-paragraph body model.'});
880
876
  }
881
877
  }
882
878
  const listRecord = slideListRecord(entries, slidePath, slideRoot, relationships);
@@ -1048,7 +1044,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
1048
1044
  const treatment = nativeContext.imageBlocks?.get(index);
1049
1045
  const report = diagnostic => { if (!(treatment && diagnostic.code === 'unsupported-image-crop')) options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`}); };
1050
1046
  const item = importPicture(entries, picture, slidePath, relationships, report);
1051
- if (item?.payload && treatment) item.payload = {...item.payload, ...structuredClone(treatment)};
1047
+ if (item?.payload && treatment) item.payload = {...item.payload, ...structuredClone(treatment), ...(nativeContext.imageSources?.has(index) ? {image: {...item.payload.image, src: nativeContext.imageSources.get(index)}} : {})};
1052
1048
  else if (item?.nativeStretch) { item.payload.fit='stretch'; item.inferredStretch=true; }
1053
1049
  else if (item?.nativeFitUnsupported) report({code:'unsupported-image-fit',message:'Native picture tiling or an offset stretch fill is not represented by the imported image block; review the reflowed image.'});
1054
1050
  if (item) items.push({...item, sources: [`pic:${index}`]});
@@ -1232,13 +1228,12 @@ function nativePictureStretch(picture,bytes,bounds,svg) {
1232
1228
  if(!bounds||!(bounds.w>0&&bounds.h>0)||!metadata||!(metadata.width>0&&metadata.height>0)||metadata.orientation>1) return false;
1233
1229
  if(stretch===undefined||fill?.['a:tile']!==undefined||Number(transform?.rot??0)!==0) return false;
1234
1230
  if([crop,rect].some(value=>value&&['l','r','t','b'].some(key=>Number(value[key]??0)!==0)))return false;
1235
- // Compare dimensions in native EMUs, without a new geometry tolerance.
1231
+ // Compare the current raster and current native frame, including only the
1232
+ // exporter's representable rounding cells (not a visual geometry tolerance).
1236
1233
  const ext=transform?.['a:ext'];
1237
1234
  const cx=Number(ext?.cx),cy=Number(ext?.cy);
1238
1235
  if(!Number.isFinite(cx)||!Number.isFinite(cy))return false;
1239
- // Equal-aspect dimensions can round by one EMU when serialized. A dimension
1240
- // reproduced by rounding the intrinsic ratio is still the same aspect.
1241
- return Math.round(cx*metadata.height/metadata.width)!==cy&&Math.round(cy*metadata.width/metadata.height)!==cx;
1236
+ return !samePictureAspect(cx,cy,metadata.width,metadata.height);
1242
1237
  }
1243
1238
 
1244
1239
  // Provenance may rebuild flat blocks into groups or scalar fields. Remove only
@@ -1329,7 +1324,7 @@ function takeTitleItem(items, dimensions, inferHeadings) {
1329
1324
 
1330
1325
  const titleLimit = dimensions.heightInches * 0.28;
1331
1326
  const candidateIndex = items.findIndex((item) => {
1332
- if (item.heading || item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
1327
+ if (item.heading || item.placeholder === 'body' || item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
1333
1328
  const y = item.bounds?.y ?? 0;
1334
1329
  return y <= titleLimit && (item.maxFontSize >= 20 || /^title\b/i.test(item.name ?? ""));
1335
1330
  });
@@ -1345,7 +1340,7 @@ function takeSubtitleItem(items, titleItem, dimensions, inferHeadings) {
1345
1340
  const titleBottom = (titleItem.bounds?.y ?? 0) + (titleItem.bounds?.h ?? 0);
1346
1341
  const subtitleLimit = Math.min(dimensions.heightInches * 0.34, 1.45);
1347
1342
  const candidateIndex = items.findIndex((item) => {
1348
- if (item.heading || item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
1343
+ if (item.heading || item.placeholder === 'body' || item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
1349
1344
  const y = item.bounds?.y ?? 0;
1350
1345
  const h = item.bounds?.h ?? 0;
1351
1346
  return y >= titleBottom - 0.05
@@ -1529,6 +1524,15 @@ function chartFromRelationship(entries, slidePath, relationships, relId, report)
1529
1524
  if (chartNode.type === 'scatter') return withOptions(scatterFromSeries(entries, relationship.path, series, budget, cachePath, {codes: valueCodes, xCode, report}));
1530
1525
  const labels = cachedValues(series[0]?.["c:cat"], cachePath(0, 'c:cat'), budget);
1531
1526
  const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, 'c:tx'), budget) ?? `Series ${index + 1}`);
1527
+ // RR-86: explicit current line paint has no per-series OPF field. Report the native locator without
1528
+ // inferring semantic highlight, rewriting the theme or claiming that these colors differ visually.
1529
+ for (const [index, entry] of series.entries()) {
1530
+ const line = comboSeries ? comboSeries[index].role === 'line' : asArray(plotArea['c:lineChart']).includes(chartNode.node);
1531
+ const solid = line && entry?.['c:spPr']?.['a:ln']?.['a:solidFill'];
1532
+ if (solid && ['a:srgbClr', 'a:schemeClr', 'a:sysClr', 'a:prstClr', 'a:scrgbClr', 'a:hslClr'].some(color => Object.hasOwn(solid, color))) {
1533
+ report?.({option: `series.${index}.line.color`, message: `Line series '${names[index]}' has explicit native solid paint that is not retained as per-series formatting in OPF; imported chart colors follow its design and semantic highlight.`});
1534
+ }
1535
+ }
1532
1536
  const values = series.map((entry, index) => {
1533
1537
  const role = entry?.["c:val"] !== undefined ? 'c:val' : 'c:yVal';
1534
1538
  return cachedValues(entry?.[role], cachePath(index, role), budget, strictCacheValue(budget));
@@ -2136,9 +2140,12 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
2136
2140
  for (const item of geometry.items) if (item.listColumns?.length > 1 && item.text?.listEntries) item.text = {...item.text, listEntries: item.listColumns.flatMap(column => column.text?.listEntries ?? [])};
2137
2141
  // RR-81: a slide on a 0.19 template uses that template's native slide layout (src/template-layouts.js), whose placeholders
2138
2142
  // core composes with this slide's options (canvas, the deck's header and footer, direction) and its placed-image bands.
2139
- const template = slideTemplate(slideContext.core);
2143
+ let template = slideTemplate(slideContext.core);
2144
+ const automatic = opfSlide.layout === undefined || opfSlide.layout === 'auto';
2145
+ const nativeBody = options.bodyText === 'native' ? planNativeBody(opfSlide, geometry, {automatic, report: options.onDiagnostic}) : undefined;
2146
+ if (template && nativeBody) template = {...template, key: `${template.key}\u0000native-body:${nativeBody.region}`, nativeBodyRegion: nativeBody.region};
2140
2147
  if (template) context.templateSlides[slideIndex] = {template, areaOptions: {...composeOptions, placements: placedImageBands(opfSlide)}};
2141
- else if (opfSlide.layout === undefined || opfSlide.layout === 'auto') context.automaticSlides[slideIndex] = {areaOptions: composeOptions, title: geometry.items.find(item => item.field === 'title')};
2148
+ else if (automatic) context.automaticSlides[slideIndex] = {areaOptions: composeOptions, title: geometry.items.find(item => item.field === 'title'), ...(nativeBody ? {nativeBody} : {})};
2142
2149
  // The default footer band (date left, text center, number right): where a footer added natively lands (RR-11).
2143
2150
  if (slideIndex === 0) context.defaultFooterOptions = composeOptions;
2144
2151
  slideContext.zoneBand = zoneBands(composeOptions);
@@ -2159,7 +2166,8 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
2159
2166
 
2160
2167
  for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
2161
2168
  // The content topology (groups, regions, root form, block ids) with the leaf boxes this geometry draws.
2162
- recordContentTopology(context.documentProvenance, opfSlide, slideIndex, geometry.items);
2169
+ const nativePlans = nativeBody ? [nativeBody, ...nativeBody.others] : [];
2170
+ recordContentTopology(context.documentProvenance, opfSlide, slideIndex, nativeBody ? geometry.items.map(item => { const plan = nativePlans.find(plan => plan.item === item); return plan ? {...item, box: plan.box, text: undefined} : item; }) : geometry.items);
2163
2171
  // Paint order, as in the preview (FA-22 contract): canvas colour, the background picture and its overlay, design.watermark,
2164
2172
  // the logo, then the items in core's order: the headings, the placed image blocks at their bands, then the flowed body.
2165
2173
  if (geometry.backgroundImage) await addBackgroundImage(slide, presentation, geometry.backgroundImage, slideIndex, slideContext, context, options);
@@ -2176,7 +2184,10 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
2176
2184
  // object or one drawn on a card stays a free shape: Change Layout would move it away from its caption or card.
2177
2185
  const boundRegion = template && item.region !== undefined && ['image', 'chart', 'table'].includes(item.field) && !item.caption && !item.frameBox
2178
2186
  ? geometry.regions?.find(region => region.name === item.region && region.flow === 'none' && region.content?.length === 1 && !region.overflow?.length) : undefined;
2179
- if ((template || context.automaticSlides[slideIndex]) && item.field === 'title' && typeof item.value === 'string' && item.text?.lines?.length === 1 && !item.text.overflow && !item.text.richLines)
2187
+ // RR-88: under bodyText 'native' a plain-string title that wraps is one native text box (native wrap, no autofit) at its measured
2188
+ // block, so it can bind to the Title placeholder like a one-line title (Keynote reports `title showing` only for a bound title).
2189
+ const nativeTitle = options.bodyText === 'native' && (template || context.automaticSlides[slideIndex]) && isNativeTitle(item);
2190
+ if ((template || context.automaticSlides[slideIndex]) && item.field === 'title' && typeof item.value === 'string' && (item.text?.lines?.length === 1 || nativeTitle) && !item.text.overflow && !item.text.richLines)
2180
2191
  context.placeholderBindings.set(`OPF heading ${item.path} line 0`, {slide: slideIndex, region: 'Title', object: 'title'});
2181
2192
  const drawnBefore = boundRegion && {images: context.images.size, placeholders: context.imagePlaceholders.size, charts: context.chartHeadings.size, tables: context.tableHeaders.size};
2182
2193
  // Card text sits on a literal card fill, not the slide background: keep it literal.
@@ -2191,7 +2202,15 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
2191
2202
  rectRadius:8*Math.min(widthInches,heightInches)/720,
2192
2203
  fill:paint(surface),line:{...paint(`#${itemContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
2193
2204
  }
2194
- if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
2205
+ if (nativeTitle) {
2206
+ addNativeTitle(slide, item, itemContext, options, context);
2207
+ } else if (nativePlans.some(plan => plan.item === item)) {
2208
+ // RR-88: the first native body is the Body placeholder; every further one is an ordinary native text box at its region.
2209
+ const plan = nativePlans.find(plan => plan.item === item);
2210
+ const body = addNativeBody(slide, plan, itemContext, options, {textBoxOptions, nativeFontOptions, exportColor, nativeColor, linkColor, langOptions, solidColorXml});
2211
+ context.nativeBodies.set(body.objectName, body.defaults);
2212
+ if (plan === nativeBody) context.placeholderBindings.set(body.objectName, {slide: slideIndex, region: nativeBody.region, object: 'body'});
2213
+ } else if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
2195
2214
  addMeasuredPayloadText(slide,item.value,item.box,itemContext,options,{path:item.path,fit:item.text,textStyle:item.textStyle,sourceText:item.field==='text'&&!!item.text.sourceLines,align:item.alignment,diagnosticsHandled:true,heading:['title','subtitle','tag'].includes(item.field)?item.field:undefined,color:item.field==='tag'?tagColor(itemContext):itemContext.textColor});
2196
2215
  } else if (["title", "subtitle", "tag"].includes(item.field) && !item.text?.richLines) {
2197
2216
  // Estimated-font headings use one native text box, which still needs a
@@ -2215,7 +2234,7 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
2215
2234
  // FA-10: a TextRun[] heading draws and exports like rich body text; its lines are named and tagged as a heading (the body's are not).
2216
2235
  const heading = item.field !== 'text';
2217
2236
  addRichFitLines(slide, item.text, item.box, itemContext, {
2218
- value: item.value, alignment: item.text.placement?.alignment ?? item.alignment ?? 'left',
2237
+ value: item.value, path: item.path, alignment: item.text.placement?.alignment ?? item.alignment ?? 'left',
2219
2238
  fallback: item.field === 'tag' ? tagColor(itemContext) : itemContext.textColor,
2220
2239
  ...(heading ? {
2221
2240
  objectName: index => `OPF heading ${item.path} line ${index}`,
@@ -2229,7 +2248,7 @@ async function addSlide(pptx, presentation, authoredSlide, slideIndex, context,
2229
2248
  } else {
2230
2249
  // 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.
2231
2250
  const mediaNames = item.caption ? new Set([...context.images.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()]) : undefined;
2232
- await addPayload(slide, presentation, item.payload, region, item.path, { ...itemContext, composition: item.composition, contentAlignment: item.alignment ?? "left", tableAlignment: geometry.design.contentAlignment, direction: geometry.direction }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
2251
+ await addPayload(slide, presentation, item.payload, region, item.path, { ...itemContext, composition: item.composition, contentAlignment: item.alignment ?? "left", tableAlignment: geometry.design.contentAlignment, direction: geometry.direction }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout, item.chart);
2233
2252
  if (item.caption) {
2234
2253
  const mediaName = item.field === 'video' ? `OPF media ${item.path} frame` : [...context.images.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()].find(name => !mediaNames.has(name));
2235
2254
  addCaption(slide, item, mediaName, itemContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: item.caption.path}));
@@ -2297,11 +2316,11 @@ function fieldToType(field) {
2297
2316
  return field === "items" || field === "bullets" ? "list" : field;
2298
2317
  }
2299
2318
 
2300
- async function addPayload(slide, presentation, payload, region, path, context, options, quoteLayout, codeLayout,metricLayout,timelineLayout) {
2319
+ async function addPayload(slide, presentation, payload, region, path, context, options, quoteLayout, codeLayout,metricLayout,timelineLayout,chartLayout) {
2301
2320
  const kind = inferPayloadKind(payload);
2302
2321
  switch (kind) {
2303
2322
  case "text":
2304
- addTextPayload(slide, payload.text ?? payload.bullets, region, context);
2323
+ addTextPayload(slide, payload.text ?? payload.bullets, region, context, path);
2305
2324
  break;
2306
2325
  case "list":
2307
2326
  addListPayload(slide, payload.items ?? payload.bullets, region, context);
@@ -2313,7 +2332,7 @@ async function addPayload(slide, presentation, payload, region, path, context, o
2313
2332
  addMediaPayload(slide, presentation, payload.video, region, path, context, options);
2314
2333
  break;
2315
2334
  case "chart":
2316
- addChartPayload(slide, payload.chart, region, context, options, path, presentation);
2335
+ addChartPayload(slide, payload.chart, region, context, options, path, presentation, chartLayout);
2317
2336
  break;
2318
2337
  case "table":
2319
2338
  addTablePayload(slide, payload.table, region, context, options, path, presentation);
@@ -2346,9 +2365,9 @@ function inferPayloadKind(payload) {
2346
2365
  return "unknown";
2347
2366
  }
2348
2367
 
2349
- function addTextPayload(slide, value, region, context) {
2368
+ function addTextPayload(slide, value, region, context, path) {
2350
2369
  if (Array.isArray(value)) {
2351
- slide.addText(textRuns(value, context, 18), textBoxOptions(region, context, 18));
2370
+ slide.addText(textRuns(value, context, 18, path), textBoxOptions(region, context, 18));
2352
2371
  return;
2353
2372
  }
2354
2373
  if (Array.isArray(value?.bullets)) {
@@ -2366,9 +2385,10 @@ function listRunLook(fit,style,context){
2366
2385
  if(text.size!==1||description.size>1)return undefined;
2367
2386
  return {text:JSON.parse([...text][0]),...(description.size?{description:JSON.parse([...description][0])}:{})};
2368
2387
  }
2369
- function richLineRuns(line,color,native,context) {
2388
+ function richLineRuns(line,color,native,context,path) {
2370
2389
  const fallback=color.replace(/^#/,'');
2371
2390
  return line.fragments.map(fragment=>{
2391
+ assertXmlText(fragment.run.text ?? fragment.text, path);
2372
2392
  const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context,native);
2373
2393
  return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:(fragment.nominalSize??fragment.fontSize)*.75,color:linkColor(fragment.run,color,context),underline:fragment.run.underline?{style:'sng',color:linkColor(fragment.run,color,context)}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/(fragment.nominalSize??fragment.fontSize)*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:|tel:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined,...langOptions(context,fragment.style.lang)}};
2374
2394
  });
@@ -2416,7 +2436,7 @@ async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,s
2416
2436
  const number=first?entry.marker.number:undefined;
2417
2437
  if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:pptxColor(context.textColor),picture:number?false:picture});
2418
2438
  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};
2419
- const runs=richLineRuns(line,color,native,context);
2439
+ const runs=richLineRuns(line,color,native,context,path);
2420
2440
  if(!runs.length)runs.push({text:'',options:{}});
2421
2441
  // Keep paragraph intent identical across runs. ZIP normalization below
2422
2442
  // removes the duplicate paragraph-property nodes emitted by PptxGenJS.
@@ -2479,7 +2499,7 @@ async function addImagePayload(slide, presentation, asset, region, path, context
2479
2499
  // One native image picture at `box` (inches), fitted at packaging (src/image-provenance.js placeImages). `treatment` is
2480
2500
  // what OPF_IMAGE_V1 stores (the authored block fields); without it the picture is untagged. Returns the picture's object
2481
2501
  // name, or undefined when the source could not be embedded (the caller draws the unavailable-image panel).
2482
- async function addImagePicture(slide, presentation, asset, box, path, context, options, {fit, focus, effects = null, shrink = true, treatment, alt, role = 'block', objectName} = {}) {
2502
+ async function addImagePicture(slide, presentation, asset, box, path, context, options, {fit, focus, effects = null, shrink = true, treatment, alt, role = 'block', objectName, src} = {}) {
2483
2503
  const outcome = { box, cover: fit === 'cover' };
2484
2504
  const resolved = await resolveImage(asset, presentation, options, path, outcome);
2485
2505
  if (!resolved) {
@@ -2497,7 +2517,8 @@ async function addImagePicture(slide, presentation, asset, box, path, context, o
2497
2517
  else context.svgPictures.set(name, outcome.svg);
2498
2518
  }
2499
2519
  const text = alt !== undefined ? alt : assetAlt(asset, presentation);
2500
- context.images.set(name, { slide: context.slidePath, role, path, box, fit, focus, shrink, effects, ...(treatment !== undefined ? {treatment} : {}) });
2520
+ // RR-94: the authored relative path or URL is recorded with the picture's media key; an SVG imports from its own part, so it is not.
2521
+ context.images.set(name, { slide: context.slidePath, role, path, box, fit, focus, shrink, effects, ...(treatment !== undefined ? {treatment} : {}), ...(src !== undefined && !outcome.svg ? {src} : {}) });
2501
2522
  context.pictureText.set(name, pictureText(text, asset, presentation));
2502
2523
  slide.addImage({ ...resolved, objectName: name, x: box.x, y: box.y, w: box.w, h: box.h, altText: text });
2503
2524
  return name;
@@ -2545,11 +2566,13 @@ async function addImageItem(slide, presentation, item, slideIndex, context, opti
2545
2566
  const treatment = Object.fromEntries(IMAGE_TREATMENT_KEYS.filter(key => key !== 'overlay' && block[key] !== undefined).map(key => [key, structuredClone(block[key])]));
2546
2567
  const effects = imageEffects(image, context);
2547
2568
  const treated = Boolean(effects.border || effects.opacity !== null || effects.recolor || image.overlay || (effects.shape && effects.shape.preset !== 'rect'));
2548
- const tagged = Object.keys(treatment).length > 0 || block.overlay !== undefined;
2569
+ // RR-94: every image block is tagged (an empty treatment when it has none), so an unchanged picture re-imports without a crop
2570
+ // diagnostic, and one authored from a relative path or URL records that source to give it back.
2571
+ const source = recordableImageSource(item.value);
2549
2572
  // A plain contained picture in the flow is framed by the picture itself (0.14 content images); a treated or placed one
2550
2573
  // keeps core's frame (0.14 slide images), so its mask, line and overlay apply to that frame.
2551
2574
  const name = await addImagePicture(slide, presentation, item.value, box, item.path, { ...context, slidePath: `slides.${slideIndex}` }, options, {
2552
- fit: image.fit, focus: image.focus, effects: treated ? effects : null, shrink: !treated && !image.placement, treatment: tagged ? treatment : undefined
2575
+ fit: image.fit, focus: image.focus, effects: treated ? effects : null, shrink: !treated && !image.placement, treatment, src: source
2553
2576
  });
2554
2577
  if (!name) {
2555
2578
  const panel = addImagePlaceholder(slide, presentation, item.value, box, item.path, context, options);
@@ -2692,7 +2715,7 @@ function applyChartTextSize(xml, size) {
2692
2715
  `<a:defRPr${/\bsz="[^"]*"/.test(attributes) ? attributes.replace(/\bsz="[^"]*"/, `sz="${size}"`) : `${attributes} sz="${size}"`}${close}>`));
2693
2716
  }
2694
2717
 
2695
- function addChartPayload(slide, chart, region, context, options = {}, path = "chart", presentation) {
2718
+ function addChartPayload(slide, chart, region, context, options = {}, path = "chart", presentation, chartLayout) {
2696
2719
  const chartData = toPptxChartData(chart, context.chartexMode, presentation, path);
2697
2720
  if (!chartData.series) {
2698
2721
  // Never lose a chart silently: the placeholder frame stands in for it, and a diagnostic names the reason.
@@ -2732,8 +2755,15 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2732
2755
  context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
2733
2756
  // FA-09: the chart's text alternative (the frame's descr; the empty string is PowerPoint's decorative marker), applied before the chartex frame copies the head.
2734
2757
  if (typeof chart.alt === 'string') context.frameAlts.set(objectName, chart.alt);
2735
- const preferredPalette = CHART_COLORS.map(color=>`#${color}`);
2736
- const palette = chartPaletteForFill(panelFill, preferredPalette).map(color=>normalizeHex(color));
2758
+ // RR-89: the series colours are core's chartSeriesPalette (accent1 to accent6 of the deck scheme, then their tints, with the chart's own
2759
+ // `colors` over them), adjusted for the chart panel like the preview does. A colour that is still an unadjusted theme accent (or a
2760
+ // `chart.colors` scheme slot or role) is written as a schemeClr, so re-theming in PowerPoint recolours the series.
2761
+ const colorContext = {roles: context.roles, variables: context.variables};
2762
+ const preferredPalette = chartSeriesPalette(context.colorScheme, chart.colors, colorContext);
2763
+ const adjustedPalette = chartPaletteForFill(panelFill, preferredPalette);
2764
+ const palette = adjustedPalette.map(color=>normalizeHex(color));
2765
+ const paletteSchemes = chartPaletteSchemes(chart.colors, preferredPalette, adjustedPalette, (reference, hex) => schemeColorValue(reference, `#${hex}`, context));
2766
+ Object.assign(context.chartHeadings.get(objectName), {palette, paletteSchemes, circular});
2737
2767
  // FA-14: chart.highlight. A highlighted chart writes accent and muted colours over the series palette, and its data labels
2738
2768
  // contrast with those colours (src/chart-highlight.js).
2739
2769
  const highlight = chartData.chartex ? undefined : chartHighlightPlan({options: chartOptions, data: chartData.resolved, kind: chartTargetFor(chart.type)?.kind, panelFill, labelColor, primary: `#${context.colors.accent}`,
@@ -2786,6 +2816,9 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2786
2816
  ...(chartData.series.every(entry => entry.values.every(value => value == null || value >= 0)) ? {valAxisMinVal: 0} : {}),
2787
2817
  ...(chartData.series.some(entry => entry.values.some(value => value < 0)) && chartData.series.every(entry => entry.values.every(value => value == null || value <= 0)) ? {valAxisMaxVal: 0} : {})
2788
2818
  } : {}),
2819
+ // RR-92: the value axis core composed (column, bar, line and area charts), as the preview draws it: PowerPoint and Keynote
2820
+ // otherwise choose their own bounds and major unit. A 100% stacked chart's axis is a fraction.
2821
+ ...(chartLayout?.valueAxis ? {valAxisMinVal: chartLayout.valueAxis.min, valAxisMaxVal: chartLayout.valueAxis.max, valAxisMajorUnit: chartLayout.valueAxis.majorUnit} : {}),
2789
2822
  barDir: chartData.barDir,
2790
2823
  barGrouping: chartData.barGrouping,
2791
2824
  // Right to left (RR-05): column, line and area charts reverse their categories (c:catAx orientation maxMin), which also moves the
@@ -2876,6 +2909,7 @@ function addTablePayload(slide, authoredTable, region, context, options, path, p
2876
2909
  const defaultText = cellStyle.color === undefined ? tableTextSchemeValue(fillValue, baseColor, context) : undefined;
2877
2910
  const alpha = value => value.length === 8 ? (1 - parseInt(value.slice(6), 16) / 255) * 100 : 0;
2878
2911
  const text = stringifyText(cell.value), style = cell.textStyle;
2912
+ assertXmlText(text, cell.path ?? path);
2879
2913
  const fragments = rich ? fit.richLines.flatMap(line => line.fragments) : [];
2880
2914
  const runs = rich ? cell.value.flatMap((value, index) => {
2881
2915
  const run = typeof value === 'string' ? {text:value} : value;
@@ -2955,10 +2989,11 @@ function pixelBox(region) {
2955
2989
  // super/subscript (a citation marker is a baseline run) and http(s)/mailto links. The body's lines stay untagged; a heading or quote
2956
2990
  // body passes `objectName` and `tag`, which name and tag every line with the boundary between wrapped lines (a soft wrap inserts
2957
2991
  // nothing, a hard line break keeps its separator), recovered from the flattened text. `fallback` is the color of runs without one.
2958
- function addRichFitLines(slide, fit, box, context, {value, alignment: logicalAlignment, fallback, objectName, tag}) {
2992
+ function addRichFitLines(slide, fit, box, context, {value, path, alignment: logicalAlignment, fallback, objectName, tag}) {
2959
2993
  const region = {x: box.x / 96, y: box.y / 96, w: box.width / 96, h: box.height / 96};
2960
2994
  const runText = run => typeof run === 'string' ? run : run.text;
2961
2995
  const whole = typeof value === 'string' ? value : value.map(runText).join('');
2996
+ assertXmlText(whole, path);
2962
2997
  const count = fit.richLines.length;
2963
2998
  let cursor = 0;
2964
2999
  for (const [index, line] of fit.richLines.entries()) {
@@ -2984,11 +3019,41 @@ function addRichFitLines(slide, fit, box, context, {value, alignment: logicalAli
2984
3019
  }
2985
3020
  }
2986
3021
 
3022
+ function assertXmlText(text, path) {
3023
+ const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(String(text??''));
3024
+ if(invalid)throw new OPFPptxError('invalid-text',`Text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which DrawingML XML cannot represent.`,{path});
3025
+ }
3026
+
2987
3027
  // Match the published renderer's payload geometry and shared core text fitting.
2988
3028
  // Each fitted line remains native editable text, without PowerPoint rewrapping it.
3029
+ // RR-88: a plain-string title that wraps over several lines, with no hard break, overflow, rich runs or right-to-left line.
3030
+ function isNativeTitle(item) {
3031
+ return item.field === 'title' && typeof item.value === 'string' && item.value.length > 0 && !/[\r\n\u2028\u2029]/.test(item.value) && item.text?.lines?.length > 1
3032
+ && !item.text.overflow && !item.text.richLines && !item.text.directions?.includes('rtl') && !item.text.sourceLines?.some(line => line.boundary === 'hard' || line.segments?.some(segment => segment.kind === 'tab'));
3033
+ }
3034
+
3035
+ // RR-88: the wrapped title as one native text box: the whole string, native wrap, no autofit, at the block its measured lines fill
3036
+ // (top of the first line to the bottom of the last), with the measured line pitch as exact spacing. Tagged as one heading line,
3037
+ // so import reads the current words as the title. The caller binds it to the Title placeholder.
3038
+ function addNativeTitle(slide, item, context, options, deckContext) {
3039
+ const fit = item.text, box = item.box, lines = fit.placement?.lines;
3040
+ const tops = fit.lines.map((_, index) => lines?.[index] ? lines[index].baseline - fit.fontSize : box.y + index * fit.lineHeight);
3041
+ const bottoms = fit.lines.map((_, index) => lines?.[index] ? lines[index].baseline - fit.fontSize + lines[index].height : box.y + (index + 1) * fit.lineHeight);
3042
+ const top = Math.min(...tops), height = Math.max(...bottoms) - top;
3043
+ const objectName = `OPF heading ${item.path} line 0`;
3044
+ deckContext.headingTags.set(objectName, {v: 1, group: item.path, field: 'title', line: 0, count: 1, boundary: 'end', separator: ''});
3045
+ deckContext.nativeTitles.add(objectName);
3046
+ const style = item.textStyle ?? resolveTextStyle({fontFamily: context.fonts.body, fontWeight: 400, path: item.path}, options.textMeasurement);
3047
+ slide.addText(item.value, {
3048
+ ...textBoxOptions({x: box.x / 96, y: top / 96, w: box.width / 96, h: height / 96}, context, fit.fontSize * .75),
3049
+ ...nativeFontOptions(style),
3050
+ color: pptxColor(context.textColor), align: physicalAlignment(fit.placement?.alignment ?? item.alignment ?? 'left', fit.directions?.[0]),
3051
+ objectName, fit: 'none', wrap: true, lineSpacing: fit.lineHeight * .75, lineSpacingMultiple: undefined
3052
+ });
3053
+ }
3054
+
2989
3055
  function addMeasuredPayloadText(slide, text, box, context, options, config) {
2990
- const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(String(text??''));
2991
- if(invalid)throw new OPFPptxError('invalid-text',`Text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which DrawingML XML cannot represent.`,{path:config.path});
3056
+ assertXmlText(text, config.path);
2992
3057
  const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
2993
3058
  const style = config.textStyle ?? resolveTextStyle({fontFamily: config.fontFamily ?? context.fonts.body, fontWeight: config.fontWeight ?? 400, path: config.path}, options.textMeasurement);
2994
3059
  const fit = config.fit ?? fitText(String(text ?? ''), box, config.fontSize * scale, (context.composition?.minFontSize ?? 16) * scale, textWidthMeasurer(style, options.textMeasurement));
@@ -3067,7 +3132,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
3067
3132
  }
3068
3133
  const staticDates = new Map();
3069
3134
  // RR-11: the first footer text, date and slide number that fit one line are native placeholders (src/native-furniture.js).
3070
- const natives = nativeFurnitureParts(layout);
3135
+ const natives = nativeFurnitureParts(layout, {staticNumbers: context.staticSlideNumbers});
3071
3136
  for(const [index,part]of layout.parts.entries()){
3072
3137
  const entry = {index, kind: part.kind, zone: part.zone, field: part.field, type: part.type, path: part.path, native: natives.has(index), names: []};
3073
3138
  drawn.push(entry);
@@ -3088,6 +3153,8 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
3088
3153
  // en-US field type is written as its laid-out text.
3089
3154
  const fields=[];
3090
3155
  for(const field of furniturePartFields(part)){
3156
+ // RR-91 (slideNumberFields, default 'static'): a number with words around it is fixed text; Keynote drops the words next to a live number.
3157
+ if(field.type==='slideNumber'&&context.staticSlideNumbers&&!isBareSlideNumber(part))continue;
3091
3158
  const nativeType=nativeFieldType(field);
3092
3159
  if(nativeType)fields.push({...field,nativeType});
3093
3160
  else options.onDiagnostic?.({code:'furniture-date-fixed',path:part.path,message:`dateFormat '${field.format}' has no PowerPoint en-US date field; the current date is exported as fixed text that PowerPoint will not update.`});
@@ -3230,7 +3297,7 @@ async function addQuotePayload(slide, presentation, value, layout, context, opti
3230
3297
  // FA-10: a TextRun[] quote body (core's rich lines, quotation marks joined to its first and last run) is exported line by line like rich body text, with the same quote tags.
3231
3298
  if (part.runs && part.fit.richLines) {
3232
3299
  addRichFitLines(slide, part.fit, part.box, context, {
3233
- value: part.text, alignment: part.fit.placement?.alignment ?? context.contentAlignment ?? 'left', fallback: context.textColor,
3300
+ value: part.text, path: part.path, alignment: part.fit.placement?.alignment ?? context.contentAlignment ?? 'left', fallback: context.textColor,
3234
3301
  objectName: line => `OPF quote ${group} part ${index} line ${line}`,
3235
3302
  tag: (name, line, count, boundary) => context.quoteTags.set(name, {v: 1, role: 'text', group, part: index, line, count, ...boundary, base: richBase(part.fit, part.style, context.colors.text), ...(index === 0 && line === 0 ? {anchor} : {})})
3236
3303
  });
@@ -3574,9 +3641,10 @@ function textBoxOptions(region, context, fontSize) {
3574
3641
  // The private text helpers annotation-export.js writes captions and footnote lines with (RR-34).
3575
3642
  const exportHelpers = {textBoxOptions, nativeFontOptions, exportColor, nativeColor};
3576
3643
 
3577
- function textRuns(value, context, fallbackFontSize) {
3644
+ function textRuns(value, context, fallbackFontSize, path) {
3578
3645
  const runs = Array.isArray(value) ? value : [value];
3579
3646
  return runs.map((run) => {
3647
+ assertXmlText(typeof run === "string" ? run : run?.text, path);
3580
3648
  if (typeof run === "string") {
3581
3649
  return {
3582
3650
  text: run,
@@ -4069,7 +4137,7 @@ async function normalizePptxZip(raw, context) {
4069
4137
  attachFurnitureFields(entries,context.furnitureFields);
4070
4138
  attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests);
4071
4139
  // RR-11: the recorded footer shapes become PowerPoint's date, footer and slide-number placeholders.
4072
- context.nativePlaceholders = attachNativePlaceholders(entries,context.nativeFurniture,context.nativeFurnitureBands);
4140
+ context.nativePlaceholders = attachNativePlaceholders(entries,context.nativeFurniture,context.nativeFurnitureBands,context.footerText);
4073
4141
  // The "Image unavailable" panel of an unresolved logo: identity only, so import does not read it as content.
4074
4142
  attachTextTags(entries,context.logoPlaceholderTags,LOGO_TAG,'opfLogoPlaceholder','logo placeholder');
4075
4143
  // opf-pptx#221: the image block of an unavailable-image placeholder, on its panel (full provenance).
@@ -4089,6 +4157,10 @@ async function normalizePptxZip(raw, context) {
4089
4157
  if(highlight){
4090
4158
  try{entries[chartPart]=encodeText(applyChartHighlight(decodeText(entries[chartPart]),highlight));}
4091
4159
  catch(error){throw new OPFPptxError('packaging-failed',`Chart highlight could not be written: ${errorMessage(error)}`);}
4160
+ }else{
4161
+ // RR-89: series that keep a theme accent (or a chart.colors scheme slot) are written as theme colours.
4162
+ const {palette,paletteSchemes,circular}=context.chartHeadings.get(name);
4163
+ if(paletteSchemes)entries[chartPart]=encodeText(applyChartSchemeColors(decodeText(entries[chartPart]),palette,paletteSchemes,{circular}));
4092
4164
  }
4093
4165
  // PptxGenJS hardcodes a black fallback in pie/doughnut label properties.
4094
4166
  // Normalize only our generated chart text styles; point/series fills stay intact.
@@ -4166,6 +4238,8 @@ async function normalizePptxZip(raw, context) {
4166
4238
  try {
4167
4239
  placeImages(entries, context.images, context.imageOverlays, {
4168
4240
  metadataFor: (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), fitImage,
4241
+ // The key of the bytes the package finally holds: the picture's orientation is normalized when the parts are written.
4242
+ mediaKeyFor: (part, id) => { const target = parseRelationships(entries, part).get(id)?.path, bytes = entries[target]; return bytes ? mediaKey(imageMetadata.get(target) ? normalizeImageOrientation(bytes, imageMetadata.get(target)) : bytes) : undefined; },
4169
4243
  fail: path => { throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path }); }
4170
4244
  });
4171
4245
  } catch (error) {
@@ -4490,6 +4564,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
4490
4564
  const content=body.replace(/<a:pPr\b[^>]*(?:\/>|>[\s\S]*?<\/a:pPr>)/g,node=>{properties ||= node;return '';});
4491
4565
  return `<a:p>${properties}${content}</a:p>`;
4492
4566
  });
4567
+ xml = nativeTitleAutofit(nativeBodyDefaults(xml, context.nativeBodies), context.nativeTitles);
4493
4568
  }
4494
4569
  // theme1.xml: FF-24 colors and names above, then FF-07 fonts here; they touch disjoint elements.
4495
4570
  if (context.scriptFonts) xml = partScriptFonts(path, xml, context.scriptFonts, context.partSlides.get(path) ?? 0);