@openpresentation/opf-pptx 0.22.0 → 0.23.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/README.md CHANGED
@@ -431,14 +431,25 @@ Table chrome follows the theme too (FF-24c). With no theme override on the slide
431
431
 
432
432
  When the deck background is a theme slot other than `light1`, the slide master gets that background and its title/body/other text styles use the paired text slot. The layout drops its own `bg1` background and inherits the master, so slides added in PowerPoint match the theme. `light1` decks keep the vendored master.
433
433
 
434
+ ### Color variants (RR-96)
435
+
436
+ A deck's `design.variant` (a background slot string such as `"dark1"`, or `{background, accents?}` with `background` one of `light1`, `light2`, `dark1`, `dark2` and `accents` a permutation of `accent1`-`accent6`) decides what the master color map says. Export writes core's `clrMapAttributes` into the master's `<p:clrMap>`: `bg1`/`tx1`/`bg2`/`tx2` name the slots the variant puts on the background and on text, and the six `accentN` names point at the permuted accent slots. A slide whose variant differs from the deck's writes `<p:clrMapOvr><a:overrideClrMapping .../>` with its own map, so a dark slide in a light deck keeps `bg1`/`tx1` as its background and text. A deck whose theme background is dark gets the dark map without an explicit variant. In PowerPoint, switching a slide's Background Style or the deck's variant therefore recolors text, borders, tables, charts and code panels together.
437
+
438
+ Every color the exporter derives from the scheme is written as `a:schemeClr`: text, backgrounds, table fills and borders, card fills and edges, chart text, gridlines and axis lines, code panels and syntax colors, metric trend marks, tags and footers. The deck's neutrals are core's `roles.neutrals`: the text color mixed into the background at a fixed share (border 20%, gridline 15%, axis line 25%, chart text 65%). They are written as the text slot with `a:lumMod`/`a:lumOff` (a translucent mix as `a:alpha`), so a variant change keeps them in step. The pass runs over the finished slide, master, layout and chart parts, before provenance is fingerprinted.
439
+
440
+ A picture background that names its `tone` (`dark` or `light`) and has no overlay of its own is covered by one full-slide rectangle named `OPF background tone <tone> slides.<n>`, directly above the picture: the tone variant's `bg1` as `a:schemeClr` at 40% alpha (the preview's `data-opf-tone-overlay` scrim), so text keeps its contrast in PowerPoint and Keynote. The slide's `clrMapOvr` is the tone's map. Import takes that rectangle back into the background's `tone`.
441
+
434
442
  These stay `a:srgbClr`:
435
443
 
436
- - literal hex colors, even when they equal a scheme slot;
444
+ - literal hex colors the document wrote, even when they equal a scheme slot;
437
445
  - `var:<id>` variables;
438
- - translucent colors;
439
- - other engine-derived chrome: card fills and borders, chart panels, series and labels, timeline connectors and markers, and image and media placeholders, plus default text on a card or a literal background. These colors are contrast-selected or derived from roles, not named by the document, and are not yet theme references.
446
+ - colors on a slide with its own `design.colorScheme` or a slide theme that changes a slot (every color on that slide is literal);
447
+ - a translucent fill the theme cannot hold (`a:schemeClr` plus `a:alpha` is used where the slot color itself is opaque);
448
+ - colors that match no scheme slot or derived neutral, such as contrast-adjusted series colors and `hlink`/`folHlink` fallbacks.
449
+
450
+ Import reads the master color map into `design.variant` (the string shorthand when there are no accent changes), the slide's `clrMapOvr` into the slide's `design.variant`, and a `schemeClr` background fill into the theme-slot background. A `bg1` background imports as no background (the variant implies it); another light or dark slot imports as `{type:'theme', slot}`. A variant that core already implies from the deck's own design is dropped, so a dark-by-theme deck does not gain one. A color map that is not a permutation core's variants describe reports `unsupported-color-map` and is not applied. On a slide without exporter provenance, a table fill equal to a scheme slot returns as the slot name (`accent1`, `light2`, ...). `test/color-variants.mjs` covers the master map, the override with accents, the scheme neutrals and the import round trip. This establishes package structure and round-trip, not PowerPoint rendering.
440
451
 
441
- Import reads the first slide master's theme and matches it against the host default catalog only (the first of `fromPptx`'s `catalogs`): recovered bare ids resolve in a document that declares no catalogs. Theme and colour-scheme recovery does not use the foreign-layout default: omitted catalogs and explicit `catalogs: []` both leave recovery unregistered. An exact twelve-slot match with one of its colour schemes returns that id; the `clrScheme` name breaks ties. Otherwise the importer returns inline slots, relative to the scheme the `clrScheme` is named after when there is one (`{id:'boost', accent1:'#123456'}`). A theme whose name equals one of its themes' names maps to `design.theme` only if the package's color scheme or heading/body fonts match that theme (its references resolved in its own group first); otherwise `theme-unverified` is reported. With omitted catalogs or explicit `catalogs: []`, or a match only in another registered catalog, the colour scheme imports inline and no theme id is recovered; an FF-32 stored reference still restores. A missing theme, or slots that are not opaque sRGB/system colors, report `unsupported-theme-colors` on `design.colorScheme`. Slide colors are still imported as resolved hex. Role overrides such as `primary` have no theme slot and are not recovered. `test/theme-colors.mjs` covers all 14 gallery schemes and 4 gallery themes (registered as `defaultCatalog`), import without catalogs, override, foreign and damaged themes, and the literal-color boundaries. `test/table-theme-colors.mjs` covers named and default table chrome on all 14 schemes and four backgrounds, literals, override slides, the link colors, re-import and a theme switch in the package. This establishes package structure and round-trip, not PowerPoint rendering.
452
+ Import reads the first slide master's theme and matches it against the host default catalog only (the first of `fromPptx`'s `catalogs`): recovered bare ids resolve in a document that declares no catalogs. Theme and colour-scheme recovery does not use the foreign-layout default: omitted catalogs and explicit `catalogs: []` both leave recovery unregistered. An exact twelve-slot match with one of its colour schemes returns that id; the `clrScheme` name breaks ties. Otherwise the importer returns inline slots, relative to the scheme the `clrScheme` is named after when there is one (`{id:'boost', accent1:'#123456'}`). A theme whose name equals one of its themes' names maps to `design.theme` only if the package's color scheme or heading/body fonts match that theme (its references resolved in its own group first); otherwise `theme-unverified` is reported. With omitted catalogs or explicit `catalogs: []`, or a match only in another registered catalog, the colour scheme imports inline and no theme id is recovered; an FF-32 stored reference still restores. A missing theme, or slots that are not opaque sRGB/system colors, report `unsupported-theme-colors` on `design.colorScheme`. Other slide colors are still imported as resolved hex. Role overrides such as `primary` have no theme slot and are not recovered. `test/theme-colors.mjs` covers all 14 gallery schemes and 4 gallery themes (registered as `defaultCatalog`), import without catalogs, override, foreign and damaged themes, and the literal-color boundaries. `test/table-theme-colors.mjs` covers named and default table chrome on all 14 schemes and four backgrounds, literals, override slides, the link colors, re-import and a theme switch in the package. This establishes package structure and round-trip, not PowerPoint rendering.
442
453
 
443
454
  ## JPEG orientation on import
444
455
 
@@ -62,7 +62,7 @@ export function readSlideTheme(slidePath, {part, relationships, bytes}) {
62
62
  if (elements?.['a:fmtScheme']) {format = elements['a:fmtScheme'];formatPath = themePath;}
63
63
  }
64
64
  }
65
- return {chain, colors, mapping, fonts, format, formatPath, parsedPart};
65
+ return {chain, colors, mapping, masterMapping, fonts, format, formatPath, parsedPart};
66
66
  }
67
67
 
