@openpresentation/opf-editor 0.20.4 → 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
@@ -110,6 +110,8 @@ Chart options (RR-35): `@openpresentation/opf-editor/chart-options` edits a char
110
110
 
111
111
  Chart highlight (FA-14): the same module edits `chart.highlight`: `setChartOptions(editor, chartPath, { highlight: { series: ["Revenue"], categories: ["Q3"] } })` replaces each listed part (an empty list removes it, `null` removes the field), refuses a name the chart does not have, and writes only what the chart type can highlight (core `chartOptionSupport(...).highlight`). `readChartOptions(chart, document)` returns the `choices` (plotted series and category labels, resolved through the document for a dataset chart), and the panel lists them as two groups of checkboxes ("Highlight series", "Highlight categories").
112
112
 
113
+ Chart colors (RR-96): the same module edits `chart.colors`, the colors by position, as one undoable patch: `setChartOptions(editor, chartPath, { colors: ["accent3", "#7B241C"] })` replaces the list (an entry is a hex color, a scheme slot such as `accent3`, which keeps following the deck and is written to PowerPoint as a theme color, or a `var:` color variable; `null` or `""` leaves that position to the deck palette, written as the palette color itself when a later position is set; `null` for the whole value removes the field; at most 24). `readChartOptions` reports what a position means (`colors.by`: the plotted series, the slices or rows of a pie, doughnut, treemap or funnel, or the colors a histogram, pareto or waterfall draws with; `colors.labels`) and the list as written (`state.colors`). The chart options panel gains a "Colors" group: one row per position with the deck's accent swatches, a "Default" choice and a hex field, plus "Reset to deck palette". The swatches come from `readSchemeSlots` (`/color-slots`), the deck's resolved scheme at a slide. The opf-render preview draws `chart.colors` once opf-render#256 lands; the document, Undo and the PowerPoint export carry it now.
114
+
113
115
  ```js
114
116
  import { createCanvasEditor } from '@openpresentation/opf-editor/canvas';
115
117
  import { saveDocument } from '@openpresentation/opf-editor';
@@ -349,6 +351,8 @@ The playground's Content tab mounts the block-level and slide-structure actions
349
351
 
350
352
  `@openpresentation/opf-editor/design-options` edits the design-level settings that used to need All properties. Each call is one validated patch and one undo step, at the deck or on one slide with `slideIndex`; `null` removes a value so it is inherited again. `prepare...` forms return the patch without touching a session.
351
353
 
354
+ Color variants (RR-96, needs core 0.23): `@openpresentation/opf-editor/design-variants` edits `design.variant`, PowerPoint's Variants for the deck's one color scheme: a mapping over its twelve slots (never a second scheme) that says which slot is the background, and so the text and the alternate surface, plus an optional accent order. `setDesignVariant(editor, "dark1")` writes the slot name (so it keeps following the scheme), at the deck or on one slide with `{ slideIndex }`; `{ accents: [...] }` reorders the six accents and keeps the background; `null` removes the variant (a slide then follows the deck); `{ clearSlideOverrides: true }` also removes slides' own variants that would hide a deck change, and the result's `shadowed` lists the slides whose own variant or theme-slot background still does. `readDesignVariant` reports the scope's own variant and the effective one (core's resolution, so a theme-slot background or a picture's `tone` counts) and `variantSwatches` the four variants (`light1`, `light2`, `dark1`, `dark2`) as colors built from the deck's own slots, like PowerPoint's gallery. The design controls gain a "Color variant" section with those four swatches, "Same as presentation" on a slide, and an accent order control; the editor imports core's `normalizeColorVariant`, so it needs core 0.23 (the peer floor moves with the release). The preview and the PowerPoint export read the variant from core's roles (the renderer and exporter releases that follow core 0.23); the editor only commits the patch.
355
+
352
356
  ```js
353
357
  import { setDesignOption, setOrganizationLogo, setHeaderFooterZone, insertZoneValue, insertZoneLogo, getDesignOption, designWarnings } from "@openpresentation/opf-editor/design-options";
354
358
 
@@ -431,9 +435,10 @@ setTableStyle(editor, "slides.4.blocks.1.table", "banded"); //
431
435
  mergeTableCells(editor, "slides.4.blocks.1.table", { section: "body", row: 1, column: 0 }, { colSpan: 3 });
432
436
  splitTableCell(editor, "slides.4.blocks.1.table", { section: "body", row: 1, column: 0 });
433
437
  setTableCellStyle(editor, "slides.4.blocks.1.table", [{ section: "header", column: 0 }], { fill: "accent", align: "center" });
438
+ setTableCellStyle(editor, "slides.4.blocks.1.table", { section: "body", row: 2, column: 3 }, { bold: true }); // a bold number cell keeps its column format and alignment; { bold: null } removes it
434
439
  ```
435
440
 
436
- Styles use scheme roles, so they follow the color scheme, and the renderers keep the text readable on any fill. A style sets the header fill (`theme`, `plain`, `accent`), banded rows and borders (`theme`, `none`, `horizontal`, `grid`); named presets are `theme`, `banded`, `grid`, `minimal` and `open`, and `theme` removes a previous style. A style owns only the fill and border fields of a cell: text color, alignment, padding, values and merges are kept. Merging never hides text: covered cells that hold text refuse with `merge-would-lose-content` unless you pass `join: true`, which joins the words into the anchor with a space and keeps run formatting. Other refusals are `merge-overlap` (the region crosses another merge), `invalid-table-span` (outside the table, or a header cell spanning into the body) and `table-cell-covered`. Splitting leaves the formerly covered cells as empty text. `parseTableCellPath` maps a canvas selection to a table and cell, and `describeTableCell` and `readTableStyle` read the current state.
441
+ Styles use scheme roles, so they follow the color scheme, and the renderers keep the text readable on any fill. A style sets the header fill (`theme`, `plain`, `accent`), banded rows and borders (`theme`, `none`, `horizontal`, `grid`); named presets are `theme`, `banded`, `grid`, `minimal` and `open`, and `theme` removes a previous style. A style owns only the fill and border fields of a cell: text color, alignment, bold, padding, values and merges are kept. To bold one cell, set `bold: true` on its style with `setTableCellStyle` (the panel's Bold checkbox does): the cell stays a plain value in a numeric column, so it keeps the column's number `format` and alignment, which rich runs would drop (needs core with `TableCellStyle.bold`). Merging never hides text: covered cells that hold text refuse with `merge-would-lose-content` unless you pass `join: true`, which joins the words into the anchor with a space and keeps run formatting. Other refusals are `merge-overlap` (the region crosses another merge), `invalid-table-span` (outside the table, or a header cell spanning into the body) and `table-cell-covered`. Splitting leaves the formerly covered cells as empty text. `parseTableCellPath` maps a canvas selection to a table and cell, and `describeTableCell` and `readTableStyle` read the current state.
437
442
 
438
443
  Table alt text (FA-27): `setTableAlt(editor, tablePath, { alt: "North America leads EMEA in Q4." })` writes the table's text alternative (`Table.alt`) as one undoable patch, like a chart's. `alt` is trimmed and an empty string or `null` removes it; `{ decorative: true }` writes the empty alt (decorative, and it wins over `alt`) and `{ decorative: false }` removes an empty alt. It works on a table that shows a shared dataset too, since the alt belongs to the table and not to its data. `readTableAlt(table)` gives `{ alt, decorative }` for a form and `prepareTableAlt` is the headless patch. The table section of the design controls has an "Alt text" field and a "Decorative (no alt text)" box, and find and replace searches `table.alt`. `validate` does not ask for a table alt, so the field is optional.
439
444
 
@@ -521,7 +526,7 @@ const selectionControls = createDesignControls(contentPanel, { editor, getSlideI
521
526
  controls.refresh(); // when the slide or the selection changes
522
527
  ```
523
528
 