68
68
  export function importBackground(slidePath, dimensions, archive, report) {
@@ -75,7 +75,7 @@ export function importBackground(slidePath, dimensions, archive, report) {
75
75
  return undefined;
76
76
  };
77
77
  // Picture relationships belong to the part that holds the fill.
78
- const context = {colors, mapping, image: fill => readImageBackground(fill, owner.path, archive, dimensions, report)};
78
+ const context = {colors, mapping, image: fill => readImageBackground(fill, owner.path, archive, dimensions, report, {colors, mapping})};
79
79
  if (background['p:bgPr']) return readNativeBackground(background['p:bgPr'], dimensions, report, context);
80
80
  const reference = background['p:bgRef'];
81
81
  if (!/^\d+$/.test(reference?.idx ?? '')) return unsupported();
@@ -93,7 +93,7 @@ export function importBackground(slidePath, dimensions, archive, report) {
93
93
  const style = styles?.[index < 1000 ? index - 1 : index - 1001];
94
94
  if (!style) return unsupported();
95
95
  context.placeholder = readBackgroundColor(reference, context);
96
- context.image = fill => readImageBackground(fill, formatPath, archive, dimensions, report);
96
+ context.image = fill => readImageBackground(fill, formatPath, archive, dimensions, report, {colors, mapping});
97
97
  return readNativeBackground(drawingObject([style]), dimensions, report, context);
98
98
  }
99
99
 
@@ -108,7 +108,7 @@ const focusValue = value => Math.round(Math.max(0, Math.min(1, value)) * 10000)
108
108
  // the focus was, so it reads as the edge-most focus that gives the same crop); centered fill-rectangle insets that keep
109
109
  // the picture's aspect are `contain`; an uncropped fill of another aspect is `stretch`. Any other geometry or picture
110
110
  // effect is imported as the closest fit and reported.
111
- function readImageBackground(fill, partPath, {relationships, bytes}, dimensions, report) {
111
+ function readImageBackground(fill, partPath, {relationships, bytes}, dimensions, report, context = {}) {
112
112
  const blip = fill['a:blip'] ?? {};
113
113
  const relationship = blip['r:embed'] && relationships(partPath).get(blip['r:embed']);
114
114
  const data = relationship && relationship.targetMode !== 'External' ? bytes(relationship.path) : undefined;
@@ -125,9 +125,12 @@ function readImageBackground(fill, partPath, {relationships, bytes}, dimensions,
125
125
  if (blip['a:grayscl'] !== undefined) recolor = 'grayscale';
126
126
  const duotone = blip['a:duotone'], literal = node => /^[\da-f]{6}$/i.test(node?.val ?? '') && Object.keys(node).every(key => key === 'val' || typeof key === 'symbol') ? `#${node.val.toUpperCase()}` : undefined;
127
127
  if (duotone) {
128
- const colors = Array.isArray(duotone['a:srgbClr']) ? duotone['a:srgbClr'] : [];
129
- const [dark, light] = colors.map(literal);
130
- if (colors.length === 2 && dark && light && Object.keys(duotone).every(key => key === 'a:srgbClr' || typeof key === 'symbol')) recolor = {dark, light};
128
+ // RR-96: the two colors are literals or scheme colors (the export writes the scheme-derived ones as schemeClr); a scheme color reads as the color it stands for.
129
+ const kind = ['a:srgbClr', 'a:schemeClr'].find(name => Array.isArray(duotone[name]) && duotone[name].length === 2);
130
+ const colors = kind ? duotone[kind] : [];
131
+ const read = node => { if (kind === 'a:srgbClr') return literal(node); const resolved = readBackgroundColor({[kind]: node}, context); return resolved && resolved.alpha === 1 ? resolved.hex : undefined; };
132
+ const [dark, light] = colors.map(read);
133
+ if (colors.length === 2 && dark && light && Object.keys(duotone).every(key => key === kind || typeof key === 'symbol')) recolor = {dark, light};
131
134
  else approximate('uses a duotone recolor that is not two literal colours');
132
135
  }
133
136
  if (recolor && blip['a:grayscl'] !== undefined && duotone) approximate('uses both a grayscale and a duotone recolor');
@@ -184,11 +184,28 @@ function readNativePattern(pattern, report, context) {
184
184
  ...(uniform && foreground.alpha !== 1 ? {opacity: foreground.alpha} : {})};
185
185
  }
186
186
 
187
+ // The OPF slot an `a:schemeClr` with no color transform stands for through the color map (`bg1` stays the role), or undefined.
188
+ // (OPF's theme-slot background is one of the four light and dark slots; an accent keeps its resolved color.)
189
+ const SLOT_NAMES_BY_OOXML = {lt1: 'light1', dk1: 'dark1', lt2: 'light2', dk2: 'dark2'};
190
+ function plainSchemeSlot(fill, context) {
191
+ const node = fill?.['a:schemeClr'];
192
+ if (!node || Array.isArray(node) || Object.keys(node).some(key => key.startsWith('a:'))) return undefined;
193
+ if (node.val === 'bg1') return !context.mapping?.bg1 || SLOT_NAMES_BY_OOXML[context.mapping.bg1] ? 'bg1' : undefined;
194
+ return SLOT_NAMES_BY_OOXML[context.mapping?.[node.val] ?? node.val];
195
+ }
196
+
187
197
  export function readNativeBackground(properties, {width, height}, report = () => {}, context = {}) {
188
198
  if (!properties) return undefined;
189
199
  if (Object.hasOwn(properties, 'a:noFill')) return {type: 'solid', color: '#FFFFFF', opacity: 0};
190
200
  const solid = readBackgroundColor(properties['a:solidFill'], context);
191
- if (solid) return {type: 'solid', color: solid.hex, ...(solid.alpha === 1 ? {} : {opacity: solid.alpha})};
201
+ if (solid) {
202
+ // RR-96: a plain scheme color is the theme slot it reaches through the color map (the OPF theme-slot background); bg1 is the variant's own
203
+ // background, which design.variant already carries, so it imports as no background of its own.
204
+ const slot = plainSchemeSlot(properties['a:solidFill'], context);
205
+ if (slot === 'bg1' && solid.alpha === 1) return undefined;
206
+ if (slot && slot !== 'bg1') return {type: 'theme', slot, ...(solid.alpha === 1 ? {} : {opacity: solid.alpha})};
207
+ return {type: 'solid', color: solid.hex, ...(solid.alpha === 1 ? {} : {opacity: solid.alpha})};
208
+ }
192
209
  if (properties['a:pattFill']) return readNativePattern(properties['a:pattFill'], report, context);
193
210
  if (properties['a:blipFill'] && context.image) return context.image(properties['a:blipFill']);
194
211
  const gradient = properties['a:gradFill'];
@@ -12,13 +12,14 @@ const hex6 = value => String(value).replace(/^#/, '').toUpperCase();
12
12
  * maps a colour reference and its resolved hex to a scheme value when the deck theme holds that colour (theme-colors.js). A colour the
13
13
  * panel adjusted, a hex or variable entry and a tint (series 7 to 12) have none.
14
14
  */
15
- export function chartPaletteSchemes(colors, preferred, adjusted, schemeFor) {
15
+ export function chartPaletteSchemes(colors, preferred, adjusted, schemeFor, accents) {
16
16
  const authored = Array.isArray(colors) ? colors : [];
17
17
  return preferred.map((color, index) => {
18
18
  if (hex6(color) !== hex6(adjusted[index])) return undefined;
19
19
  const entry = typeof authored[index] === 'string' ? authored[index].trim() : undefined;
20
20
  if (entry !== undefined) return /^[A-Za-z]/.test(entry) ? schemeFor(entry, hex6(color)) : undefined;
21
- return index < 6 ? schemeFor(`accent${index + 1}`, hex6(color)) : undefined;
21
+ // RR-96: series i is the slot the variant's accent order puts at position i (accent i + 1 by default), and schemeFor names the role that slot has.
22
+ return index < 6 ? schemeFor(accents?.[index] ?? `accent${index + 1}`, hex6(color)) : undefined;
22
23
  });
23
24
  }
24
25
 
package/dist/chartex.js CHANGED
@@ -171,7 +171,7 @@ const axisTitle = (value, text, run) => {
171
171
 
172
172
  function axes(spec, gridColor, text, titles = {}, run = {}, code) {
173
173
  if (!spec.axes) return '';
174
- const gridlines = `<cx:majorGridlines><cx:spPr><a:ln w="9525"><a:solidFill><a:srgbClr val="${gridColor}"><a:alpha val="70000"/></a:srgbClr></a:solidFill></a:ln></cx:spPr></cx:majorGridlines>`;
174
+ const gridlines = `<cx:majorGridlines><cx:spPr><a:ln w="9525"><a:solidFill><a:srgbClr val="${gridColor}"/></a:solidFill></a:ln></cx:spPr></cx:majorGridlines>`;
175
175
  const gapWidth = {clusteredColumn: '0.06', paretoLine: '0.06', boxWhisker: '1', waterfall: '0.5', funnel: '0.06'}[spec.layoutId] ?? '1';
176
176
  // CT_Axis order: scaling, units, majorGridlines, tickLabels, spPr, txPr.
177
177
  const category = `<cx:axis id="0"><cx:catScaling gapWidth="${gapWidth}"/>${axisTitle(titles.category, text, run)}<cx:tickLabels/>${text}</cx:axis>`;
@@ -254,7 +254,7 @@ export function chartStyleXml({labelColor = '000000', gridColor = '000000', font
254
254
  // theme's tx1: PowerPoint applies the style part to chartex labels that carry no cx:txPr of their own.
255
255
  const textColor = `<a:srgbClr val="${labelColor}"/>`;
256
256
  const face = escapeXml(font);
257
- const faintLine = `<a:ln w="9525" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:srgbClr val="${gridColor}"><a:alpha val="70000"/></a:srgbClr></a:solidFill><a:round/></a:ln>`;
257
+ const faintLine = `<a:ln w="9525" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:srgbClr val="${gridColor}"/></a:solidFill><a:round/></a:ln>`;
258
258
  const entry = (name, {fillIdx = '0', fillColor = '', fontColor = textColor, spPr = '', size = '', mods = ''} = {}) =>
259
259
  `<cs:${name}${mods ? ` mods="${mods}"` : ''}><cs:lnRef idx="0"/><cs:fillRef idx="${fillIdx}">${fillColor}</cs:fillRef><cs:effectRef idx="0"/><cs:fontRef idx="minor">${fontColor}</cs:fontRef>${spPr ? `<cs:spPr>${spPr}</cs:spPr>` : ''}${size ? `<cs:defRPr sz="${size}" kern="1200">${solidFill(labelColor)}<a:latin typeface="${face}"/><a:ea typeface="${face}"/><a:cs typeface="${face}"/></cs:defRPr>` : ''}</cs:${name}>`;
260
260
  const entries = STYLE_ENTRIES.map(name => {
@@ -151,7 +151,7 @@ export function pictureFrame(metadata, {box, fit, focus = {x: .5, y: .5}, shrink
151
151
  * picture's objectName, or 'background' for the overlay of a native slide background fill; `overlay` (the authored value)
152
152
  * is stored in OPF_IMAGE_OVERLAY_V1.
153
153
  */
154
- export function placeImages(entries, images, overlays, {metadataFor, fail, fitImage, mediaKeyFor}) {
154
+ export function placeImages(entries, images, overlays, {metadataFor, fail, fitImage, mediaKeyFor, finish = (_part, xml) => xml}) {
155
155
  if (!images.size && !overlays.size) return;
156
156
  const manifests = new Map(), overlayManifests = new Map(), placed = new Set(), shaped = new Set();
157
157
  for (const part of Object.keys(entries).filter(path => /^ppt\/slides\/slide\d+\.xml$/.test(path))) {
@@ -176,6 +176,8 @@ export function placeImages(entries, images, overlays, {metadataFor, fail, fitIm
176
176
  const blip = blipEffects(effects);
177
177
  if (blip) picture = picture.replace(/<a:blip\b([^>]*?)(?:\/>|>([\s\S]*?)<\/a:blip>)/, (_, attributes, inner = '') => `<a:blip${attributes}>${blip}${inner}</a:blip>`);
178
178
  }
179
+ // RR-96: the colours the treatment wrote become scheme colours before the manifest fingerprints the shape.
180
+ picture = finish(part, picture);
179
181
  placed.add(name);
180
182
  if (image.treatment !== undefined) {
181
183
  const node = parser.parse(picture)['p:pic'];
@@ -191,6 +193,7 @@ export function placeImages(entries, images, overlays, {metadataFor, fail, fitIm
191
193
  if (!overlay) return shape;
192
194
  const emu = value => Math.round(value / 96 * EMUS_PER_INCH);
193
195
  shape = shape.replace(/<p:spPr>[\s\S]*?<\/p:spPr>/, `<p:spPr><a:xfrm><a:off x="${emu(overlay.box.x)}" y="${emu(overlay.box.y)}"/><a:ext cx="${emu(overlay.box.width)}" cy="${emu(overlay.box.height)}"/></a:xfrm>${presetXml(overlay.shape)}<a:solidFill>${colorXml(overlay, overlay.opacity)}</a:solidFill><a:ln><a:noFill/></a:ln></p:spPr>`);
196
+ shape = finish(part, shape);
194
197
  const node = parser.parse(shape)['p:sp'];
195
198
  overlayManifests.set(name, {v: 1, slide: overlay.slide, owner: overlay.owner, overlay: overlay.overlay, properties: node['p:spPr']});
196
199
  shaped.add(name);
package/dist/index.js CHANGED
@@ -42,17 +42,17 @@ import {dedupeMedia, mediaKey} from './media-dedupe.js';
42
42
  import {placeWatermarks, importWatermark, importTextWatermark, tagTextWatermarks, watermarkName, watermarkTextName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
43
43
  import {placeLogos, importLogo, importLogoPlaceholders, logoName, LOGO_TAG} from './logo-provenance.js';
44
44
  import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
45
- import {importBackground} from './background-import.js';
45
+ import {importBackground, readSlideTheme} from './background-import.js';
46
46
  import {chartHighlightPlan, applyChartHighlight} from './chart-highlight.js';
47
47
  import {orderChartSeries} from './chart-order.js';
48
- import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
48
+ import {themeSlotColors, schemeColorTable, writeSchemeColors, authoredLiteralColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath, variantFromColorMap} from './theme-colors.js';
49
49
  import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, planSlideThemes, reconcileLanguage, reportPerSlideNotesScriptFonts, runLanguageFonts, runLanguageTag, stripRunScriptFonts, themeEastAsianFromLatin} from './script-fonts.js';
50
50
  import { webpToPng, svgToPng, readLocalFile, localFileReadable } from '#image-fallback';
51
51
  import { prepareSvg, svgDataUriBytes, svgRasterScale, svgBlipRelationship, attachSvgPictures } from './svg-image.js';
52
52
  import { chartPaletteSchemes, applyChartSchemeColors } from './chart-scheme-colors.js';
53
53
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
54
54
  import {
55
- catalogRecords, chartPaletteForFill, chartSeriesPalette, codeHighlightBands, codeHighlightColors, codeHighlightLines, codeLineNumbers, codeLineRuns, codeSyntaxPaletteForScheme, composeSlide,
55
+ catalogRecords, clrMapAttributes, normalizeColorVariant, chartPaletteForFill, chartSeriesPalette, chartTextSize as coreChartTextSize, codeHighlightBands, codeHighlightColors, codeHighlightLines, codeSyntaxPaletteForRoles, readableSchemeColor, codeLineNumbers, codeLineRuns, codeSyntaxPaletteForScheme, composeSlide,
56
56
  ENGINE_DEFAULT_FONT_SCHEME, defaultSlideBackground, fitImage, fitText, layoutTable, layoutWatermark, metricTrendMark, resolveCanvasDimensions, resolveColorRoles, resolveFontFamilies,
57
57
  resolveChartColors, resolveReference, resolveScriptFonts, resolveTextStyle, textColorForFill, textWidthMeasurer, timelineMarkerShapes, timelineTextColor, tokenizeCode
58
58
  } from "@openpresentation/opf/composition";
@@ -334,6 +334,14 @@ export async function toPptx(input, options = {}) {
334
334
  return slide.section;
335
335
  });
336
336
  context.masterBackground = masterBackground(context);
337
+ // RR-96: the master's clrMap is the deck variant's map (a dark deck is the dark map, not hand-inverted colors); a slide whose variant
338
+ // differs writes its own override (exportSlideContext).
339
+ context.clrMap = context.roles.map ? clrMapAttributes(context.roles.map) : undefined;
340
+ context.clrMapOverrides = new Map();
341
+ // RR-96: the scheme-derived colors of the deck (master and layouts) and of each slide (its parts and charts), rewritten to schemeClr in the finished parts.
342
+ const authoredLiterals = authoredLiteralColors(presentation.variables, {...presentation.design, colorScheme: undefined, theme: undefined});
343
+ context.deckSchemeColors = {table: schemeColorTable(context), keep: authoredLiterals};
344
+ context.slideSchemeColors = [];
337
345
  context.linkSentinels = linkSentinels(presentation);
338
346
  const pptx = new PptxGenJS();
339
347
  configurePresentation(pptx, presentation, {...context,fonts:exportSlideContext(presentation,0,context,options).fonts});
@@ -567,6 +575,8 @@ export async function fromPptx(input, options = {}) {
567
575
  restoreCitations(imported, furnitureContexts.map(context => context.annotations?.notes ?? []), report);
568
576
  restoreLogoFallback(imported, furnitureContexts);
569
577
  applyRunColors(imported, slideProvenance, restoredGroups, report, options);
578
+ restoreSlotFills(imported, slideProvenance, options);
579
+ dropImpliedVariants(imported, options);
570
580
  applyNativeImageFits(imported, furnitureContexts, restoredGroups, options);
571
581
  if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
572
582
  languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
@@ -607,6 +617,66 @@ function applyRunColors(imported, slideProvenance, restoredGroups, report, optio
607
617
  restoreRunColors(imported.slides, info, resolve, report);
608
618
  }
609
619
 
620
+ // RR-96: the variant a color map imports as is dropped where the document's own design already gives that map: core picks light1 or dark1 from the
621
+ // slide background (a dark theme is the dark map), so a deck that is dark by its theme, and a slide that is dark by its own background, need no `variant`.
622
+ function dropImpliedVariants(imported, options) {
623
+ const rolesVariant = design => {
624
+ try { return JSON.stringify(coreContext({...imported, design, slides: [{}]}, 0, {...options, textMeasurement: undefined}).resolved.roles?.variant); } catch { return undefined; }
625
+ };
626
+ const withoutVariant = design => { const {variant, ...rest} = design ?? {}; return rest; };
627
+ if (imported.design?.variant !== undefined) {
628
+ const implied = rolesVariant(withoutVariant(imported.design));
629
+ if (implied !== undefined && implied === JSON.stringify(normalizeColorVariant(imported.design.variant))) {
630
+ const {variant, ...rest} = imported.design;
631
+ if (Object.keys(rest).length) imported.design = rest; else delete imported.design;
632
+ }
633
+ }
634
+ for (const slide of imported.slides) {
635
+ if (slide.design?.variant === undefined) continue;
636
+ const rest = withoutVariant(slide.design);
637
+ const implied = rolesVariant({...imported.design, ...rest});
638
+ if (implied !== undefined && implied === JSON.stringify(normalizeColorVariant(slide.design.variant))) {
639
+ if (Object.keys(rest).length) slide.design = rest; else delete slide.design;
640
+ }
641
+ }
642
+ }
643
+
644
+ // RR-96: a fill that is exactly a slot of the slide's color scheme comes back as that slot (`accent1`, `light2`, ...), the way a PowerPoint scheme
645
+ // fill is a theme color. Table cell and column fills only; a literal that is not a slot stays a literal.
646
+ const FILL_SLOT_ORDER = ['accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6', 'dark1', 'light1', 'dark2', 'light2'];
647
+ function restoreSlotFills(imported, slideProvenance, options) {
648
+ const names = new Map();
649
+ const slotsOf = slide => {
650
+ const key = slide.design ?? imported.design ?? null;
651
+ if (!names.has(key)) {
652
+ let table = new Map();
653
+ try {
654
+ const context = resolvePresentationContext(slide.design ? {...imported, design: {...imported.design, ...slide.design}} : imported, options);
655
+ const slots = themeSlotColors(context.colorScheme);
656
+ for (const slot of FILL_SLOT_ORDER) if (slots[slot] && !table.has(slots[slot])) table.set(slots[slot], slot);
657
+ } catch { table = new Map(); }
658
+ names.set(key, table);
659
+ }
660
+ return names.get(key);
661
+ };
662
+ const visit = (value, table) => {
663
+ if (Array.isArray(value)) { value.forEach(item => visit(item, table)); return; }
664
+ if (!isPlainObject(value)) return;
665
+ if (typeof value.fill === 'string') {
666
+ const hex = value.fill.match(/^#([0-9a-f]{6})$/i)?.[1]?.toUpperCase();
667
+ const slot = hex && table.get(hex);
668
+ if (slot) value.fill = slot;
669
+ }
670
+ for (const child of Object.values(value)) visit(child, table);
671
+ };
672
+ // A slide restored from provenance keeps its authored values (a literal that happens to equal a slot stays the literal).
673
+ for (const [index, slide] of imported.slides.entries()) {
674
+ if (slideProvenance[index]?.structure !== 'untagged') continue;
675
+ const containers = [slide.table, ...asArray(slide.blocks).map(block => block?.table), ...Object.values(slide).flatMap(region => isPlainObject(region) ? [region.table] : [])].filter(Boolean);
676
+ if (containers.length) { const table = slotsOf(slide); if (table.size) containers.forEach(container => visit(container, table)); }
677
+ }
678
+ }
679
+
610
680
  // A consumed logo picture (OPF_LOGO_V1) never becomes content. Its own image restores the organization's logo only when the
611
681
  // stored document did not (RR-71: logos live on the organization), so a package exported without provenance still keeps its
612
682
  // logo: on the primary organization, else on a new one named after the deck. The picture is one drawn asset, so it becomes the
@@ -809,7 +879,31 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
809
879
  images.consumedShapes.delete(images.backgroundOverlayShape);
810
880
  options.onDiagnostic?.({code: 'invalid-image-provenance', path: `slides.${slideIndex}.design.background.overlay`, message: 'The tagged OPF background overlay no longer sits on an image background; it was imported as an ordinary shape.'});
811
881
  }
812
- if (imageBackground) slide.design = {background: imageBackground};
882
+ // RR-96: the tone scrim of a picture background is no content; it restores the background's tone.
883
+ let toned = imageBackground;
884
+ if (imageBackground?.type === 'image') {
885
+ for (const tone of ['dark', 'light']) {
886
+ const index = nativeContext.shapes.findIndex(shape => shape['p:nvSpPr']?.['p:cNvPr']?.name === toneScrimName(`slides.${slideIndex}`, tone));
887
+ if (index >= 0) { images.consumedShapes.add(index); toned = {...imageBackground, tone}; }
888
+ }
889
+ }
890
+ if (toned) slide.design = {background: toned};
891
+ // RR-96: a slide's own color map override (accents included) is its variant. An override that is the master's map imports nothing, one that is
892
+ // the default map on a dark master is the explicit `light1`.
893
+ {
894
+ const {mapping, masterMapping} = readSlideTheme(slidePath, {
895
+ part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
896
+ });
897
+ const sameMap = [...new Set([...Object.keys(mapping), ...Object.keys(masterMapping)])].every(key => mapping[key] === masterMapping[key]);
898
+ if (!sameMap) {
899
+ const mapped = variantFromColorMap(mapping, {explicit: true});
900
+ // The variant a tone names is the tone's, not a second statement of it.
901
+ const tone = slide.design?.background?.tone, impliedByTone = tone && mapped.variant === (tone === 'dark' ? 'dark1' : 'light1');
902
+ if (impliedByTone) { /* the tone names this variant */ }
903
+ else if (mapped.variant) slide.design = {...slide.design, variant: mapped.variant};
904
+ else if (mapped.unsupported) options.onDiagnostic?.({code: "unsupported-color-map", path: `slides.${slideIndex}.design.variant`, message: "The slide's color map override is not a variant OPF can name, so slides[n].design.variant was not imported."});
905
+ }
906
+ }
813
907
  if (Object.keys(furniture.design).length) slide.design = {...slide.design, ...furniture.design};
814
908
  nativeContext.imageBlocks = images.blocks;
815
909
  nativeContext.imageSources = images.sources;
@@ -1912,10 +2006,20 @@ function resolvePresentationContext(presentation, options, core = coreContext({.
1912
2006
  const {theme, colorScheme} = core.resolved;
1913
2007
  const dimensions = {widthInches: core.options.width / 96, heightInches: core.options.height / 96};
1914
2008
  const variables = resolveVariableColors(presentation.variables);
1915
- const background = resolveBackground(design.background ?? theme?.background, colorScheme, variables);
2009
+ const definition = design.background ?? theme?.background;
1916
2010
  const fonts = exportFonts(core, options.textMeasurement);
1917
- // One resolution for every color role (core resolveColorRoles), shared with the SVG preview and the audit.
1918
- const roles = resolveColorRoles(colorScheme, {background: `#${background}`});
2011
+ // One resolution for every color role, shared with the SVG preview and the audit: core's, which maps the roles through the slide's
2012
+ // variant (RR-96: bg1, tx1, bg2, tx2 and the accent order over the one scheme, the neutrals as slot plus luminance transform).
2013
+ // A core older than the variants has no `resolved.roles`; its roles are resolved from the background as before.
2014
+ let background = resolveBackground(definition, colorScheme, variables);
2015
+ let roles = core.resolved.roles ?? resolveColorRoles(colorScheme, {background: `#${background}`});
2016
+ // A single-color background is the slide's bg1 under its variant, which can differ from the background definition (a deck
2017
+ // variant hides the theme's background); a picture, gradient or pattern keeps its own base color.
2018
+ // A theme-slot background (the theme's own, or a slot name) with no variant keeps the slot's color: the scheme's `background` role does not
2019
+ // restyle it, as the preview draws it.
2020
+ const slotBackground = design.variant === undefined && (typeof definition === 'string' || definition?.type === 'theme');
2021
+ if (core.resolved.roles && isSingleColorBackground(definition) && !slotBackground) background = normalizeHex(roles.background);
2022
+ else if (slotBackground && roles.background !== `#${background}`) roles = {...roles, background: `#${background}`};
1919
2023
  const textColor = normalizeHex(roles.text);
1920
2024
 
1921
2025
  return {
@@ -2022,6 +2126,11 @@ function writeLinkSentinels(xml, sentinels) {
2022
2126
  });
2023
2127
  }
2024
2128
 
2129
+ // RR-96: the master's clrMap from the deck variant's map (core clrMapAttributes: bg1, tx1, bg2, tx2, the six accents and the two link slots).
2130
+ function writeColorMap(xml, attributes) {
2131
+ return xml.replace(/<p:clrMap\b[^>]*\/>/, `<p:clrMap${Object.entries(attributes).map(([role, slot]) => ` ${role}="${slot}"`).join('')}/>`);
2132
+ }
2133
+
2025
2134
  // PptxGenJS color option: a scheme value it can emit (tx1, bg2, ...) or RRGGBB.
2026
2135
  function pptxColor(value) {
2027
2136
  return /^(?:tx[12]|bg[12]|accent[1-6])$/.test(value) ? value : normalizeHex(value);
@@ -2064,6 +2173,16 @@ function hostDefaultRecords(kind, catalogs) {
2064
2173
  function importThemeDesign(entries, presentationRoot, presentationRels, catalogs) {
2065
2174
  const diagnostics = [], design = {};
2066
2175
  const report = (code, path, message) => diagnostics.push({code, path, message});
2176
+ // RR-96: the first slide master's clrMap is the deck's variant (a dark deck is bg1 on dk1 and tx1 on lt1), as design.variant: the background slot
2177
+ // alone, or {background, accents} when the map re-points the accents. The default map imports nothing.
2178
+ const masterId = asArray(presentationRoot?.["p:sldMasterIdLst"]?.["p:sldMasterId"]).map(node => node?.["r:id"]).find(id => presentationRels.get(id)?.type?.endsWith("/slideMaster"));
2179
+ const masterPart = masterId ? presentationRels.get(masterId).path : undefined;
2180
+ const masterMap = masterPart && entries[masterPart] ? parseOptionalXml(entries, masterPart)?.["p:sldMaster"]?.["p:clrMap"] : undefined;
2181
+ if (masterMap) {
2182
+ const mapped = variantFromColorMap(masterMap);
2183
+ if (mapped.variant) design.variant = mapped.variant;
2184
+ else if (mapped.unsupported) report("unsupported-color-map", "design.variant", "The slide master's color map is not a variant OPF can name (a background and text on the light and dark slots, with an accent order that is a permutation of accent1 to accent6), so design.variant was not imported.");
2185
+ }
2067
2186
  const themePath = presentationThemePath(presentationRoot, presentationRels, path => parseRelationships(entries, path), entries);
2068
2187
  const theme = themePath ? parseOptionalXml(entries, themePath)?.["a:theme"] : undefined;
2069
2188
  const elements = theme?.["a:themeElements"];
@@ -2300,6 +2419,11 @@ function exportSlideContext(presentation, slideIndex, baseContext, options) {
2300
2419
  throw new OPFPptxError("mixed-slide-dimensions", "PowerPoint requires one canvas size per presentation. Set dimensions on the deck or export this slide separately.");
2301
2420
  }
2302
2421
  const slideContext = { ...baseContext, core, composeFonts: {...core.options.fontFamilies, heading: resolved.fonts.heading, body: resolved.fonts.body, code: resolved.fonts.code}, backgroundDefinition: resolved.backgroundDefinition, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors, roles: resolved.roles, variables: resolved.variables };
2422
+ // RR-96: a slide whose variant differs from the deck's writes its own clrMapOvr (accent order included).
2423
+ if (resolved.roles.map && baseContext.clrMap) {
2424
+ const attributes = clrMapAttributes(resolved.roles.map);
2425
+ if (JSON.stringify(attributes) !== JSON.stringify(baseContext.clrMap)) baseContext.clrMapOverrides.set(`ppt/slides/slide${slideIndex + 1}.xml`, attributes);
2426
+ }
2303
2427
  // A slide-level color scheme (directly or through a slide theme) pins every
2304
2428
  // named color on that slide to literal sRGB; see schemeColorValue().
2305
2429
  const slideScheme = themeSlotColors(resolved.colorScheme), deckScheme = baseContext.themeColors ?? {};
@@ -2309,6 +2433,7 @@ function exportSlideContext(presentation, slideIndex, baseContext, options) {
2309
2433
  const text = defaultTextSchemeValues(slideContext);
2310
2434
  slideContext.textColor = text.text ?? resolved.colors.text;
2311
2435
  slideContext.mutedColor = text.muted ?? resolved.colors.mutedText;
2436
+ baseContext.slideSchemeColors[slideIndex] = {table: schemeColorTable(slideContext), keep: authoredLiteralColors(slide, presentation.variables, {...effective.design, colorScheme: undefined, theme: undefined})};
2312
2437
  return slideContext;
2313
2438
  }
2314
2439
 
@@ -2587,6 +2712,9 @@ async function addImageItem(slide, presentation, item, slideIndex, context, opti
2587
2712
  return name;
2588
2713
  }
2589
2714
 
2715
+ const TONE_SCRIM_OPACITY = 0.4;
2716
+ const toneScrimName = (slidePath, tone) => `OPF background tone ${tone} ${slidePath}`;
2717
+
2590
2718
  // An image background (core SlideComposition.backgroundImage). With alt text it is a full-slide picture at the back of
2591
2719
  // the slide over the canvas colour, carrying the alt text as its descr; without alt it is the native slide background
2592
2720
  // fill (written at packaging). Its overlay is one shape directly above the picture.
@@ -2623,6 +2751,12 @@ async function addBackgroundImage(slide, presentation, background, slideIndex, s
2623
2751
  width: box.w * 96, height: box.h * 96, report: options.onDiagnostic
2624
2752
  } });
2625
2753
  }
2754
+ // RR-96: a picture background that names its tone (and has no overlay of its own) is a picture the text must read on: the slide background
2755
+ // color of the tone's variant (bg1) covers it at 40%, the preview's scrim (opf-render `data-opf-tone-overlay`).
2756
+ const tone = isPlainObject(authored) ? authored.tone : undefined;
2757
+ if (!background.overlay && (tone === 'dark' || tone === 'light')) {
2758
+ slide.addShape('rect', { x: box.x, y: box.y, w: box.w, h: box.h, objectName: toneScrimName(slidePath, tone), fill: { color: normalizeHex(slideContext.roles?.background ?? `#${slideContext.colors.background}`), transparency: 100 - TONE_SCRIM_OPACITY * 100 } });
2759
+ }
2626
2760
  if (background.overlay) addImageOverlay(slide, background.overlay, structuredClone(isPlainObject(authored) ? authored.overlay : undefined) ?? {color: background.overlay.color, opacity: background.overlay.opacity}, overlayName, owner, { ...slideContext, slidePath }, effects);
2627
2761
  }
2628
2762
 
@@ -2695,17 +2829,17 @@ async function addLogo(slide, presentation, logo, slideIndex, slideContext, cont
2695
2829
  slide.addImage({...resolved, objectName: logoName(), ...region, altText: assetAlt(logo.source, presentation) ?? 'Logo'});
2696
2830
  }
2697
2831
 
2698
- // FF-62: chart text is one size. The preview draws every chart label (axis, legend, data label) at
2699
- // max(14, composition.minFontSize ?? 16) px scaled by the slide's shorter side over 720 px (renderer charts.js, `fontPx`),
2700
- // 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`).
2701
- // 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
2702
- // product: the floor rounds up (core `snapFontSizeUp`) and an unfloored request of 14 px rounds down (`snapFontSizeDown`).
2703
- // Math.round differed from the preview by 0.01 pt on slide sizes whose scale is not a whole number (A4, Letter, 16:10).
2704
- function chartTextSize(context) {
2832
+ // FF-62, RR-90: chart text is one size, core's `chartTextSize` for the chart's box: 20 px at the 1280 x 720 reference (15 pt on a 13.33 x 7.5 in
2833
+ // slide), shrunk only when the box is small (at most 1/18 of its height and 1/30 of its width, whole pixels) and never below
2834
+ // `composition.minFontSize` (default 16), scaled by the slide's shorter side over 720 px. The preview (opf-render charts.js) and core's value-axis
2835
+ // density call the same function with the same inputs, so the axis core composed matches the text drawn. The export writes the size in hundredths
2836
+ // of a point (DrawingML `sz`) on core's 0.01 pt grid, as core composes every font size (RR-16): the request rounds down, never below the
2837
+ // grid-rounded floor (core `gridFontSize`, `snapFontSizeUp(floor)` against `snapFontSizeDown(request)`).
2838
+ function chartTextHundredths(context, region) {
2705
2839
  const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
2706
2840
  const floor = context.composition?.minFontSize ?? 16;
2707
- const hundredths = Math.max(14, floor) * scale * 75;
2708
- return floor >= 14 ? Math.ceil(hundredths - 1e-6) : Math.floor(hundredths + 1e-6);
2841
+ const px = coreChartTextSize({width: region.w * 96, height: region.h * 96, scale, minFontSize: floor});
2842
+ return Math.max(Math.ceil(floor * scale * 75 - 1e-6), Math.floor(px * scale * 75 + 1e-6));
2709
2843
  }
2710
2844
 
2711
2845
  // Every c:txPr of a generated classic chart carries the chart text size: PptxGenJS writes no size on the legend (PowerPoint's
@@ -2734,12 +2868,18 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2734
2868
  // Keep the resolved palette surface (including alpha) explicit in native
2735
2869
  // chart/plot areas, so inherited labels are assessed against their own panel.
2736
2870
  const panelFill = context.colorScheme.surface ?? context.colorScheme[context.roles.dark ? 'dark2' : 'light2'] ?? `#${context.colors.surface}`;
2737
- const labelColor = normalizeHex(textColorForFill(panelFill, `#${context.colors.text}`));
2871
+ // RR-96: chart text, gridlines and axis lines are core's scheme-derived neutrals (tx1 mixed into bg1 at 65%, 15% and 25%), which the finished
2872
+ // part writes as schemeClr with lumMod/lumOff, so a variant or background style change in PowerPoint recolors them.
2873
+ const neutrals = context.roles.neutrals;
2874
+ const labelColor = normalizeHex(textColorForFill(panelFill, neutrals?.chartText?.hex ?? `#${context.colors.text}`));
2875
+ // Data labels and highlighted marks contrast with their own mark, from the full text colour (the chart-text neutral is for axes and legends).
2876
+ const markLabelColor = normalizeHex(textColorForFill(panelFill, `#${context.colors.text}`));
2877
+ const gridColor = normalizeHex(neutrals?.gridline?.hex ?? `#${context.colors.border}`), axisLineColor = normalizeHex(neutrals?.axisLine?.hex ?? `#${context.colors.border}`);
2738
2878
  const transparency = /^#[0-9a-f]{8}$/i.test(panelFill) ? (1 - parseInt(panelFill.slice(7), 16) / 255) * 100 : 0;
2739
2879
  const fill = {color:normalizeHex(panelFill),transparency};
2740
2880
  const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
2741
2881
  const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
2742
- const textSize = chartTextSize(context);
2882
+ const textSize = chartTextHundredths(context, region);
2743
2883
  // RR-54: the columns' number formats (Excel codes per exported series) for the caches, labels, value axis and workbook.
2744
2884
  // The value axis shows the first plotted series' format (General when it has none), as the preview does.
2745
2885
  // A combo chart's secondary value axis shows its first series' format (FA-15).
@@ -2747,7 +2887,7 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2747
2887
  const numberFormats = chartData.formats && {...chartData.formats, axis: chartData.spec.grouping === 'percentStacked' ? undefined : chartData.formats.series[0],
2748
2888
  ...(secondaryIndex >= 0 ? {secondaryAxis: chartData.formats.series[secondaryIndex]} : {}),
2749
2889
  labels: !chartOptions?.dataLabels?.content?.includes('percent'), scatter: chartData.type === 'scatter'};
2750
- context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading,labelColor,spec:chartData.spec,textSize,
2890
+ context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading,labelColor,markLabelColor,spec:chartData.spec,textSize,
2751
2891
  options:chartOptions,kind:chartTargetFor(chart.type)?.kind,pointCount:chartData.pointCount,numberFormats});
2752
2892
  // Synthetic row-number categories are a native adaptation, not an authored
2753
2893
  // data column. Full provenance restores the source form only on unchanged caches.
@@ -2758,15 +2898,15 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2758
2898
  // 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
2899
  // `colors` over them), adjusted for the chart panel like the preview does. A colour that is still an unadjusted theme accent (or a
2760
2900
  // `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};
2901
+ const colorContext = {roles: context.roles, variables: context.variables, accents: context.roles.map?.accents};
2762
2902
  const preferredPalette = chartSeriesPalette(context.colorScheme, chart.colors, colorContext);
2763
2903
  const adjustedPalette = chartPaletteForFill(panelFill, preferredPalette);
2764
2904
  const palette = adjustedPalette.map(color=>normalizeHex(color));
2765
- const paletteSchemes = chartPaletteSchemes(chart.colors, preferredPalette, adjustedPalette, (reference, hex) => schemeColorValue(reference, `#${hex}`, context));
2905
+ const paletteSchemes = chartPaletteSchemes(chart.colors, preferredPalette, adjustedPalette, (reference, hex) => schemeColorValue(reference, `#${hex}`, context), context.roles.map?.accents);
2766
2906
  Object.assign(context.chartHeadings.get(objectName), {palette, paletteSchemes, circular});
2767
2907
  // FA-14: chart.highlight. A highlighted chart writes accent and muted colours over the series palette, and its data labels
2768
2908
  // contrast with those colours (src/chart-highlight.js).
2769
- const highlight = chartData.chartex ? undefined : chartHighlightPlan({options: chartOptions, data: chartData.resolved, kind: chartTargetFor(chart.type)?.kind, panelFill, labelColor, primary: `#${context.colors.accent}`,
2909
+ const highlight = chartData.chartex ? undefined : chartHighlightPlan({options: chartOptions, data: chartData.resolved, kind: chartTargetFor(chart.type)?.kind, panelFill, labelColor: markLabelColor, primary: `#${context.colors.accent}`,
2770
2910
  schemeFor: hex => schemeColorValue('primary', hex, context)});
2771
2911
  context.chartPalettes.set(objectName,highlight ? highlight.seriesFills : palette);
2772
2912
  if (highlight) context.chartHeadings.get(objectName).highlight = highlight;
@@ -2774,7 +2914,7 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2774
2914
  // The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
2775
2915
  // PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
2776
2916
  const series = chartData.series.map((entry) => ({name: entry.name, labels: [...entry.labels], values: [...entry.values]}));
2777
- 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, ...(numberFormats ? {formats: numberFormats.series} : {})});
2917
+ context.chartex.set(objectName, {spec: chartData.chartex, series, hasCategories: chartData.hasCategories, fill, labelColor, gridColor, font: context.fonts.body, palette, textSize, options: chartOptions, ...(numberFormats ? {formats: numberFormats.series} : {})});
2778
2918
  if (chartData.chartex.layoutId === 'regionMap') {
2779
2919
  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.`});
2780
2920
  }
@@ -2807,10 +2947,13 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2807
2947
  legendColor: labelColor,
2808
2948
  legendFontFace: context.fonts.body,
2809
2949
  legendFontSize: textSize / 100,
2810
- dataLabelColor: labelColor,
2950
+ dataLabelColor: markLabelColor,
2811
2951
  dataLabelFontSize: textSize / 100,
2812
2952
  showValue: false,
2813
- valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
2953
+ ...(circular ? {dataBorder: {color: normalizeHex(neutrals?.outline?.hex ?? `#${context.colors.background}`), pt: 0.75}} : {}),
2954
+ valGridLine: { color: gridColor, size: 1 },
2955
+ catAxisLineColor: axisLineColor,
2956
+ valAxisLineColor: axisLineColor,
2814
2957
  // Magnitude bars/columns/areas must not let Office auto-truncate a near-equal range.
2815
2958
  ...(chartData.spec.family === "category" && ["bar", "area"].includes(chartData.type) && chartData.spec.grouping !== "percentStacked" ? {
2816
2959
  ...(chartData.series.every(entry => entry.values.every(value => value == null || value >= 0)) ? {valAxisMinVal: 0} : {}),
@@ -2903,7 +3046,8 @@ function addTablePayload(slide, authoredTable, region, context, options, path, p
2903
3046
  // FF-24c: engine chrome resolves from scheme roles (header accent1, body surface), so it follows the deck theme like a named fill.
2904
3047
  const fillReference = cellStyle.fill ?? (header ? 'accent1' : 'surface');
2905
3048
  const fillValue = schemeColorValue(fillReference, baseFill, context);
2906
- const inheritedText = textColorForFill(`#${baseFill}`, header ? "#FFFFFF" : `#${context.colors.text}`);
3049
+ // RR-96: header text is the role (bg1 or tx1) that reads better on the fill, as the preview chooses it (core readableSchemeColor).
3050
+ const inheritedText = header && context.roles ? readableSchemeColor(`#${baseFill}`, context.roles).hex : textColorForFill(`#${baseFill}`, header ? "#FFFFFF" : `#${context.colors.text}`);
2907
3051
  const baseColor = exportColor(cellStyle.color ?? inheritedText.replace(/^#/, ""), context, inheritedText.replace(/^#/, ""));
2908
3052
  // Default cell text follows the theme only with a theme-referenced fill: paired slot on a light or dark fill, light1/dark1 on an accent fill.
2909
3053
  const defaultText = cellStyle.color === undefined ? tableTextSchemeValue(fillValue, baseColor, context) : undefined;
@@ -3199,7 +3343,8 @@ function addCodePayload(slide, value, layout, region, context, path, options) {
3199
3343
  const highlight = codeHighlight(value, layout, context);
3200
3344
  context.codeTags.set(panelName,codeManifest(value,layout,group,highlight?.bands.length));
3201
3345
  const syntax = codeSyntax(value, layout, context, path, options);
3202
- slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', width: .75}, objectName:panelName});
3346
+ const palette = codePalette(context);
3347
+ slide.addShape('rect', {...region, fill: {color: codePanelColor(context).slice(1)}, line: {color: context.roles.neutrals?.codePanel ? context.colors.border : '334155', width: .75}, objectName:panelName});
3203
3348
  if (highlight) {
3204
3349
  const bodyPart = layout.parts.find(part => part.role === 'body');
3205
3350
  highlight.bands.forEach((band, bandIndex) => {
@@ -3223,7 +3368,7 @@ function addCodePayload(slide, value, layout, region, context, path, options) {
3223
3368
  const lineOptions = {
3224
3369
  ...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),
3225
3370
  ...nativeFontOptions(part.style),
3226
- color:part.role==='body'?(lineColors?.plain.slice(1)??'E5E7EB'):'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
3371
+ color:part.role==='body'?(lineColors?.plain.slice(1)??palette.plain.slice(1)):palette.keyword.slice(1),align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
3227
3372
  tabStops:tabStops.length?tabStops:undefined,objectName,
3228
3373
  };
3229
3374
  // 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.
@@ -3240,7 +3385,7 @@ function codeHighlight(value, layout, context) {
3240
3385
  if (!body?.fit) return undefined;
3241
3386
  const lines = codeHighlightLines(value.highlight, body.text).lines;
3242
3387
  if (!lines.length) return undefined;
3243
- return {marked: new Set(lines), numbers: codeLineNumbers(body.fit.sourceLines), bands: codeHighlightBands(body.fit.sourceLines, lines), colors: codeHighlightColors(context.colorScheme)};
3388
+ return {marked: new Set(lines), numbers: codeLineNumbers(body.fit.sourceLines), bands: codeHighlightBands(body.fit.sourceLines, lines), colors: codeHighlightColors(context.colorScheme, codePalette(context), codePanelColor(context))};
3244
3389
  }
3245
3390
 
3246
3391
  // Token ranges and palette for the code body, from core (the preview calls the same functions); undefined for plain code.
@@ -3249,14 +3394,23 @@ function codeSyntax(value, layout, context, path, options) {
3249
3394
  const language = typeof value?.language === 'string' ? value.language : layout.parts.find(part => part.role === 'language')?.text;
3250
3395
  if (!body || !language) return undefined;
3251
3396
  const tokens = tokenizeCode(body.text, language);
3252
- return tokens.length ? {tokens, palette: codeSyntaxPaletteForScheme(context.colorScheme)} : undefined;
3397
+ return tokens.length ? {tokens, palette: codePalette(context)} : undefined;
3398
+ }
3399
+
3400
+ // RR-96: the code panel, plain text, comment and syntax colors are the slide's own roles through its variant (core codeSyntaxPaletteForRoles, the
3401
+ // panel `roles.neutrals.codePanel`), written as scheme colors in the finished slide; an older core keeps the fixed palette.
3402
+ function codePalette(context) {
3403
+ return context.roles.neutrals?.codePanel ? codeSyntaxPaletteForRoles(context.roles) : codeSyntaxPaletteForScheme(context.colorScheme);
3404
+ }
3405
+ function codePanelColor(context) {
3406
+ return context.roles.neutrals?.codePanel?.hex ?? '#111827';
3253
3407
  }
3254
3408
 
3255
3409
  function addMetricPayload(slide,value,layout,context,path,options) {
3256
3410
  if (!layout) throw new OPFPptxError('missing-metric-layout','Metric export requires coordinated core metric geometry.',{path});
3257
3411
  const group=String(context.metricTags.size+1),manifest=metricManifest(value,layout,group);
3258
3412
  // RR-07: a trend colours the trend and delta text and adds one native arrow shape, from core's accepted geometry.
3259
- const trendMark=metricTrendMark(layout,{background:`#${normalizeHex(context.colors.background)}`});
3413
+ const trendMark=metricTrendMark(layout,{background:`#${normalizeHex(context.colors.background)}`,...(context.roles.neutrals?.trendNeutral ? {neutral: context.roles.neutrals.trendNeutral.hex} : {})});
3260
3414
  const partColor=part=>trendMark&&(part.role==='trend'||part.role==='delta')?trendMark.color.slice(1):part.role==='value'?context.colors.accent:context.textColor;
3261
3415
  for (const [partIndex,part] of layout.parts.entries()) {
3262
3416
  const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
@@ -3385,7 +3539,7 @@ function addMediaPayload(slide, presentation, value, region, path, context, opti
3385
3539
  }
3386
3540
  slide.addShape("rect", {x: region.x, y: region.y, w: region.w, h: region.h, fill: {color: context.colors.surface}, line: {color: context.colors.border, width: 0.75}, hyperlink, objectName: names.frame});
3387
3541
  slide.addShape("ellipse", {x: icon.x / 96, y: icon.y / 96, w: 72 / 96, h: 72 / 96, fill: {color: context.colors.accent}, line: {transparency: 100}, hyperlink: hyperlink && {url: source}, objectName: names.badge});
3388
- slide.addShape("triangle", {x: (icon.x + 28) / 96, y: (icon.y + 22) / 96, w: 28 / 96, h: 28 / 96, rotate: 90, fill: {color: "FFFFFF"}, line: {transparency: 100}, hyperlink: hyperlink && {url: source}, objectName: names.play});
3542
+ slide.addShape("triangle", {x: (icon.x + 28) / 96, y: (icon.y + 22) / 96, w: 28 / 96, h: 28 / 96, rotate: 90, fill: {color: context.roles ? normalizeHex(readableSchemeColor(`#${context.colors.accent}`, context.roles).hex) : "FFFFFF"}, line: {transparency: 100}, hyperlink: hyperlink && {url: source}, objectName: names.play});
3389
3543
  addMeasuredPayloadText(slide, mediaCaption(value), {...box, y: icon.y + 72 + 20, height: 50}, context, options,
3390
3544
  {path, fontSize: 18, fontFamily: context.fonts.body, fontWeight: 600, align: "center", color: context.colors.mutedText, objectName: `OPF media ${path} caption`, media: true, keepEmpty: true, hyperlink});
3391
3545
  }
@@ -4058,6 +4212,13 @@ function referenceId(reference) {
4058
4212
 
4059
4213
  // A solid color or pattern background color is a ColorRef (hex, `var:` variable, colour-scheme slot or role), resolved
4060
4214
  // as for table fills and run colours. Roles resolve through the colour scheme alone: the background cannot depend on itself.
4215
+ // A background that is one color: a color string, a theme slot, or a solid fill (not a picture, gradient or pattern).
4216
+ function isSingleColorBackground(value) {
4217
+ if (value === undefined || value === null) return true;
4218
+ if (typeof value === 'string') return true;
4219
+ return isPlainObject(value) && (value.type === 'solid' || value.type === 'theme');
4220
+ }
4221
+
4061
4222
  function resolveBackgroundColorRef(entry, colorScheme, variables = {}) {
4062
4223
  return resolveColorRefValue(entry, {colorScheme, colors: {}, variables});
4063
4224
  }
@@ -4125,6 +4286,12 @@ async function normalizePptxZip(raw, context) {
4125
4286
  });
4126
4287
  }
4127
4288
 
4289
+ // RR-96: slide colors that are scheme colors become schemeClr first, so every fingerprint of a shape's properties (card frames, picture
4290
+ // manifests) records the final XML.
4291
+ for (const path of Object.keys(entries)) {
4292
+ const match = /^ppt\/slides\/slide(\d+)\.xml$/.exec(path), entry = match && context.slideSchemeColors[Number(match[1]) - 1];
4293
+ if (entry) entries[path] = encodeText(writeSchemeColors(decodeText(entries[path]), entry.table, entry.keep));
4294
+ }
4128
4295
  attachCodeTags(entries, context.codeTags);
4129
4296
  attachMetricTags(entries,context.metricTags,context.metricDescriptions);
4130
4297
  attachCardTags(entries,context.cardTags);
@@ -4150,7 +4317,7 @@ async function normalizePptxZip(raw, context) {
4150
4317
  if(!context.chartHeadings.has(name))continue;
4151
4318
  const id=frame.match(/<c:chart\b[^>]*\br:id="([^"]+)"/)?.[1],chartPart=relationships.get(id)?.path;
4152
4319
  if(!chartPart)throw new OPFPptxError('packaging-failed','Generated chart relationship is missing.');
4153
- const {heading,labelColor,spec,textSize}=context.chartHeadings.get(name);
4320
+ const {heading,labelColor,markLabelColor,spec,textSize}=context.chartHeadings.get(name);
4154
4321
  if(heading!==undefined)writeChartCategoryHeading(entries,chartPart,heading);
4155
4322
  entries[chartPart]=encodeText(omitEmptyNumberPoints(applyChartConstruct(decodeText(entries[chartPart]),spec)));
4156
4323
  const highlight=context.chartHeadings.get(name).highlight;
@@ -4168,7 +4335,7 @@ async function normalizePptxZip(raw, context) {
4168
4335
  entries[chartPart]=encodeText(applyChartTextSize(decodeText(entries[chartPart]),textSize));
4169
4336
  // RR-35: data labels last, so their own text colours (contrast inside a mark) are not normalized away.
4170
4337
  const {options:chartOptions,kind:optionKind,pointCount}=context.chartHeadings.get(name);
4171
- if(chartOptions?.dataLabels)entries[chartPart]=encodeText(applyDataLabels(decodeText(entries[chartPart]),{resolved:chartOptions,kind:optionKind,palette:context.chartPalettes.get(name),labelColor,font:context.chartFonts.get(name)?.body??'Arial',textSize,pointCount,highlight}));
4338
+ if(chartOptions?.dataLabels)entries[chartPart]=encodeText(applyDataLabels(decodeText(entries[chartPart]),{resolved:chartOptions,kind:optionKind,palette:context.chartPalettes.get(name),labelColor:markLabelColor,font:context.chartFonts.get(name)?.body??'Arial',textSize,pointCount,highlight}));
4172
4339
  // RR-54: the columns' number formats, after the labels: caches, label and value-axis formats, then the workbook cells.
4173
4340
  const numberFormats=context.chartHeadings.get(name).numberFormats;
4174
4341
  if(numberFormats){
@@ -4238,6 +4405,7 @@ async function normalizePptxZip(raw, context) {
4238
4405
  try {
4239
4406
  placeImages(entries, context.images, context.imageOverlays, {
4240
4407
  metadataFor: (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), fitImage,
4408
+ finish: (part, xml) => { const slide = /^ppt\/slides\/slide(\d+)\.xml$/.exec(part), entry = slide && context.slideSchemeColors[Number(slide[1]) - 1]; return entry ? writeSchemeColors(xml, entry.table, entry.keep) : xml; },
4241
4409
  // The key of the bytes the package finally holds: the picture's orientation is normalized when the parts are written.
4242
4410
  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; },
4243
4411
  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 }); }
@@ -4259,12 +4427,17 @@ async function normalizePptxZip(raw, context) {
4259
4427
  stampFurnitureImageKeys(entries, context.furnitureManifests, context.furnitureImages, parseRelationships);
4260
4428
  // Charts and notes follow the language and fonts of the slide they belong to.
4261
4429
  context.partSlides = new Map();
4430
+ context.styleSlides = new Map();
4262
4431
  for (const part of Object.keys(entries)) {
4263
4432
  const slide = /^ppt\/slides\/slide(\d+)\.xml$/.exec(part);
4264
4433
  if (!slide) continue;
4265
4434
  context.partSlides.set(part, Number(slide[1]) - 1);
4266
4435
  for (const relationship of parseRelationships(entries, part).values()) {
4267
4436
  if (/\/(?:chart|chartEx|notesSlide)$/.test(relationship.type)) context.partSlides.set(relationship.path, Number(slide[1]) - 1);
4437
+ // The chart style and color style parts of a native chart take the scheme colors of their slide.
4438
+ if (/\/(?:chart|chartEx)$/.test(relationship.type) && entries[relationship.path]) {
4439
+ for (const related of parseRelationships(entries, relationship.path).values()) if (/\/chartStyle$/.test(related.type)) context.styleSlides.set(related.path, Number(slide[1]) - 1);
4440
+ }
4268
4441
  }
4269
4442
  }
4270
4443
  const output = {};
@@ -4410,12 +4583,15 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
4410
4583
  xml = themeMasterBulletFonts(xml).replace(/<p:cSld\b([^>]*)>/, (_, attributes) =>
4411
4584
  `<p:cSld${attributes.replace(/\s+name="[^"]*"/, '')} name="pptx.gallery">`);
4412
4585
  }
4586
+ if (context.clrMap && /^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(path)) xml = writeColorMap(xml, context.clrMap);
4413
4587
  if (context.masterBackground && path === 'ppt/slideMasters/slideMaster1.xml') xml = writeMasterBackground(xml, context.masterBackground);
4414
4588
  if (context.masterBackground && /^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(path)) xml = inheritLayoutBackground(xml);
4415
4589
  if (path === 'ppt/presentation.xml') xml = presentationInSchemaOrder(xml);
4416
4590
  if (path === 'ppt/theme/theme1.xml') xml = writeThemeColors(xml, {colors: context.themeColors, schemeName: context.schemeName, themeName: context.themeName});
4417
4591
  if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) {
4418
4592
  xml = writeLinkSentinels(xml, context.linkSentinels);
4593
+ const override = context.clrMapOverrides.get(path);
4594
+ if (override) xml = xml.replace('<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>', `<p:clrMapOvr><a:overrideClrMapping${Object.entries(override).map(([role, slot]) => ` ${role}="${slot}"`).join('')}/></p:clrMapOvr>`);
4419
4595
  const fill = context.backgroundFills.get(path);
4420
4596
  if (typeof fill === 'string') xml = xml.replace(/<p:bg>[\s\S]*?<\/p:bg>/, `<p:bg><p:bgPr>${fill}<a:effectLst/></p:bgPr></p:bg>`);
4421
4597
  else if (fill?.image) {
@@ -4567,6 +4743,11 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
4567
4743
  xml = nativeTitleAutofit(nativeBodyDefaults(xml, context.nativeBodies), context.nativeTitles);
4568
4744
  }
4569
4745
  // theme1.xml: FF-24 colors and names above, then FF-07 fonts here; they touch disjoint elements.
4746
+ // RR-96: literal colors that are scheme colors become schemeClr (with lumMod/lumOff for the derived neutrals).
4747
+ if (/^ppt\/(?:slides\/slide|charts\/chart(?:Ex)?|charts\/style)\d+\.xml$/.test(path)) {
4748
+ const entry = context.slideSchemeColors[context.partSlides.get(path) ?? context.styleSlides.get(path)];
4749
+ if (entry) xml = writeSchemeColors(xml, entry.table, entry.keep);
4750
+ } else if (/^ppt\/(?:slideMasters\/slideMaster|slideLayouts\/slideLayout)\d+\.xml$/.test(path)) xml = writeSchemeColors(xml, context.deckSchemeColors.table, context.deckSchemeColors.keep);
4570
4751
  if (context.scriptFonts) xml = partScriptFonts(path, xml, context.scriptFonts, context.partSlides.get(path) ?? 0);
4571
4752
  else if (/^ppt\/theme\/theme\d+\.xml$/.test(path)) xml = themeEastAsianFromLatin(xml);
4572
4753
  if (/^ppt\/(?:slides\/slide|notesSlides\/notesSlide)\d+\.xml$/.test(path)) xml = stripRunScriptFonts(xml);
@@ -18,14 +18,29 @@ export const THEME_SLOTS = Object.freeze([
18
18
  ['hyperlink', 'hlink'], ['followedHyperlink', 'folHlink'],
19
19
  ]);
20
20
 
21
- // Slide content reaches the theme through the master clrMap. The vendored
22
- // master maps bg1=lt1, tx1=dk1, bg2=lt2, tx2=dk2 and every other slot to itself.
23
- const CONTENT_VALUES = Object.freeze({
24
- dark1: 'tx1', light1: 'bg1', dark2: 'tx2', light2: 'bg2',
25
- accent1: 'accent1', accent2: 'accent2', accent3: 'accent3',
26
- accent4: 'accent4', accent5: 'accent5', accent6: 'accent6',
27
- hyperlink: 'hlink', followedHyperlink: 'folHlink',
28
- });
21
+ // Slide content reaches the theme through the clrMap: the master's, and a slide's own override (RR-96). With the default map
22
+ // bg1=lt1, tx1=dk1, bg2=lt2, tx2=dk2 and every other slot maps to itself; a variant swaps or re-points them (core ColorMap, whose
23
+ // slots are named dark1, light1, dark2, light2 and accent1 to accent6).
24
+ const DEFAULT_MAP = Object.freeze({bg1: 'light1', tx1: 'dark1', bg2: 'light2', tx2: 'dark2', accents: ['accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6']});
25
+ const FIXED_VALUES = Object.freeze({hyperlink: 'hlink', followedHyperlink: 'folHlink'});
26
+ // Which scheme role a document color reference prefers when two roles map to one slot (light2 and dark2 variants map bg1 and bg2 to it).
27
+ const PREFERRED_ROLE = Object.freeze({background: 'bg1', surface: 'bg2', text: 'tx1', textSecondary: 'tx2'});
28
+ // The accent a color role names: the variant's first, second and third accent (the scheme's own order by default).
29
+ const ACCENT_ROLE_INDEX = Object.freeze({primary: 0, secondary: 1, accent: 2});
30
+
31
+ /**
32
+ * The slide-content scheme value (tx1, bg1, bg2, tx2, accent3, hlink, ...) that reaches a theme slot through a clrMap, or undefined
33
+ * when no role of the map points at the slot (light1 under a light2 variant). `prefer` names the role to use when several qualify.
34
+ */
35
+ export function contentValueFor(slot, map = DEFAULT_MAP, prefer) {
36
+ if (Object.hasOwn(FIXED_VALUES, slot)) return FIXED_VALUES[slot];
37
+ if (/^accent[1-6]$/.test(slot)) {
38
+ const index = (map.accents ?? DEFAULT_MAP.accents).indexOf(slot);
39
+ return index >= 0 ? `accent${index + 1}` : undefined;
40
+ }
41
+ const roles = ['bg1', 'tx1', 'bg2', 'tx2'].filter(role => map[role] === slot);
42
+ return roles.includes(prefer) ? prefer : roles[0];
43
+ }
29
44
  // PptxGenJS 4.0.1 createColorElement accepts only these scheme values (pptxgenjs-plus accepts more; the set is kept
30
45
  // so exported colours stay as they were).
31
46
  const VENDOR_VALUES = new Set(['tx1', 'tx2', 'bg1', 'bg2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6']);
@@ -111,14 +126,18 @@ export function schemeColorValue(reference, hex, context, {vendor = false} = {})
111
126
  const deck = context.themeColors;
112
127
  if (!resolved || !deck) return undefined;
113
128
  let slots;
129
+ const map = context.roles?.map;
114
130
  if (SLOT_NAMES.has(name)) slots = [name];
115
- else if (name === 'background') slots = [backgroundSlot(context.backgroundDefinition)].filter(slot => SLOT_NAMES.has(slot));
131
+ // RR-96: the slide's own roles come first (a variant puts bg1, tx1, bg2 and tx2 on other slots), then the earlier slot candidates.
132
+ else if (name === 'background') slots = [map?.bg1, map?.bg2, backgroundSlot(context.backgroundDefinition)].filter(slot => SLOT_NAMES.has(slot));
133
+ else if (map && ACCENT_ROLE_INDEX[name] !== undefined) slots = [map.accents?.[ACCENT_ROLE_INDEX[name]]].filter(Boolean);
134
+ else if (map && PREFERRED_ROLE[name]) slots = [map[PREFERRED_ROLE[name]], ...(ROLE_SLOTS[name] ?? [])];
116
135
  else slots = ROLE_SLOTS[name] ?? [];
117
136
  const effective = themeSlotColors(context.colorScheme);
118
137
  for (const slot of slots) {
119
138
  if (deck[slot] !== resolved || effective[slot] !== resolved) continue;
120
- const value = CONTENT_VALUES[slot];
121
- if (!vendor || VENDOR_VALUES.has(value)) return value;
139
+ const value = contentValueFor(slot, context.roles?.map, PREFERRED_ROLE[name]);
140
+ if (value && (!vendor || VENDOR_VALUES.has(value))) return value;
122
141
  }
123
142
  return undefined;
124
143
  }
@@ -131,7 +150,9 @@ function backgroundOpacity(definition) {
131
150
  /** Scheme value of a theme-slot slide background, or undefined when the background stays literal. */
132
151
  export function schemeBackgroundValue(definition, context) {
133
152
  const slot = backgroundSlot(definition);
134
- return slot ? schemeColorValue(slot, context.colors?.background, context) : undefined;
153
+ if (slot) return schemeColorValue(slot, context.colors?.background, context) ?? schemeColorValue('background', context.colors?.background, context);
154
+ // RR-96: no background of its own (or none named): the slide's bg1 through its variant's map.
155
+ return definition === undefined ? schemeColorValue('background', context.colors?.background, context) : undefined;
135
156
  }
136
157
 
137
158
  /** Native slide background fill for a theme-slot background, or null when it must stay literal. */
@@ -142,30 +163,15 @@ export function schemeBackgroundFill(definition, context) {
142
163
  return `<a:solidFill>${alpha === 100000 ? `<a:schemeClr val="${value}"/>` : `<a:schemeClr val="${value}"><a:alpha val="${alpha}"/></a:schemeClr>`}</a:solidFill>`;
143
164
  }
144
165
 
145
- // The exporter picks default text for contrast with the slide background:
146
- // light1 on a dark background, else the scheme text (dark1); muted text is
147
- // dark2/light2. When the background itself is a theme reference, the matching
148
- // text slot is the semantic pair, so a PowerPoint theme switch moves both.
149
- const TEXT_PAIRS = Object.freeze({
150
- light1: {text: 'dark1', muted: 'dark2'}, light2: {text: 'dark1', muted: 'dark2'},
151
- dark1: {text: 'light1', muted: 'light2'}, dark2: {text: 'light1', muted: 'light2'},
152
- });
153
-
154
166
  /**
155
- * Scheme values for the exporter's default text and muted text on this slide.
156
- * A role gets a scheme value only when the slide background is an opaque theme
157
- * reference to a light or dark slot, the slide has no scheme override, and the
158
- * resolved default color is exactly the paired slot in the deck theme.
167
+ * Scheme values for the exporter's default text and muted text on this slide: the roles tx1 and tx2 of the slide's map (RR-96). A dark
168
+ * slide is the dark variant's map with light text through tx1 and tx2, not hand-inverted colors; the values are used only where the
169
+ * resolved color is exactly what the map's slot holds in the deck theme, and a slide without its own scheme.
159
170
  */
160
171
  export function defaultTextSchemeValues(context) {
161
- const background = schemeBackgroundValue(context.backgroundDefinition, context);
162
- if (!background || backgroundOpacity(context.backgroundDefinition) !== 1) return {};
163
- const pair = TEXT_PAIRS[backgroundSlot(context.backgroundDefinition)];
164
- if (!pair) return {};
165
172
  const values = {};
166
- for (const role of ['text', 'muted']) {
167
- const hex = role === 'text' ? context.colors?.text : context.colors?.mutedText;
168
- const value = schemeColorValue(pair[role], hex, context);
173
+ for (const [role, name, hex] of [['text', 'text', context.colors?.text], ['muted', 'textSecondary', context.colors?.mutedText]]) {
174
+ const value = schemeColorValue(name, hex, context);
169
175
  if (value) values[role] = value;
170
176
  }
171
177
  return values;
@@ -173,16 +179,17 @@ export function defaultTextSchemeValues(context) {
173
179
 
174
180
  /**
175
181
  * Scheme value for the exporter's default text in a table cell whose fill is a
176
- * theme reference (`fillValue`, from schemeColorValue). A light or dark fill (bg1,
177
- * bg2, tx1, tx2) pairs with the opposite text slot (dark1 on bg, light1 on tx).
178
- * An accent fill has contrast-selected text, which stays a theme reference only
179
- * when the selection is exactly the deck light1 or dark1. Anything else, and any
182
+ * theme reference (`fillValue`, from schemeColorValue). A fill on the background
183
+ * side (bg1, bg2) pairs with the map's text slot, a text-side fill (tx1, tx2) with its background slot,
184
+ * and an accent fill has contrast-selected text, which stays a theme reference only
185
+ * when the selection is exactly one of those two slots. Anything else, and any
180
186
  * fill that is not a theme reference, keeps literal text.
181
187
  */
182
188
  export function tableTextSchemeValue(fillValue, hex, context) {
183
189
  if (!fillValue) return undefined;
190
+ const map = context.roles?.map ?? DEFAULT_MAP;
184
191
  const light = /^bg[12]$/.test(fillValue), dark = /^tx[12]$/.test(fillValue);
185
- const candidates = light ? ['dark1'] : dark ? ['light1'] : /^accent[1-6]$/.test(fillValue) ? ['light1', 'dark1'] : [];
192
+ const candidates = light ? [map.tx1] : dark ? [map.bg1] : /^accent[1-6]$/.test(fillValue) ? [map.bg1, map.tx1] : [];
186
193
  for (const slot of candidates) {
187
194
  const value = schemeColorValue(slot, hex, context, {vendor: true});
188
195
  if (value) return value;
@@ -190,6 +197,66 @@ export function tableTextSchemeValue(fillValue, hex, context) {
190
197
  return undefined;
191
198
  }
192
199
 
200
+ // ---------------------------------------------------------------------------
201
+ // RR-96: scheme-derived colors in the finished parts
202
+
203
+ const hex6Of = value => sixDigitHex(value);
204
+
205
+ /**
206
+ * The colors of one slide that are scheme colors, keyed by RRGGBB: the roles tx1, bg1, tx2 and bg2 and the accents through the slide's
207
+ * map, then every neutral core derived (border, gridline, axis line, chart text, outline, neutral trend, code panel, code text and code
208
+ * comment), each with the scheme value to write and its luminance transform (`lumMod`, `lumOff`, in thousandths of a percent).
209
+ * A color that two entries share keeps the first (plain roles before accents before neutrals). Empty for a slide with its own color scheme
210
+ * (its colors stay literal) or one whose roles carry no map (a core older than the variants).
211
+ */
212
+ export function schemeColorTable(context) {
213
+ const table = new Map();
214
+ const map = context.roles?.map;
215
+ if (!map || context.schemeOverride) return table;
216
+ const slots = themeSlotColors(context.colorScheme);
217
+ const roleHex = role => /^accent([1-6])$/.test(role) ? slots[(map.accents ?? [])[Number(role.slice(6)) - 1]] : slots[map[role]];
218
+ const add = (hex, entry) => { const key = hex6Of(hex); if (key && !table.has(key)) table.set(key, entry); };
219
+ for (const role of ['tx1', 'bg1', 'tx2', 'bg2']) if (slots[map[role]]) add(slots[map[role]], {value: role});
220
+ (map.accents ?? []).forEach((slot, index) => { if (slots[slot]) add(slots[slot], {value: `accent${index + 1}`}); });
221
+ for (const neutral of Object.values(context.roles.neutrals ?? {})) {
222
+ if (!neutral?.slot || !/^(?:tx[12]|bg[12]|accent[1-6])$/.test(neutral.slot)) continue;
223
+ // A neutral that is a plain role (no luminance transform) is that role only where the scheme's slot has its color: on a slide with a literal
224
+ // background the outline is bg1 by name but the literal's color.
225
+ if (neutral.lumMod === undefined && neutral.lumOff === undefined && hex6Of(neutral.hex) !== roleHex(neutral.slot)) continue;
226
+ add(neutral.hex, {value: neutral.slot, ...(neutral.lumMod !== undefined ? {lumMod: neutral.lumMod} : {}), ...(neutral.lumOff !== undefined ? {lumOff: neutral.lumOff} : {})});
227
+ }
228
+ return table;
229
+ }
230
+
231
+ /**
232
+ * Rewrite the literal `a:srgbClr` colors of a finished slide, chart or master part that are scheme colors to `a:schemeClr`: the role with its
233
+ * `lumMod` and `lumOff` for a derived neutral, keeping any child the literal carried (an alpha). `table` is {@link schemeColorTable}; `keep` holds the
234
+ * RRGGBB colors the author wrote literally on the slide, which stay literal even when a scheme color happens to equal them, and a literal that
235
+ * already carries its own luminance transform is left alone.
236
+ */
237
+ export function writeSchemeColors(xml, table, keep = new Set()) {
238
+ if (!table.size) return xml;
239
+ return xml.replace(/<a:srgbClr val="([0-9A-Fa-f]{6})"\s*(?:\/>|>([\s\S]*?)<\/a:srgbClr>)/g, (match, hex, inner = '') => {
240
+ const key = hex.toUpperCase(), entry = table.get(key);
241
+ if (!entry || keep.has(key) || /<a:(?:lumMod|lumOff|tint|shade|satMod)\b/.test(inner)) return match;
242
+ const transforms = `${entry.lumMod !== undefined ? `<a:lumMod val="${entry.lumMod}"/>` : ''}${entry.lumOff !== undefined ? `<a:lumOff val="${entry.lumOff}"/>` : ''}`;
243
+ return `<a:schemeClr val="${entry.value}">${transforms}${inner}</a:schemeClr>`;
244
+ });
245
+ }
246
+
247
+ /** The RRGGBB colors written literally in a slide's authored content (and the document's variables), which a scheme color must not take over. */
248
+ export function authoredLiteralColors(...values) {
249
+ const found = new Set();
250
+ const visit = value => {
251
+ if (typeof value === 'string') { for (const match of value.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})(?:[0-9a-f]{2})?\b/gi)) found.add(sixDigitHex(`#${match[1]}`)); }
252
+ else if (Array.isArray(value)) value.forEach(visit);
253
+ else if (value && typeof value === 'object') Object.values(value).forEach(visit);
254
+ };
255
+ values.forEach(visit);
256
+ found.delete(undefined);
257
+ return found;
258
+ }
259
+
193
260
  // ---------------------------------------------------------------------------
194
261
  // Import
195
262
 
@@ -267,6 +334,30 @@ export function recoverTheme({themeName, colors, majorFont, minorFont}, {themes,
267
334
  return colorMatch || fontMatch ? {id: theme.id} : {unverified: theme.id};
268
335
  }
269
336
 
337
+ // ---------------------------------------------------------------------------
338
+ // RR-96: the color map back to a variant
339
+
340
+ const MAP_BACKGROUNDS = Object.freeze({'lt1,dk1,lt2,dk2': 'light1', 'lt2,dk1,lt2,dk2': 'light2', 'dk1,lt1,dk2,lt2': 'dark1', 'dk2,lt1,dk2,lt2': 'dark2'});
341
+ const ACCENT_KEYS = Object.freeze(['accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6']);
342
+
343
+ /**
344
+ * The variant a PowerPoint color map stands for (`mapping`: bg1, tx1, bg2, tx2 as lt1, dk1, lt2, dk2, and the six accents; a missing attribute is
345
+ * the default). `design.variant` as authored is the background slot alone (`"dark1"`) unless the accents are re-pointed, which needs the object form
346
+ * `{background, accents}`. Returns `{variant}` (the default map is `light1` with no accent order, written only when `explicit`, a slide
347
+ * override back to the default), `{} ` for the default map, and `{unsupported: true}` for a map that is no variant (bg1 on an accent, text on a light and
348
+ * background on a light slot, a repeated accent): the caller reports it and imports nothing.
349
+ */
350
+ export function variantFromColorMap(mapping, {explicit = false} = {}) {
351
+ const at = (key, fallback) => (typeof mapping?.[key] === 'string' ? mapping[key] : fallback);
352
+ const background = MAP_BACKGROUNDS[[at('bg1', 'lt1'), at('tx1', 'dk1'), at('bg2', 'lt2'), at('tx2', 'dk2')].join(',')];
353
+ const accents = ACCENT_KEYS.map(key => at(key, key));
354
+ const permutation = accents.length === 6 && new Set(accents).size === 6 && accents.every(slot => ACCENT_KEYS.includes(slot));
355
+ if (!background || !permutation) return {unsupported: true};
356
+ const reordered = accents.some((slot, index) => slot !== ACCENT_KEYS[index]);
357
+ if (!reordered && background === 'light1' && !explicit) return {};
358
+ return {variant: reordered ? {background, accents} : background};
359
+ }
360
+
270
361
  // ---------------------------------------------------------------------------
271
362
  // Slide master
272
363
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-pptx",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -66,7 +66,7 @@
66
66
  "dependencies": {
67
67
  "@node-projects/jszip": "4.3.0",
68
68
  "@openpresentation/gallery": "^3.0.0",
69
- "@openpresentation/opf": "^0.22.1",
69
+ "@openpresentation/opf": "^0.23.0",
70
70
  "@rgrove/parse-xml": "4.2.3",
71
71
  "fast-xml-parser": "^5.8.0",
72
72
  "fflate": "^0.8.3",
@@ -74,7 +74,7 @@
74
74
  "sharp": "0.35.5"
75
75
  },
76
76
  "peerDependencies": {
77
- "@openpresentation/opf-render": "^0.22.1"
77
+ "@openpresentation/opf-render": "^0.23.0"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "@openpresentation/opf-render": {
@@ -98,7 +98,7 @@
98
98
  "@expo-google-fonts/roboto": "0.4.3",
99
99
  "@expo-google-fonts/roboto-mono": "0.4.2",
100
100
  "@expo-google-fonts/tinos": "0.4.2",
101
- "@openpresentation/opf-render": "0.22.1",
101
+ "@openpresentation/opf-render": "0.23.0",
102
102
  "@resvg/resvg-js": "2.6.2",
103
103
  "esbuild": "0.28.2",
104
104
  "playwright": "1.64.0"