524
- Sections: `look` (theme, color scheme, font scheme, language, this slide's layout), `background` (type, theme slot, solid color, gradient with its stops, image with fit, focus, alt text, recolor and overlay, any of the 54 patterns, opacity; a draft until Apply, with Remove), `header-footer` (choose header or footer and a zone, then every part the zone supports: a text box with an "Insert value" menu that puts the slide number, slide count, section, organization, speaker or deck name at the cursor as a `{{ }}` variable (Enter commits, Shift+Enter starts a new line), an image with an "Insert logo" action (a shape and, for several organizations, an organization; it writes the logo reference as the zone's image), social profiles, and the current or fixed date and format, with a summary of the zones in use), `brand` (the organization logo: one image for every shape, or each of the four shapes for both, light and dark backgrounds, for the organization you choose; which organization's logo and shape covers, sections and picture bullets draw, or none; picture bullets, accent font, watermark), `layout-options` (alignment, direction, primary chart, content box, default image fit), `info` (narrative, tone, audience, socials), `selection` (content type with a loss report, list levels, group of metrics, group and ungroup, image to background or watermark, replacement, chart type), `image` (the selected image block's fit, focus, shape, corner radius, border, opacity, recolor, overlay, aspect ratio and placement), `catalog` (reference findings and the explicit "Update from catalog"), `slide-content` (blocks to regions and back, design images back into the content) and `table` (style, merge, split, cell fill and alignment). The panel is native `details`, `fieldset`, `select`, checkbox and text controls with a label on each, so it works with the keyboard and with screen readers: groups open with Enter or Space, a select changes with the arrow keys, text fields commit on Enter or when you leave them, and results and refusals are announced in `role="status"` and `role="alert"` regions. A refused change (an invalid value, text a merge would hide) is explained, changes nothing, and puts the field back. "Applies to" switches the design controls between the whole presentation and the current slide, and a control shows when a slide value is "set on this slide" or "from the presentation". The panel offers only conversions that exist, shows what a conversion loses before you choose it, and lists why an unavailable one is unavailable.
529
+ Sections: `look` (theme, color scheme, font scheme, language, this slide's layout), `background` (type, theme slot, solid color, gradient with its stops, image with fit, focus, alt text, recolor and overlay, any of the 54 patterns, opacity; a draft until Apply, with Remove), `header-footer` (choose header or footer and a zone, then every part the zone supports: a text box with an "Insert value" menu that puts the slide number, slide count, section, organization, speaker or deck name at the cursor as a `{{ }}` variable (Enter commits, Shift+Enter starts a new line), an image with an "Insert logo" action (a shape and, for several organizations, an organization; it writes the logo reference as the zone's image), social profiles, and the current or fixed date and format, with a summary of the zones in use), `brand` (the organization logo: one image for every shape, or each of the four shapes for both, light and dark backgrounds, for the organization you choose; which organization's logo and shape covers, sections and picture bullets draw, or none; picture bullets, accent font, watermark), `layout-options` (alignment, direction, primary chart, content box, default image fit), `info` (narrative, tone, audience, socials), `selection` (content type with a loss report, list levels, group of metrics, group and ungroup, image to background or watermark, replacement, chart type), `image` (the selected image block's fit, focus, shape, corner radius, border, opacity, recolor, overlay, aspect ratio and placement), `catalog` (reference findings and the explicit "Update from catalog"), `slide-content` (blocks to regions and back, design images back into the content) and `table` (style, merge, split, cell fill, alignment and bold). The panel is native `details`, `fieldset`, `select`, checkbox and text controls with a label on each, so it works with the keyboard and with screen readers: groups open with Enter or Space, a select changes with the arrow keys, text fields commit on Enter or when you leave them, and results and refusals are announced in `role="status"` and `role="alert"` regions. A refused change (an invalid value, text a merge would hide) is explained, changes nothing, and puts the field back. "Applies to" switches the design controls between the whole presentation and the current slide, and a control shows when a slide value is "set on this slide" or "from the presentation". The panel offers only conversions that exist, shows what a conversion loses before you choose it, and lists why an unavailable one is unavailable.
525
530
 
526
531
  In React (or Svelte, or anything else) mount it into a ref and destroy it on unmount; it needs no framework runtime:
527
532
 
@@ -4,8 +4,11 @@
4
4
  // `setChartOptions` (src/chart-options.js), the canvas redraws, and Undo restores the chart. The panel shows only what
5
5
  // the selected chart type can show (core's support table), and hides itself when no chart is selected. On a combo chart
6
6
  // (FA-15) a "Series" group sets which series are lines and which lines use the secondary value axis, and a secondary axis
7
- // title field appears while a line uses that axis.
7
+ // title field appears while a line uses that axis. (RR-96) A "Colors" group sets the color of each series (or slice) from the deck's accent
8
+ // swatches or a hex color; a swatch writes the scheme slot, so the chart keeps following the deck.
8
9
  import { CHART_LABEL_CONTENT, CHART_LEGEND_POSITIONS, parseChartPath, readChartOptions, setChartOptions } from "./chart-options.js";
10
+ import { readSchemeSlots } from "./color-slots.js";
11
+ import { isColorRef } from "./background-options.js";
9
12
 
10
13
  const LEGEND_LABELS = { default: "Default", none: "None", top: "Top", bottom: "Bottom", left: "Left", right: "Right" };
11
14
  const CONTENT_LABELS = { category: "Category", value: "Value", percent: "Percent" };
@@ -46,6 +49,7 @@ export function createChartOptionsPanel(host, { editor, getSelectedPath, onStatu
46
49
  <label class="opf-co-row" data-row="position"><span>Label position</span><select id="${id}-position" aria-label="Label position" data-opf-chart-option="dataLabels.position"></select></label>
47
50
  <label class="opf-co-row" data-row="separator"><span>Separator</span><input type="text" id="${id}-separator" aria-label="Label separator" data-opf-chart-option="dataLabels.separator" autocomplete="off"></label>
48
51
  </div>
52
+ <fieldset class="opf-co-group opf-co-colors" data-group="colors"><legend>Colors</legend><div data-row="colors"></div><button type="button" class="opf-co-reset" data-opf-chart-option="colors.reset" aria-label="Reset chart colors to the deck palette">Reset to deck palette</button></fieldset>
49
53
  <div class="opf-co-group" data-group="highlight">
50
54
  <fieldset class="opf-co-highlight" data-part="series"><legend>Highlight series</legend><div class="opf-co-choices" data-choices="series"></div></fieldset>
51
55
  <fieldset class="opf-co-highlight" data-part="categories"><legend>Highlight categories</legend><div class="opf-co-choices" data-choices="categories"></div></fieldset>
@@ -54,11 +58,12 @@ export function createChartOptionsPanel(host, { editor, getSelectedPath, onStatu
54
58
  const $ = (selector) => root.querySelector(selector);
55
59
  const fields = {
56
60
  alt: $(`#${id}-alt`), decorative: $(`#${id}-decorative`), category: $(`#${id}-category`), value: $(`#${id}-value`), secondary: $(`#${id}-secondary`), legend: $(`#${id}-legend`), labels: $(`#${id}-labels`),
57
- series: $("[data-row='combo-series']"),
61
+ series: $("[data-row='combo-series']"), colors: $("[data-row='colors']"), colorsReset: $("[data-opf-chart-option='colors.reset']"),
58
62
  position: $(`#${id}-position`), separator: $(`#${id}-separator`), contents: [...root.querySelectorAll("[data-opf-chart-option^='dataLabels.content.']")],
59
63
  highlight: { series: $("[data-choices='series']"), categories: $("[data-choices='categories']") },
60
64
  };
61
65
  let chartPath;
66
+ let colorState = { entries: [], count: 0 };
62
67
 
63
68
  function refresh() {
64
69
  const path = parseChartPath(getSelectedPath?.() ?? "");
@@ -70,7 +75,7 @@ export function createChartOptionsPanel(host, { editor, getSelectedPath, onStatu
70
75
  return;
71
76
  }
72
77
  chartPath = path;
73
- const { fields: support, state, choices, combo } = readChartOptions(chart, editor.presentation);
78
+ const { fields: support, state, choices, combo, colors } = readChartOptions(chart, editor.presentation);
74
79
  root.hidden = false;
75
80
  const setValue = (input, value) => { if (input !== document.activeElement && input.value !== value) input.value = value; };
76
81
  setValue(fields.alt, state.alt);
@@ -145,6 +150,62 @@ export function createChartOptionsPanel(host, { editor, getSelectedPath, onStatu
145
150
  for (const input of list.querySelectorAll("input")) input.checked = state.highlight[part].includes(input.value);
146
151
  }
147
152
  $("[data-group='highlight']").hidden = $("[data-part='series']").hidden && $("[data-part='categories']").hidden;
153
+ refreshColors(colors, state.colors, chartPath);
154
+ }
155
+
156
+ // RR-96: one row per position of `chart.colors`: the deck's accent swatches, a "Default" choice and a hex field. The swatches are the
157
+ // deck's own colors (the resolved scheme of the chart's slide), and choosing one writes its slot name.
158
+ function refreshColors(positions, entries, path) {
159
+ colorState = { entries, count: positions.labels.length };
160
+ const slide = Number(/^slides\.(\d+)/.exec(path)?.[1] ?? 0);
161
+ const slots = readSchemeSlots(editor.presentation, { slideIndex: slide, catalogs: editor.catalogs });
162
+ const signature = JSON.stringify([positions.labels, positions.slots.map((slot) => slots[slot] ?? ""), entries]);
163
+ $("[data-group='colors']").hidden = positions.labels.length === 0;
164
+ fields.colorsReset.hidden = entries.length === 0;
165
+ if (fields.colors.dataset.signature === signature) return;
166
+ fields.colors.dataset.signature = signature;
167
+ fields.colors.replaceChildren(...positions.labels.map((name, index) => {
168
+ const entry = entries[index] ?? "";
169
+ const row = document.createElement("div");
170
+ row.className = "opf-co-row opf-co-color-row";
171
+ row.dataset.position = String(index);
172
+ const label = document.createElement("span");
173
+ label.textContent = name;
174
+ const group = document.createElement("span");
175
+ group.className = "opf-co-swatches";
176
+ group.setAttribute("role", "group");
177
+ group.setAttribute("aria-label", `${name} color`);
178
+ const choose = (value, text, color) => {
179
+ const button = document.createElement("button");
180
+ button.type = "button";
181
+ button.className = "opf-co-swatch";
182
+ button.dataset.opfChartOption = "colors.choose";
183
+ button.dataset.color = value;
184
+ button.setAttribute("aria-label", `${name}: ${text}`);
185
+ button.setAttribute("aria-pressed", String(entry === value));
186
+ if (color) button.style.background = color;
187
+ else button.textContent = "Default";
188
+ return button;
189
+ };
190
+ group.append(choose("", "deck palette", undefined), ...positions.slots.map((slot, k) => choose(slot, `accent ${k + 1}`, slots[slot])));
191
+ const hex = document.createElement("input");
192
+ hex.type = "text";
193
+ hex.className = "opf-co-hex";
194
+ hex.dataset.opfChartOption = "colors.hex";
195
+ hex.setAttribute("aria-label", `${name} hex color`);
196
+ hex.placeholder = "#RRGGBB";
197
+ hex.autocomplete = "off";
198
+ hex.value = entry && !positions.slots.includes(entry) ? entry : "";
199
+ row.append(label, group, hex);
200
+ return row;
201
+ }));
202
+ }
203
+
204
+ // The `colors` list with position `index` set to `value` ("" leaves it to the deck palette); other positions keep what the chart has.
205
+ function colorsWith(index, value) {
206
+ const next = Array.from({ length: Math.max(colorState.entries.length, index + 1) }, (_, k) => colorState.entries[k] ?? null);
207
+ next[index] = value || null;
208
+ return next;
148
209
  }
149
210
 
150
211
  function commit(change, message) {
@@ -175,6 +236,21 @@ export function createChartOptionsPanel(host, { editor, getSelectedPath, onStatu
175
236
  [fields.labels, "change", () => commit({ dataLabels: fields.labels.checked }, fields.labels.checked ? "Showing data labels. Undo restores the chart." : "Hid the data labels. Undo restores them.")],
176
237
  [fields.position, "change", () => commit({ dataLabels: { position: fields.position.value } }, "Moved the data labels. Undo restores them.")],
177
238
  [fields.separator, "change", () => commit({ dataLabels: { separator: fields.separator.value } }, "Changed the label separator. Undo restores it.")],
239
+ [fields.colors, "click", (event) => {
240
+ const button = event.target.closest?.("[data-opf-chart-option='colors.choose']");
241
+ if (!button) return;
242
+ const index = Number(button.closest("[data-position]").dataset.position);
243
+ commit({ colors: colorsWith(index, button.dataset.color) }, button.dataset.color ? "Changed a chart color. Undo restores it." : "Returned a chart color to the deck palette. Undo restores it.");
244
+ }],
245
+ [fields.colors, "change", (event) => {
246
+ const input = event.target.closest?.("[data-opf-chart-option='colors.hex']");
247
+ if (!input) return;
248
+ const index = Number(input.closest("[data-position]").dataset.position);
249
+ const value = input.value.trim();
250
+ if (value && !isColorRef(value)) { onStatus("Use a hex color such as #7B241C."); refresh(); return; }
251
+ commit({ colors: colorsWith(index, value) }, "Changed a chart color. Undo restores it.");
252
+ }],
253
+ [fields.colorsReset, "click", () => commit({ colors: null }, "Returned the chart colors to the deck palette. Undo restores them.")],
178
254
  ...["series", "categories"].map((part) => [fields.highlight[part], "change", () => {
179
255
  const names = checkedNames(part);
180
256
  commit({ highlight: { [part]: names } }, names.length ? `Highlighted ${names.join(", ")}. Undo restores the colors.` : `Cleared the ${part} highlight. Undo restores it.`);
@@ -6,6 +6,10 @@ export type ChartLabelPosition = "center" | "inside-end" | "inside-base" | "outs
6
6
 
7
7
  export declare const CHART_LEGEND_POSITIONS: readonly ["default", "none", "top", "bottom", "left", "right"];
8
8
  export declare const CHART_LABEL_CONTENT: readonly ["category", "value", "percent"];
9
+ /** The most colors `chart.colors` takes. */
10
+ export declare const CHART_COLORS_MAX: 24;
11
+ /** The scheme slots a chart color is offered from, in palette order. */
12
+ export declare const CHART_COLOR_SLOTS: readonly ["accent1", "accent2", "accent3", "accent4", "accent5", "accent6"];
9
13
 
10
14
  /** What a chart type can show (core's `chartOptionSupport`). */
11
15
  export interface ChartOptionFields {
@@ -26,6 +30,14 @@ export interface ChartOptionFields {
26
30
  }
27
31
  /** The names a highlight can take on a chart, from its resolved data (empty when the data does not resolve). */
28
32
  export interface ChartHighlightChoices { series: string[]; categories: string[] }
33
+ /** What the positions of `chart.colors` mean on a chart (RR-96): its plotted series, its slices or rows, or the colors a histogram, pareto or waterfall draws with. */
34
+ export interface ChartColorPositions {
35
+ by: "series" | "categories" | "colors";
36
+ /** One name per position (at most 24), in the order `chart.colors` is matched. */
37
+ labels: string[];
38
+ /** The scheme slots to offer as swatches. */
39
+ slots: readonly string[];
40
+ }
29
41
  /** A plotted series of a combo chart: drawn as columns ("bar") or as a line, on the primary or secondary value axis (FA-15). */
30
42
  export interface ChartComboSeriesState {
31
43
  name: string;
@@ -43,6 +55,8 @@ export interface ChartOptionState {
43
55
  dataLabels: { on: boolean; explicit: boolean; content: ChartLabelContent[]; position: ChartLabelPosition | "auto"; separator: string };
44
56
  /** The series and categories `chart.highlight` names (empty lists when it is absent). */
45
57
  highlight: { series: string[]; categories: string[] };
58
+ /** `chart.colors` as written, a ColorRef per position (empty when absent: the deck palette applies). */
59
+ colors: string[];
46
60
  }
47
61
  export interface ChartOptionChange {
48
62
  /** The text alternative: what the data shows. Trimmed; an empty string or null removes it. */
@@ -58,6 +72,11 @@ export interface ChartOptionChange {
58
72
  dataLabels?: boolean | null | { content?: ChartLabelContent[]; position?: ChartLabelPosition | "auto"; separator?: string };
59
73
  /** `null` removes the highlight; a list replaces that part (an empty list removes it) and the other part is kept. */
60
74
  highlight?: null | { series?: string[]; categories?: string[] };
75
+ /**
76
+ * RR-96: `chart.colors`, the colors by position. `null` removes it; a list replaces it: an entry is a hex color, a scheme color (`accent3`, which keeps following the
77
+ * deck) or `var:<id>`, and `null` or `""` leaves that position to the deck palette (a gap before a chosen color is written as the palette color itself). At most 24.
78
+ */
79
+ colors?: null | Array<string | null>;
61
80
  }
62
81
  export interface PreparedChartOptions {
63
82
  action: "chart-options";
@@ -70,6 +89,6 @@ export interface PreparedChartOptions {
70
89
  /** The chart path a selection path points at, or undefined. */
71
90
  export declare function parseChartPath(path: string): string | undefined;
72
91
  /** `combo` is present on a combo chart: its plotted series in data order. Pass the `document` for a dataset-backed chart. */
73
- export declare function readChartOptions(chart: unknown, presentation?: unknown): { target: unknown; fields: ChartOptionFields; choices: ChartHighlightChoices; state: ChartOptionState; combo?: { series: ChartComboSeriesState[] } };
92
+ export declare function readChartOptions(chart: unknown, presentation?: unknown): { target: unknown; fields: ChartOptionFields; choices: ChartHighlightChoices; colors: ChartColorPositions; state: ChartOptionState; combo?: { series: ChartComboSeriesState[] } };
74
93
  export declare function prepareChartOptions(presentation: unknown, chartPath: string, change: ChartOptionChange): PreparedChartOptions;
75
94
  export declare function setChartOptions(editor: EditorSession, chartPath: string, change: ChartOptionChange, meta?: Record<string, unknown>): EditorChange & { action: "chart-options"; chartPath: string; changed: boolean };
@@ -7,15 +7,21 @@
7
7
  // choice, and only a pie or doughnut can show percent. A highlight names plotted series and category labels (the
8
8
  // choices come from the chart's resolved data, so a dataset chart offers its dataset's columns and rows); a pie
9
9
  // highlights slices (categories), an area only series. Nothing here invents text. A combo chart (FA-15) also offers which series are lines (`line`), which lines use the secondary value
10
- // axis (`secondaryAxis`) and that axis's title.
11
- import { resolveChartData } from "@openpresentation/opf";
12
- import { chartOptionSupport, chartOptionTarget } from "@openpresentation/opf/composition";
10
+ // axis (`secondaryAxis`) and that axis's title. (RR-96) `colors` edits `chart.colors`, the series (or slice) colors by position, each a hex
11
+ // color, a scheme slot such as `accent3` (it keeps following the deck) or a `var:` color variable.
12
+ import { resolveChartData, resolveSlideContext } from "@openpresentation/opf";
13
+ import { chartOptionSupport, chartOptionTarget, chartSeriesPalette } from "@openpresentation/opf/composition";
13
14
  import { getValueAtPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
14
15
  import { checkedDocument, fail, same } from "./edit-helpers.js";
15
16
  import { checkFormat } from "./checks.js";
17
+ import { isColorRef } from "./background-options.js";
16
18
 
17
19
  export const CHART_LEGEND_POSITIONS = Object.freeze(["default", "none", "top", "bottom", "left", "right"]);
18
20
  export const CHART_LABEL_CONTENT = Object.freeze(["category", "value", "percent"]);
21
+ /** The most colors `chart.colors` takes (the schema's maxItems). */
22
+ export const CHART_COLORS_MAX = 24;
23
+ /** The scheme slots a chart color is offered from, in palette order: the deck's accents. */
24
+ export const CHART_COLOR_SLOTS = Object.freeze(["accent1", "accent2", "accent3", "accent4", "accent5", "accent6"]);
19
25
 
20
26
  const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
21
27
  const CONTENT_ORDER = CHART_LABEL_CONTENT;
@@ -52,6 +58,29 @@ function highlightChoices(chart, document) {
52
58
  return { series: resolved.columns.slice(first), categories: [...new Set(resolved.rows.map((row) => label(row[0])).filter(Boolean))] };
53
59
  }
54
60
 
61
+ // RR-96: what `chart.colors` positions mean on a chart. A series chart colors its plotted series, a pie, doughnut, treemap or funnel
62
+ // its slices or rows (one per data row, to the schema's 24), and a histogram, pareto or waterfall the colors it draws with.
63
+ const SERIES_KINDS = new Set(["bar", "line", "area", "scatter", "radar", "combo"]);
64
+ const ROW_KINDS = new Set(["pie", "doughnut", "treemap", "funnel"]);
65
+ function colorPositions(chart, target, document, combo) {
66
+ const kind = target?.kind;
67
+ const label = (cell) => (cell === null || cell === undefined ? "" : String(cell));
68
+ let by = "colors";
69
+ let labels = [];
70
+ if (SERIES_KINDS.has(kind)) {
71
+ by = "series";
72
+ labels = combo ? combo.map((entry) => entry.name) : highlightChoices(chart, document).series;
73
+ } else if (ROW_KINDS.has(kind)) {
74
+ by = "categories";
75
+ try {
76
+ const resolved = resolveChartData(chart, document);
77
+ if (resolved?.ok) labels = resolved.rows.map((row) => label(row[0]));
78
+ } catch { /* unresolved data: no positions to name */ }
79
+ }
80
+ if (!labels.length) { by = "colors"; labels = CHART_COLOR_SLOTS.map((_, index) => `Color ${index + 1}`); }
81
+ return { by, labels: labels.slice(0, CHART_COLORS_MAX).map((text, index) => text || `Color ${index + 1}`), slots: CHART_COLOR_SLOTS };
82
+ }
83
+
55
84
  const names = (value) => (Array.isArray(value) ? [...new Set(value.filter((entry) => typeof entry === "string"))] : []);
56
85
 
57
86
  /**
@@ -85,7 +114,10 @@ export function readChartOptions(chart, presentation) {
85
114
  // FA-15: a combo chart's plotted series, each drawn as columns ("bar") or as a line, on the primary or secondary value axis.
86
115
  ...(combo ? { combo: { series: combo } } : {}),
87
116
  choices: highlightChoices(chart, presentation),
117
+ colors: colorPositions(chart, target, presentation, combo),
88
118
  state: {
119
+ // RR-96: `chart.colors` as written (a ColorRef per position); [] when absent, so the deck palette applies.
120
+ colors: Array.isArray(chart?.colors) ? chart.colors.filter((entry) => typeof entry === "string") : [],
89
121
  // FA-09: the text alternative. `decorative` is the empty alt, a reviewed choice, not a missing one.
90
122
  alt: typeof chart?.alt === "string" ? chart.alt : "",
91
123
  decorative: chart?.alt === "",
@@ -142,7 +174,7 @@ function comboFields(chart, change, document) {
142
174
  }
143
175
 
144
176
  // The desired value of each field (undefined removes it), from the chart and a patch.
145
- function desired(chart, change, support, choices, current, document) {
177
+ function desired(chart, change, support, choices, current, document, parts) {
146
178
  const out = {};
147
179
  const combo = support.axisTitles.secondary === true;
148
180
  if (change.line !== undefined || change.secondaryAxis !== undefined) {
@@ -201,12 +233,46 @@ function desired(chart, change, support, choices, current, document) {
201
233
  out.highlight = Object.keys(next).length ? next : undefined;
202
234
  }
203
235
  }
236
+ if (change.colors !== undefined) out.colors = chartColors(change.colors, document, parts);
204
237
  return out;
205
238
  }
206
239
 
240
+ // RR-96: the `chart.colors` list to write (undefined removes the field). A position left to the deck palette (null or "") is only
241
+ // expressible as the palette color itself, so a gap before a chosen color is filled with the accent slot (the palette's own color for
242
+ // the first six; later ones are its tints, written as that hex), and trailing gaps are dropped, which keeps the palette.
243
+ function chartColors(wanted, document, parts) {
244
+ if (wanted === null) return undefined;
245
+ if (!Array.isArray(wanted)) throw fail("invalid-chart-option", "colors is a list of colors by position, or null to remove it.", { colors: wanted });
246
+ if (wanted.length > CHART_COLORS_MAX) throw fail("invalid-chart-option", `A chart takes at most ${CHART_COLORS_MAX} colors.`, { count: wanted.length });
247
+ const entries = wanted.map((entry, index) => {
248
+ if (entry === null || entry === undefined || entry === "") return undefined;
249
+ if (!isColorRef(entry)) throw fail("invalid-chart-option", `Color ${index + 1} must be a hex color such as #7B241C, a scheme color such as accent3, or var:<id>.`, { index, color: entry });
250
+ return /^#/.test(entry) ? entry.toUpperCase() : entry;
251
+ });
252
+ while (entries.length && entries[entries.length - 1] === undefined) entries.pop();
253
+ if (!entries.length) return undefined;
254
+ let palette;
255
+ return entries.map((entry, index) => {
256
+ if (entry !== undefined) return entry;
257
+ if (index < CHART_COLOR_SLOTS.length) return CHART_COLOR_SLOTS[index];
258
+ palette ??= paletteOf(document, parts);
259
+ return palette[index];
260
+ });
261
+ }
262
+
263
+ // The deck palette of the slide a chart is on (`chartSeriesPalette` of its resolved color scheme), for positions past the six accents.
264
+ function paletteOf(document, parts) {
265
+ let scheme = {};
266
+ try {
267
+ const slide = parts[0] === "slides" ? Number(parts[1]) : 0;
268
+ scheme = resolveSlideContext(document, Number.isInteger(slide) ? slide : 0, {}).resolved?.colorScheme ?? {};
269
+ } catch { /* the engine default palette */ }
270
+ return chartSeriesPalette(scheme);
271
+ }
272
+
207
273
  /**
208
274
  * Prepare one validated patch for a change to the chart's options. `change` is
209
- * `{ alt?, decorative?, axisTitles?: { category?, value?, secondary? }, legend?, dataLabels?, highlight?, line?, secondaryAxis? }`: `alt` is the text alternative (trimmed; empty or `null`
275
+ * `{ alt?, decorative?, axisTitles?: { category?, value?, secondary? }, legend?, dataLabels?, highlight?, colors?, line?, secondaryAxis? }`: `alt` is the text alternative (trimmed; empty or `null`
210
276
  * removes it), `decorative: true` writes the empty alt (and wins over `alt`), `decorative: false` removes an empty alt; `axisTitles` entries are strings (empty removes a title),
211
277
  * `legend` is `"default"` (remove the field), `"none"`, `"top"`, `"bottom"`, `"left"` or `"right"`, and `dataLabels` is
212
278
  * `true`, `false` (or `null`, which removes the field), or `{ content?, position?, separator? }` merged over the current labels, and
@@ -214,13 +280,15 @@ function desired(chart, change, support, choices, current, document) {
214
280
  * chart does not have is refused) and the other part is kept. On a combo chart (FA-15), `line` lists the series drawn as lines and
215
281
  * `secondaryAxis` the lines on the secondary value axis, by name; at least one series stays columns and at least one is a line, `line`
216
282
  * is written only when it differs from the default (the last series), and the secondary axis title goes when no series uses that axis.
283
+ * `colors` (RR-96) is `null` (remove the field) or a list of colors by position (a hex color, a scheme color such as `accent3` that keeps following the deck, or
284
+ * `var:<id>`; `null` or `""` leaves that position to the deck palette), at most 24; it replaces the whole list, and trailing positions left to the palette are dropped.
217
285
  * Fields the chart type cannot show are never written. The document is not modified.
218
286
  */
219
287
  export function prepareChartOptions(presentation, chartPath, change) {
220
288
  const before = checkFormat(presentation);
221
289
  const { parts, chart } = chartAt(presentation, chartPath);
222
290
  const read = readChartOptions(chart, presentation);
223
- const wanted = desired(chart, change, read.fields, read.choices, read.state, presentation);
291
+ const wanted = desired(chart, change, read.fields, read.choices, read.state, presentation, parts);
224
292
  const patches = [];
225
293
  for (const [key, value] of Object.entries(wanted)) {
226
294
  const path = opfPathToJsonPointer([...parts, key]);
@@ -0,0 +1,12 @@
1
+ import type { Catalog } from "@openpresentation/opf";
2
+
3
+ /** The four background slots a variant maps to the background: `light1`, `light2`, `dark1`, `dark2`. */
4
+ export declare const VARIANT_BACKGROUND_SLOTS: readonly ["light1", "light2", "dark1", "dark2"];
5
+ /** The six accent slots, in the scheme's own order. */
6
+ export declare const ACCENT_SLOTS: readonly ["accent1", "accent2", "accent3", "accent4", "accent5", "accent6"];
7
+ export type VariantBackgroundSlot = (typeof VARIANT_BACKGROUND_SLOTS)[number];
8
+ export type AccentSlot = (typeof ACCENT_SLOTS)[number];
9
+ /** Slot name to `#RRGGBB`; a slot the resolved scheme leaves out is absent. */
10
+ export type SchemeSlots = Partial<Record<VariantBackgroundSlot | AccentSlot, string>>;
11
+ /** The deck's resolved color scheme slots at `slideIndex` (default 0), for swatches. Never throws: an unresolvable document gives `{}`. */
12
+ export declare function readSchemeSlots(presentation: unknown, options?: { slideIndex?: number; catalogs?: readonly Catalog[] }): SchemeSlots;
@@ -0,0 +1,31 @@
1
+ // RR-96: the color scheme slots a picker offers as swatches, read from the scheme the document resolves for a slide. A slot is
2
+ // shown as its own color, so a swatch is what the deck draws, and a choice writes the slot name (`accent3`, `dark1`), never the hex,
3
+ // so it keeps following the deck if its scheme changes. Read only: no edit goes through here.
4
+ import { resolveSlideContext } from "@openpresentation/opf";
5
+
6
+ /** The four background slots a variant maps to the background, in the order PowerPoint's Variants gallery shows them. */
7
+ export const VARIANT_BACKGROUND_SLOTS = Object.freeze(["light1", "light2", "dark1", "dark2"]);
8
+ /** The six accent slots, in the scheme's own order. */
9
+ export const ACCENT_SLOTS = Object.freeze(["accent1", "accent2", "accent3", "accent4", "accent5", "accent6"]);
10
+
11
+ const HEX = /^#[0-9a-f]{6}$/i;
12
+
13
+ /**
14
+ * The hex color of each of `light1`, `light2`, `dark1`, `dark2` and `accent1` to `accent6` in the deck's resolved color scheme at
15
+ * `slideIndex` (default: the first slide; a slide has the deck's scheme unless it sets its own). A slot the scheme leaves out is
16
+ * absent. Never throws: an unresolvable document gives `{}`.
17
+ */
18
+ export function readSchemeSlots(presentation, { slideIndex = 0, catalogs } = {}) {
19
+ let scheme;
20
+ try {
21
+ const slides = Array.isArray(presentation?.slides) ? presentation.slides.length : 0;
22
+ scheme = resolveSlideContext(presentation, Math.min(Math.max(slideIndex, 0), Math.max(slides - 1, 0)), catalogs ? { catalogs } : {}).resolved?.colorScheme;
23
+ } catch {
24
+ return {};
25
+ }
26
+ const slots = {};
27
+ for (const name of [...VARIANT_BACKGROUND_SLOTS, ...ACCENT_SLOTS]) {
28
+ if (typeof scheme?.[name] === "string" && HEX.test(scheme[name])) slots[name] = scheme[name];
29
+ }
30
+ return slots;
31
+ }
@@ -41,6 +41,8 @@ import {
41
41
  setHeaderFooterZone,
42
42
  setOrganizationLogo,
43
43
  } from "./design-options.js";
44
+ import { ACCENT_SLOTS } from "./color-slots.js";
45
+ import { readDesignVariant, setDesignVariant, variantSwatches } from "./design-variants.js";
44
46
  import { createContentControls } from "./content-controls.js";
45
47
  import { IMAGE_EDGES, IMAGE_FITS, IMAGE_SHAPES, readImageTreatments, setImageTreatment } from "./image-options.js";
46
48
  import { applyCatalogUpdate, catalogRecordAt, checkCatalogUpdates, mergeCatalogs, moveToCustom, referenceFindings } from "./catalogs.js";
@@ -48,10 +50,11 @@ import { describeTableCell, mergeTableCells, parseTableCellPath, readTableAlt, r
48
50
  import { OPFEditorError } from "./index.js";
49
51
 
50
52
  /** The sections a panel can show. `selection` and `table` follow the current selection; the rest follow the deck or the current slide. */
51
- export const DESIGN_CONTROL_SECTIONS = Object.freeze(["look", "background", "header-footer", "brand", "layout-options", "info", "selection", "image", "table", "slide-content", "catalog"]);
53
+ export const DESIGN_CONTROL_SECTIONS = Object.freeze(["look", "variant", "background", "header-footer", "brand", "layout-options", "info", "selection", "image", "table", "slide-content", "catalog"]);
52
54
 
53
55
  const SECTION_TITLES = {
54
56
  look: "Look and language",
57
+ variant: "Color variant",
55
58
  background: "Background",
56
59
  image: "Selected image",
57
60
  catalog: "Catalog records",
@@ -431,7 +434,7 @@ export function createDesignControls(container, options = {}) {
431
434
  const scopeIndex = () => (state.scope === "slide" ? getSlide() : undefined);
432
435
  const scoped = (extra = {}) => (state.scope === "slide" ? { slideIndex: getSlide(), ...extra } : extra);
433
436
  const sourceNote = (scope, inherited) => (state.scope === "slide" ? (scope === "slide" ? "set on this slide" : inherited ? "from the presentation" : "default") : "");
434
- const scopeNeeded = sections.some((name) => ["look", "background", "header-footer", "brand", "layout-options"].includes(name));
437
+ const scopeNeeded = sections.some((name) => ["look", "variant", "background", "header-footer", "brand", "layout-options"].includes(name));
435
438
  let scopeField;
436
439
  if (scopeNeeded) {
437
440
  scopeField = selectField("scope", "Applies to", {
@@ -502,6 +505,75 @@ export function createDesignControls(container, options = {}) {
502
505
  });
503
506
  }
504
507
 
508
+ // --- color variant (RR-96) ----------------------------------------------------------------------
509
+
510
+ // PowerPoint's Variants gallery: four swatches built from the deck's own scheme slots (light1, light2, dark1, dark2) and, below
511
+ // them, the order of the accents. A choice writes the slot name (`design.variant`), so it keeps following the color scheme, and the
512
+ // preview comes from core's roles. Needs core 0.23 (`design.variant`).
513
+ if (built.variant) {
514
+ const { body } = built.variant;
515
+ const view = () => ({ ...scoped(), catalogs: catalogOptions().catalogs });
516
+ const SLOT_LABELS = { light1: "Light 1", light2: "Light 2", dark1: "Dark 1", dark2: "Dark 2" };
517
+ const gallery = h("div", { class: "opf-dc-variants", role: "radiogroup", "aria-label": "Color variant" });
518
+ const sameAsDeck = button("Same as presentation", () => run(() => setDesignVariant(editor, null, view()), "This slide follows the presentation's color variant."), { quiet: true });
519
+ const accentRows = ACCENT_SLOTS.map((_, index) => {
520
+ const field = selectField(`variant-accent-${index + 1}`, `Accent ${index + 1}`, {
521
+ onChange: (value) => {
522
+ const order = readDesignVariant(editor.presentation, view()).accents;
523
+ const from = order.indexOf(value);
524
+ const next = [...order];
525
+ // A permutation: the slot that was drawn as this accent takes the place of the one chosen.
526
+ [next[index], next[from]] = [order[from], order[index]];
527
+ run(() => setDesignVariant(editor, { accents: next }, view()), "Accent order changed.");
528
+ },
529
+ });
530
+ field.setOptions(ACCENT_SLOTS.map((slot, k) => ({ value: slot, label: `Accent ${k + 1} color (${slot})` })));
531
+ return field;
532
+ });
533
+ const resetAccents = button("Reset accent order", () => run(() => {
534
+ const current = readDesignVariant(editor.presentation, view());
535
+ return setDesignVariant(editor, { background: current.background, accents: ACCENT_SLOTS }, view());
536
+ }, "Accents are back in the color scheme's own order."), { quiet: true });
537
+ const accentGroup = h("fieldset", { class: "opf-dc-fieldset", "data-variant-accents": "" }, h("legend", {}, "Accent order"), ...accentRows.map((row) => row.wrap), resetAccents);
538
+ body.append(gallery, sameAsDeck, accentGroup);
539
+ const pick = (slot) => {
540
+ const current = readDesignVariant(editor.presentation, view());
541
+ // Keep a reordered accent order when only the background changes.
542
+ const accents = current.own?.accents;
543
+ return run(() => setDesignVariant(editor, accents ? { background: slot, accents } : slot, view()), (change) => {
544
+ const hidden = change.shadowed?.length ? ` ${change.shadowed.length === 1 ? "One slide keeps its own" : `${change.shadowed.length} slides keep their own`} background or variant.` : "";
545
+ return `Color variant set to ${SLOT_LABELS[slot]}.${hidden}`;
546
+ });
547
+ };
548
+ syncs.push(() => {
549
+ const current = readDesignVariant(editor.presentation, view());
550
+ const swatches = variantSwatches(editor.presentation, view());
551
+ const signature = JSON.stringify([swatches, state.scope]);
552
+ if (gallery.dataset.signature !== signature) {
553
+ gallery.dataset.signature = signature;
554
+ gallery.replaceChildren(...swatches.map((entry) => {
555
+ const chip = (color, className) => h("span", { class: className, style: color ? `background:${color}` : undefined, "aria-hidden": "true" });
556
+ return h("button", {
557
+ type: "button",
558
+ class: "opf-dc-variant",
559
+ role: "radio",
560
+ "aria-checked": String(entry.selected),
561
+ "data-variant": entry.slot,
562
+ "aria-label": `${SLOT_LABELS[entry.slot]}: ${entry.slot} background${entry.dark ? " (dark)" : ""}`,
563
+ title: `${SLOT_LABELS[entry.slot]}: ${entry.slot} background`,
564
+ style: entry.background ? `background:${entry.background};color:${entry.text ?? "inherit"}` : undefined,
565
+ onclick: () => pick(entry.slot),
566
+ }, h("span", { class: "opf-dc-variant-text", "aria-hidden": "true" }, "Aa"), h("span", { class: "opf-dc-variant-chips", "aria-hidden": "true" }, chip(entry.surface, "opf-dc-variant-chip"), ...entry.accents.map((color) => chip(color, "opf-dc-variant-chip"))));
567
+ }));
568
+ }
569
+ const note = current.scope === "default" ? "default" : state.scope === "slide" ? (current.inherited ? "from the presentation" : "set on this slide") : "";
570
+ gallery.setAttribute("aria-description", note);
571
+ sameAsDeck.hidden = !(state.scope === "slide" && current.own !== undefined);
572
+ accentRows.forEach((row, index) => row.set(current.accents[index]));
573
+ resetAccents.hidden = current.own?.accents === undefined;
574
+ });
575
+ }
576
+
505
577
  // --- background ---------------------------------------------------------------------------------
506
578
 
507
579
  if (built.background) {
@@ -1341,7 +1413,9 @@ export function createDesignControls(container, options = {}) {
1341
1413
  fill.setOptions(CELL_FILLS.map(([value, label]) => ({ value, label })));
1342
1414
  const align = selectField("cell-align", "Cell text alignment", { empty: "Default", onChange: (value) => run(() => setTableCellStyle(editor, dynamic.tablePath, dynamic.cell, { align: value === "" ? null : value }), "Cell alignment changed.") });
1343
1415
  align.setOptions(["left", "center", "right"].map((value) => ({ value, label: titleCase(value) })));
1344
- const cellBox = h("div", { class: "opf-dc-cell" }, cellLabel, columns.wrap, rows.wrap, join.wrap, h("div", { class: "opf-dc-actions" }, merge, split), fill.wrap, align.wrap);
1416
+ // RR-93: `bold` is a cell style field, so a number cell stays bold without rich runs and keeps its number format and alignment. Unchecking removes the field.
1417
+ const bold = checkField("cell-bold", "Bold cell text", { onChange: (checked) => run(() => setTableCellStyle(editor, dynamic.tablePath, dynamic.cell, { bold: checked ? true : null }), checked ? "Cell text is bold." : "Cell text is no longer bold.") });
1418
+ const cellBox = h("div", { class: "opf-dc-cell" }, cellLabel, columns.wrap, rows.wrap, join.wrap, h("div", { class: "opf-dc-actions" }, merge, split), fill.wrap, align.wrap, bold.wrap);
1345
1419
  // FA-27: the table's text alternative, like a chart's. It belongs to the table, so a dataset-backed table has it too.
1346
1420
  const alt = textField("table-alt", "Alt text", {
1347
1421
  help: "What the table shows: its point and the key numbers, not \"a table\". Optional, because a screen reader reads a table cell by cell. Press Enter to apply.",
@@ -1354,7 +1428,7 @@ export function createDesignControls(container, options = {}) {
1354
1428
  });
1355
1429
  const styleControls = [headerStyle.wrap, banding.wrap, borders.wrap, resetStyle, cellBox];
1356
1430
  body.append(summary, alt.wrap, decorative.wrap, ...styleControls);
1357
- dynamic.table = { details, summary, alt, decorative, styleControls, headerStyle, banding, borders, cellBox, cellLabel, columns, rows, merge, split, fill, align, join };
1431
+ dynamic.table = { details, summary, alt, decorative, styleControls, headerStyle, banding, borders, cellBox, cellLabel, columns, rows, merge, split, fill, align, bold, join };
1358
1432
  }
1359
1433
 
1360
1434
  // --- sync -----------------------------------------------------------------------------------------
@@ -1445,6 +1519,7 @@ export function createDesignControls(container, options = {}) {
1445
1519
  const fillValue = typeof state.style.fill === "string" ? state.style.fill : "";
1446
1520
  tableUi.fill.set(CELL_FILLS.some(([value]) => value === fillValue) ? fillValue : fillValue ? "(custom)" : "");
1447
1521
  tableUi.align.set(state.style.align ?? "");
1522
+ tableUi.bold.set(state.style.bold === true);
1448
1523
  }
1449
1524
  }
1450
1525
  }
@@ -0,0 +1,90 @@
1
+ import type { Catalog } from "@openpresentation/opf";
2
+ import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
3
+ import type { AccentSlot, VariantBackgroundSlot } from "./color-slots.js";
4
+
5
+ export { ACCENT_SLOTS, VARIANT_BACKGROUND_SLOTS } from "./color-slots.js";
6
+ export type { AccentSlot, VariantBackgroundSlot } from "./color-slots.js";
7
+
8
+ /** A variant of the deck's one color scheme: the slot that is the background and, optionally, the order of the accents (core's `ColorVariant`). */
9
+ export interface DesignVariant {
10
+ background: VariantBackgroundSlot;
11
+ /** A permutation of `accent1` to `accent6`: entry i is the slot drawn as accent i + 1 (primary, secondary, accent and the chart series colors follow it). */
12
+ accents?: AccentSlot[];
13
+ }
14
+ /** What `design.variant` takes: the background slot (the shorthand) or the object. */
15
+ export type DesignVariantInput = VariantBackgroundSlot | DesignVariant;
16
+ /** What the editor writes: the slot as a string, or the object only for a reordered accent order. */
17
+ export type StoredDesignVariant = VariantBackgroundSlot | { background: VariantBackgroundSlot; accents: AccentSlot[] };
18
+
19
+ /** For each background slot, the slots the variant maps to bg1, tx1, bg2 and tx2 (PowerPoint's color map), and whether it is dark. */
20
+ export declare const VARIANT_MAPS: Readonly<Record<VariantBackgroundSlot, { readonly bg1: string; readonly tx1: string; readonly bg2: string; readonly tx2: string; readonly dark: boolean }>>;
21
+
22
+ /** Validate a variant and return what to store (see {@link StoredDesignVariant}). Throws `invalid-variant` with a sentence a person can act on. */
23
+ export declare function normalizeVariant(value: DesignVariantInput): StoredDesignVariant;
24
+
25
+ export interface DesignVariantOptions {
26
+ /** One slide instead of the deck. */
27
+ slideIndex?: number;
28
+ /** Deck scope: also remove the slides' own variants that would hide the change. */
29
+ clearSlideOverrides?: boolean;
30
+ /** Catalogs the document's references resolve against (a session form uses the session's). */
31
+ catalogs?: readonly Catalog[];
32
+ /** Session change metadata (session form only). */
33
+ meta?: Record<string, unknown>;
34
+ }
35
+ export interface DesignVariantState {
36
+ /** What this scope's own `design.variant` says, normalized to the object; undefined when it sets none. */
37
+ own?: DesignVariant;
38
+ /** The slide's effective variant background, by core's resolution (a variant, a theme-slot background or a picture's tone, the deck's, the theme's, else light1). */
39
+ background: VariantBackgroundSlot;
40
+ /** The effective accent order (the six slots; the scheme's own order unless a variant reorders it). */
41
+ accents: AccentSlot[];
42
+ /** Where `own` comes from: "slide" (the slide sets it), "deck", or "default" (nothing sets one). */
43
+ scope: "slide" | "deck" | "default";
44
+ /** True when a slide takes its variant from the deck (it sets none itself). */
45
+ inherited: boolean;
46
+ }
47
+ export declare function readDesignVariant(presentation: unknown, options?: Pick<DesignVariantOptions, "slideIndex" | "catalogs">): DesignVariantState;
48
+
49
+ /** One of the four variants as a swatch built from the deck's own scheme slots. */
50
+ export interface VariantSwatch {
51
+ slot: VariantBackgroundSlot;
52
+ dark: boolean;
53
+ /** Hex colors of the slots the variant maps to its background, text and alternate surface; undefined when the scheme leaves a slot out. */
54
+ background?: string;
55
+ text?: string;
56
+ surface?: string;
57
+ /** The first three accents in the effective accent order. */
58
+ accents: Array<string | undefined>;
59
+ /** Whether it is the effective variant of the slide. */
60
+ selected: boolean;
61
+ }
62
+ /** The four variants (`light1`, `light2`, `dark1`, `dark2`) as swatches from the deck's scheme at a scope, like PowerPoint's Variants gallery. */
63
+ export declare function variantSwatches(presentation: unknown, options?: Pick<DesignVariantOptions, "slideIndex" | "catalogs">): VariantSwatch[];
64
+
65
+ export interface PreparedDesignVariant {
66
+ option: "variant";
67
+ scope: "deck" | "slide";
68
+ slideIndex?: number;
69
+ presentation: unknown;
70
+ patches: JsonPatchOperation[];
71
+ changed: boolean;
72
+ /** Slides whose own variant, or theme-slot or toned background, still hides a deck-level change. */
73
+ shadowed: number[];
74
+ }
75
+ /**
76
+ * The patch that sets the variant (a slot, `{ background, accents? }`, or `null` to remove it; `{ accents }` alone keeps the background)
77
+ * at the deck, or on one slide with `slideIndex`. The document is not modified.
78
+ */
79
+ export declare function prepareDesignVariant(presentation: unknown, value: DesignVariantInput | { accents: AccentSlot[] } | null, options?: Omit<DesignVariantOptions, "meta">): PreparedDesignVariant;
80
+ export interface DesignVariantChange extends Omit<EditorChange, "presentation" | "patches"> {
81
+ presentation: unknown;
82
+ patches: JsonPatchOperation[];
83
+ option: "variant";
84
+ scope: "deck" | "slide";
85
+ slideIndex?: number;
86
+ changed: boolean;
87
+ shadowed: number[];
88
+ }
89
+ /** Set the variant as one undoable transaction. */
90
+ export declare function setDesignVariant(editor: EditorSession, value: DesignVariantInput | { accents: AccentSlot[] } | null, options?: DesignVariantOptions): DesignVariantChange;
@@ -0,0 +1,180 @@
1
+ // Color variants (RR-96): `design.variant`, at the deck or on one slide. A variant is a mapping over the deck's one twelve-slot color
2
+ // scheme, as PowerPoint's color map is, never a second scheme: which slot is the background (and so the text and the alternate
3
+ // surface) and, optionally, the order of the accents. `light1`, `light2`, `dark1` and `dark2` are the four backgrounds PowerPoint's
4
+ // Variants gallery offers. A choice writes the slot name, so it keeps following the deck's color scheme. Every function has a
5
+ // `prepare…` form that returns the patch without touching a session and a session form that commits it as one undoable edit, like
6
+ // every other design option; the preview and the PowerPoint export read the variant from core, nothing is drawn here.
7
+ import { resolveSlideContext } from "@openpresentation/opf";
8
+ import { normalizeColorVariant } from "@openpresentation/opf/composition";
9
+ import { getValueAtPath, opfPathToJsonPointer } from "./index.js";
10
+ import { checkedDocument, designPatches, fail } from "./edit-helpers.js";
11
+ import { checkFormat } from "./checks.js";
12
+ import { ACCENT_SLOTS, VARIANT_BACKGROUND_SLOTS, readSchemeSlots } from "./color-slots.js";
13
+
14
+ export { ACCENT_SLOTS, VARIANT_BACKGROUND_SLOTS };
15
+ const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
16
+ const sameOrder = (accents) => accents.every((slot, index) => slot === ACCENT_SLOTS[index]);
17
+
18
+ /** For each background slot: the slots the variant maps to the text, the alternate surface and the secondary text (the schema's ColorVariant table). */
19
+ export const VARIANT_MAPS = Object.freeze({
20
+ light1: Object.freeze({ bg1: "light1", tx1: "dark1", bg2: "light2", tx2: "dark2", dark: false }),
21
+ light2: Object.freeze({ bg1: "light2", tx1: "dark1", bg2: "light2", tx2: "dark2", dark: false }),
22
+ dark1: Object.freeze({ bg1: "dark1", tx1: "light1", bg2: "dark2", tx2: "light2", dark: true }),
23
+ dark2: Object.freeze({ bg1: "dark2", tx1: "light1", bg2: "dark2", tx2: "light2", dark: true }),
24
+ });
25
+
26
+ /**
27
+ * Validate a variant and return what to store: the background slot as a string, or `{ background, accents }` only when the accents are
28
+ * reordered. Accepts a slot, `{ background, accents? }`; a full accent list in the scheme's own order is dropped (it says nothing).
29
+ */
30
+ export function normalizeVariant(value) {
31
+ const spec = typeof value === "string" ? { background: value } : value;
32
+ if (!isObject(spec)) throw fail("invalid-variant", `A variant is a background slot (${VARIANT_BACKGROUND_SLOTS.join(", ")}) or { background, accents }.`, { value });
33
+ const extra = Object.keys(spec).filter((key) => key !== "background" && key !== "accents");
34
+ if (extra.length) throw fail("invalid-variant", `A variant has a background and optionally accents, not ${extra.join(", ")}.`, { fields: extra });
35
+ if (!VARIANT_BACKGROUND_SLOTS.includes(spec.background)) throw fail("invalid-variant", `The variant background is one of ${VARIANT_BACKGROUND_SLOTS.join(", ")}.`, { background: spec.background });
36
+ if (spec.accents === undefined || spec.accents === null) return spec.background;
37
+ const accents = spec.accents;
38
+ if (!Array.isArray(accents) || accents.length !== ACCENT_SLOTS.length || new Set(accents).size !== ACCENT_SLOTS.length || accents.some((slot) => !ACCENT_SLOTS.includes(slot)))
39
+ throw fail("invalid-variant", "The accent order uses each of accent1 to accent6 once.", { accents });
40
+ return sameOrder(accents) ? spec.background : { background: spec.background, accents: [...accents] };
41
+ }
42
+
43
+ // Core's own normalization of a stored variant (the string shorthand or the object) to the object it resolves with.
44
+ const asObject = (stored) => normalizeColorVariant(stored);
45
+
46
+ function scopeOf(presentation, options) {
47
+ const index = options.slideIndex;
48
+ if (index === undefined) return { scope: "deck", base: [] };
49
+ if (!Number.isInteger(index) || !presentation.slides?.[index]) throw fail("slide-index-out-of-range", "Choose an existing slide for this option.", { slideIndex: index });
50
+ return { scope: "slide", base: ["slides", String(index)], slideIndex: index };
51
+ }
52
+
53
+ const ownVariant = (presentation, base) => {
54
+ const design = getValueAtPath(presentation, base.length ? [...base, "design"] : ["design"]);
55
+ return isObject(design) && design.variant !== undefined ? design.variant : undefined;
56
+ };
57
+
58
+ /**
59
+ * The variant at a scope, for a panel. `own` is what that scope's `design.variant` says (`{ background, accents? }`, undefined when
60
+ * it sets none), `background` and `accents` the slide's effective variant (core's resolution: a slide's variant, a theme-slot
61
+ * background, a picture or gradient `tone`, the deck's variant, the theme's, then the default; `accents` always has the six slots in
62
+ * order), `scope` where `own` comes from ("slide", "deck" or "default") and `inherited` whether a slide takes it from the deck.
63
+ */
64
+ export function readDesignVariant(presentation, options = {}) {
65
+ const { base } = scopeOf(presentation, options);
66
+ const own = ownVariant(presentation, base);
67
+ const deck = ownVariant(presentation, []);
68
+ let effective;
69
+ try {
70
+ const slide = options.slideIndex ?? 0;
71
+ effective = resolveSlideContext(presentation, Math.min(Math.max(slide, 0), Math.max((presentation.slides?.length ?? 1) - 1, 0)), options.catalogs ? { catalogs: options.catalogs } : {}).resolved?.roles?.variant;
72
+ } catch { /* older core or an unresolvable document: fall back to what the document says */ }
73
+ const fallback = asObject(own ?? deck) ?? { background: "light1" };
74
+ const shown = isObject(effective) && typeof effective.background === "string" ? effective : fallback;
75
+ const slideScope = options.slideIndex !== undefined;
76
+ return {
77
+ own: own === undefined ? undefined : asObject(own),
78
+ background: shown.background,
79
+ accents: Array.isArray(shown.accents) ? [...shown.accents] : [...ACCENT_SLOTS],
80
+ scope: own !== undefined ? (slideScope ? "slide" : "deck") : slideScope && deck !== undefined ? "deck" : "default",
81
+ inherited: slideScope && own === undefined,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * The four variants as swatches built from the deck's own scheme slots at a scope: for each of `light1`, `light2`, `dark1` and `dark2`
87
+ * the colors of its background, text, alternate surface and first three accents (in the effective accent order), and whether it is
88
+ * the effective one. A color the scheme leaves out is undefined.
89
+ */
90
+ export function variantSwatches(presentation, options = {}) {
91
+ const slots = readSchemeSlots(presentation, { slideIndex: options.slideIndex ?? 0, catalogs: options.catalogs });
92
+ const current = readDesignVariant(presentation, options);
93
+ return VARIANT_BACKGROUND_SLOTS.map((slot) => {
94
+ const map = VARIANT_MAPS[slot];
95
+ return {
96
+ slot,
97
+ dark: map.dark,
98
+ background: slots[map.bg1],
99
+ text: slots[map.tx1],
100
+ surface: slots[map.bg2],
101
+ accents: current.accents.slice(0, 3).map((accent) => slots[accent]),
102
+ selected: current.background === slot,
103
+ };
104
+ });
105
+ }
106
+
107
+ /** Slides whose own design hides a deck-level variant: their own `variant`, or a theme-slot background that decides the slide's variant (or a tone). */
108
+ function shadowedSlides(presentation) {
109
+ const hidden = [];
110
+ (presentation.slides ?? []).forEach((slide, index) => {
111
+ const design = slide?.design;
112
+ if (!isObject(design)) return;
113
+ const background = design.background;
114
+ const decides = design.variant !== undefined || background === "light1" || background === "light2" || background === "dark1" || background === "dark2" || (isObject(background) && (background.type === "theme" || background.tone !== undefined));
115
+ if (decides) hidden.push(index);
116
+ });
117
+ return hidden;
118
+ }
119
+
120
+ /**
121
+ * Compute the patch that sets the variant, at the deck or (with `slideIndex`) on one slide. `value` is a background slot, `{ background,
122
+ * accents? }`, or `null` to remove the variant at that scope (a slide then follows the deck). `{ accents }` alone keeps the scope's
123
+ * (or else the effective) background. Deck scope: `clearSlideOverrides` also removes the slides' own variants that would hide the
124
+ * change; `shadowed` lists the slides whose own variant or background would still hide it.
125
+ */
126
+ export function prepareDesignVariant(presentation, value, options = {}) {
127
+ if (!isObject(presentation)) throw fail("invalid-input", "Design variants need an OPF document object.");
128
+ if (value === undefined) throw fail("invalid-variant", "Pass a variant, or null to remove it.");
129
+ const before = checkFormat(presentation);
130
+ const { scope, base, slideIndex } = scopeOf(presentation, options);
131
+ let stored = null;
132
+ if (value !== null) {
133
+ let spec = value;
134
+ if (isObject(value) && value.background === undefined) {
135
+ const current = readDesignVariant(presentation, { slideIndex, catalogs: options.catalogs });
136
+ spec = { background: current.background, ...value };
137
+ }
138
+ stored = normalizeVariant(spec);
139
+ }
140
+ const patches = designPatches(presentation, base, { variant: stored });
141
+ let shadowed = [];
142
+ if (scope === "deck") {
143
+ shadowed = shadowedSlides(presentation);
144
+ if (options.clearSlideOverrides) {
145
+ for (const index of shadowed) {
146
+ if (presentation.slides[index].design?.variant !== undefined) patches.push({ op: "remove", path: opfPathToJsonPointer(["slides", String(index), "design", "variant"]) });
147
+ }
148
+ shadowed = shadowedSlides(applyRemovals(presentation, patches));
149
+ }
150
+ }
151
+ const next = checkedDocument(presentation, patches, before);
152
+ return { option: "variant", scope, ...(slideIndex !== undefined ? { slideIndex } : {}), presentation: next, patches, changed: patches.length > 0, shadowed };
153
+ }
154
+
155
+ function applyRemovals(presentation, patches) {
156
+ const copy = structuredClone(presentation);
157
+ for (const patch of patches) {
158
+ if (patch.op !== "remove") continue;
159
+ const match = /^\/slides\/(\d+)\/design\/variant$/.exec(patch.path);
160
+ if (match) delete copy.slides[Number(match[1])].design.variant;
161
+ }
162
+ return copy;
163
+ }
164
+
165
+ function checkEditor(editor) {
166
+ if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function")
167
+ throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
168
+ }
169
+
170
+ /** Set the variant as one undoable transaction. See {@link prepareDesignVariant}. */
171
+ export function setDesignVariant(editor, value, options = {}) {
172
+ checkEditor(editor);
173
+ const { meta = {}, ...rest } = options;
174
+ const prepared = prepareDesignVariant(editor.presentation, value, { catalogs: editor.catalogs, ...rest });
175
+ const { presentation, patches, ...summary } = prepared;
176
+ void presentation;
177
+ if (!prepared.changed) return { ...summary, presentation: editor.presentation, patches: [], inversePatches: [], validation: editor.validation };
178
+ const change = editor.applyPatch(patches, { ...meta, source: meta.source ?? "design-option", option: "variant", scope: prepared.scope });
179
+ return { ...change, ...summary };
180
+ }
@@ -62,7 +62,7 @@ export declare function describeTableCell(table: unknown, cell: TableCellAddress
62
62
  /** Merge `span.colSpan` x `span.rowSpan` cells from `cell`. `join` keeps the text of covered cells by joining it into the anchor; without it, covered text refuses with `merge-would-lose-content`. */
63
63
  export declare function prepareTableMerge(presentation: unknown, tablePath: string, cell: TableCellAddress, span: { colSpan?: number; rowSpan?: number }, options?: { join?: boolean }): PreparedTableChange;
64
64
  export declare function prepareTableSplit(presentation: unknown, tablePath: string, cell: TableCellAddress): PreparedTableChange;
65
- /** Merge style fields into the cells' styles (null removes a field, `style: null` clears). */
65
+ /** Merge style fields into the cells' styles (null removes a field, `style: null` clears). Fields include `fill`, `color`, `align`, `verticalAlign`, `padding`, `borders` and `bold` (boolean; keeps a numeric cell's number format and alignment, which rich runs would drop). */
66
66
  export declare function prepareTableCellStyle(presentation: unknown, tablePath: string, cells: TableCellAddress | TableCellAddress[], style: Record<string, unknown> | null): PreparedTableChange;
67
67
  /** Apply `{ header, banding, borders }` (missing fields mean theme/false) or a named preset: sets the header fill, banded-row fill and borders of every cell and clears them elsewhere; `theme` removes a previous style. */
68
68
  export declare function prepareTableStyle(presentation: unknown, tablePath: string, preset: keyof typeof TABLE_STYLE_PRESETS | Partial<TableStyle>): PreparedTableChange;
@@ -256,14 +256,16 @@ export function prepareTableSplit(presentation, tablePath, cell) {
256
256
 
257
257
  /**
258
258
  * Compute the patch that changes the style of the cells. `style` fields merge into each cell's style
259
- * (fill, color, align, verticalAlign, padding, borders); a `null` field removes it, and `style: null`
260
- * clears the whole style. Covered (merged) positions are skipped. A cell left with no style or spans
259
+ * (fill, color, bold, align, verticalAlign, padding, borders); a `null` field removes it, and `style: null`
260
+ * clears the whole style. `bold` is a boolean (RR-93): it bolds the whole cell and, unlike rich runs, keeps a
261
+ * numeric column's number `format` and alignment. Covered (merged) positions are skipped. A cell left with no style or spans
261
262
  * becomes a plain value again. Style is appearance only: values and spans are untouched.
262
263
  */
263
264
  export function prepareTableCellStyle(presentation, tablePath, cells, style) {
264
265
  const list = Array.isArray(cells) ? cells : [cells];
265
266
  if (!list.length) throw fail("table-cell-not-found", "Choose at least one cell.", {});
266
267
  if (style !== null && !isObject(style)) throw fail("invalid-table-style", "A cell style is an object, or null to clear it.", { style });
268
+ if (style && Object.hasOwn(style, "bold") && style.bold !== null && style.bold !== undefined && typeof style.bold !== "boolean") throw fail("invalid-table-style", "Cell bold is true or false (null removes it).", { bold: style.bold });
267
269
  return transaction(presentation, tablePath, (table) => {
268
270
  let skipped = 0;
269
271
  for (const cell of list) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.20.4",
3
+ "version": "0.23.0",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -114,6 +114,14 @@
114
114
  "types": "./dist/assets.d.ts",
115
115
  "default": "./dist/assets.js"
116
116
  },
117
+ "./design-variants": {
118
+ "types": "./dist/design-variants.d.ts",
119
+ "default": "./dist/design-variants.js"
120
+ },
121
+ "./color-slots": {
122
+ "types": "./dist/color-slots.d.ts",
123
+ "default": "./dist/color-slots.js"
124
+ },
117
125
  "./backgrounds": {
118
126
  "types": "./dist/background-options.d.ts",
119
127
  "default": "./dist/background-options.js"
@@ -266,11 +274,11 @@
266
274
  "@codemirror/state": "6.7.6",
267
275
  "@codemirror/view": "6.43.14",
268
276
  "@lezer/highlight": "1.2.5",
269
- "@openpresentation/opf": "^0.20.5",
277
+ "@openpresentation/opf": "^0.23.0",
270
278
  "jsonc-parser": "3.3.1"
271
279
  },
272
280
  "peerDependencies": {
273
- "@openpresentation/opf-render": "^0.20.4"
281
+ "@openpresentation/opf-render": "^0.23.0"
274
282
  },
275
283
  "peerDependenciesMeta": {
276
284
  "@openpresentation/opf-render": {
@@ -323,8 +331,8 @@
323
331
  "@expo-google-fonts/roboto-mono": "0.4.2",
324
332
  "@expo-google-fonts/stix-two-math": "0.4.0",
325
333
  "@expo-google-fonts/tinos": "0.4.2",
326
- "@openpresentation/opf-pptx": "^0.20.4",
327
- "@openpresentation/opf-render": "^0.20.4",
334
+ "@openpresentation/opf-pptx": "^0.23.0",
335
+ "@openpresentation/opf-render": "^0.23.0",
328
336
  "@resvg/resvg-js": "2.6.2",
329
337
  "axe-core": "4.14.0",
330
338
  "esbuild": "0.28.2",