@openpresentation/opf-render 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/charts.js CHANGED
@@ -1,15 +1,10 @@
1
- // Chart previews for the OPF chart type catalog (FF-22).
1
+ // Chart previews for the OPF chart types (FF-22).
2
2
  //
3
- // The core catalog keeps one chart type per Office chart construct and
4
- // deprecates the rest with a replacement id (`deprecation.replacedBy`,
5
- // `mappings.openxml`). The tables below are carried here so the preview stays
6
- // synchronous and self-contained; test/chart-catalog.mjs checks them against
7
- // the installed core catalog. Kept ids render the construct PowerPoint shows
8
- // for the exported chart; deprecated ids render exactly like their replacement;
9
- // any other id returns null so the caller keeps its legacy single-series preview.
10
- import { chartColorForFill, resolveTextStyle, textColorForFill, textWidthMeasurer } from "@openpresentation/opf/composition";
11
- // chartPaletteForFill ships with core RR-29 (opf#270); an older published core still loads and clamps each colour on its own.
12
- import * as opfComposition from "@openpresentation/opf/composition";
3
+ // OPF 0.15 makes chart.type an engine vocabulary: core's schema enum and CHART_TYPES, one type per Office chart construct.
4
+ // The table below is carried here so the preview stays synchronous and self-contained; test/chart-catalog.mjs checks it
5
+ // against core's CHART_TYPES. Each type renders the construct PowerPoint shows for the exported chart; any other value
6
+ // returns null so the caller keeps its legacy single-series preview.
7
+ import { chartHighlightColors, chartHighlightMarks, chartNumber, chartPaletteForFill, formatDataNumber, numberFormatError, resolveChartData, resolveTextStyle, textColorForFill, textWidthMeasurer } from "@openpresentation/opf/composition";
13
8
  import { drawAxisTitles, drawBarLabel, drawCenteredLabel, drawPointLabel, drawSliceLabel, labelString, outsideLabelReserve, reportOptionDiagnostics, reserveAxisTitles, resolveOptions } from "./chart-options.js";
14
9
 
15
10
  // Ordered series palette written by opf-pptx (`CHART_COLORS`), before the same
@@ -19,12 +14,34 @@ export const CHART_SERIES_COLORS = Object.freeze([
19
14
  "#F59E0B", "#EF4444", "#8B5CF6", "#14B8A6", "#0F172A", "#64748B"
20
15
  ]);
21
16
 
22
- // The series colours for a chart panel: core's `chartPaletteForFill` (contrast against the panel without letting two series merge)
23
- // when the installed core has it, else each colour clamped on its own.
17
+ // The series colours for a chart panel: core's `chartPaletteForFill` (contrast against the panel without letting two series merge).
24
18
  export function chartSeriesPalette(surface) {
25
- return typeof opfComposition.chartPaletteForFill === "function"
26
- ? opfComposition.chartPaletteForFill(surface, CHART_SERIES_COLORS)
27
- : CHART_SERIES_COLORS.map((color) => chartColorForFill(surface, color));
19
+ return chartPaletteForFill(surface, CHART_SERIES_COLORS);
20
+ }
21
+
22
+ // FA-14: chart.highlight. Core resolves which series and categories are named (chartHighlightMarks) and the two colours
23
+ // (chartHighlightColors); a highlighted mark takes the accent, every other mark the muted neutral. A chart without a
24
+ // highlight has no `c.highlight`, and every colour below is the palette colour it always was.
25
+ function highlightFor(c, resolved, spec, data) {
26
+ if (!resolved.highlight) return undefined;
27
+ const marks = chartHighlightMarks(resolved.highlight, { columns: data.columns, hasX: spec.kind === "scatter" && data.columns.length > 2, rows: data.rows });
28
+ if (!marks) return undefined;
29
+ return { ...marks, ...chartHighlightColors(c.surface, c.bound.design.colors.primary, c.labelColor) };
30
+ }
31
+
32
+ /** The colour of series `j` as a whole (a line, an area, a legend key): the accent when highlighted, else muted. */
33
+ function seriesColor(c, j) {
34
+ return c.highlight ? (c.highlight.series[j] ? c.highlight.accent : c.highlight.muted) : c.colors[j % c.colors.length];
35
+ }
36
+
37
+ /** The colour of the mark of series `j` at row `i`: highlighted when its series or its category is named. */
38
+ function markColor(c, j, i) {
39
+ return c.highlight ? (c.highlight.series[j] || c.highlight.categories[i] ? c.highlight.accent : c.highlight.muted) : c.colors[j % c.colors.length];
40
+ }
41
+
42
+ /** The colour of the pie or doughnut slice of row `i`. */
43
+ function sliceColor(c, i) {
44
+ return c.highlight ? (c.highlight.categories[i] ? c.highlight.accent : c.highlight.muted) : c.colors[i % c.colors.length];
28
45
  }
29
46
 
30
47
  const bar = (dir, grouping) => ({ kind: "bar", dir, grouping });
@@ -34,18 +51,18 @@ const radar = (style) => ({ kind: "radar", style, markers: style === "marker" })
34
51
 
35
52
  export const CHART_TYPES = Object.freeze({
36
53
  column: bar("col", "clustered"),
37
- "stacked-column-3x": bar("col", "stacked"),
38
- "100pct-stacked-column-3x": bar("col", "percentStacked"),
54
+ "stacked-column": bar("col", "stacked"),
55
+ "100pct-stacked-column": bar("col", "percentStacked"),
39
56
  bar: bar("bar", "clustered"),
40
- "stacked-bar-3x": bar("bar", "stacked"),
41
- "100pct-stacked-bar-3x": bar("bar", "percentStacked"),
57
+ "stacked-bar": bar("bar", "stacked"),
58
+ "100pct-stacked-bar": bar("bar", "percentStacked"),
42
59
  line: line("standard", false),
43
60
  "line-with-markers": line("standard", true),
44
- "stacked-line-3x": line("stacked", false),
45
- "stacked-line-with-markers-3x": line("stacked", true),
61
+ "stacked-line": line("stacked", false),
62
+ "stacked-line-with-markers": line("stacked", true),
46
63
  area: area("standard"),
47
- "stacked-area-3x": area("stacked"),
48
- "100pct-stacked-area-3x": area("percentStacked"),
64
+ "stacked-area": area("stacked"),
65
+ "100pct-stacked-area": area("percentStacked"),
49
66
  pie: { kind: "pie" },
50
67
  doughnut: { kind: "doughnut" },
51
68
  scatter: { kind: "scatter" },
@@ -58,54 +75,14 @@ export const CHART_TYPES = Object.freeze({
58
75
  "box-and-whisker": { kind: "box" },
59
76
  waterfall: { kind: "waterfall" },
60
77
  funnel: { kind: "funnel" },
61
- world: { kind: "map" }
78
+ world: { kind: "map" },
79
+ // FA-15: clustered columns with line series (markers), the lines optionally on a secondary value axis (core resolves the plan).
80
+ combo: { kind: "combo", dir: "col", grouping: "clustered", markers: true }
62
81
  });
63
82
 
64
- const variants = (base, target) => Object.fromEntries([base, `${base}-2x`, `${base}-3x`].map((id) => [id, target]));
65
- const suffixes = (base, target) => Object.fromEntries(["", "-2x", "-3x", "-4x", "-5x", "-6x"].map((suffix) => [`${base}${suffix}`, target]));
66
-
67
- // Deprecated core ids -> kept replacement (core spec/catalogs/chart-types `deprecation.replacedBy`).
68
- export const DEPRECATED_CHART_TYPES = Object.freeze({
69
- ...variants("100pct-bullet-bar", "100pct-stacked-bar-3x"),
70
- "100pct-progress-bar": "100pct-stacked-bar-3x",
71
- "100pct-stacked-bar-2x": "100pct-stacked-bar-3x",
72
- ...variants("100pct-bullet-column", "100pct-stacked-column-3x"),
73
- "100pct-stacked-column-2x": "100pct-stacked-column-3x",
74
- "100pct-stacked-area-2x": "100pct-stacked-area-3x",
75
- australia: "world",
76
- canada: "world",
77
- "united-kingdom": "world",
78
- "united-states": "world",
79
- "box-and-whisker-2x": "box-and-whisker",
80
- "box-and-whisker-3x": "box-and-whisker",
81
- ...variants("bullet-bar", "bar"),
82
- "clustered-bar-2x": "bar",
83
- ...variants("bullet-column", "column"),
84
- "clustered-column": "column",
85
- ...suffixes("dot-plot", "scatter"),
86
- dumbbell: "scatter",
87
- "line-2x": "line",
88
- "line-3x": "line",
89
- "line-with-high-low": "line",
90
- ...suffixes("sparkline", "line"),
91
- "line-with-high-low-and-markers": "line-with-markers",
92
- "line-with-markers-2x": "line-with-markers",
93
- "line-with-markers-3x": "line-with-markers",
94
- "stacked-area-2x": "stacked-area-3x",
95
- "stacked-bar-2x": "stacked-bar-3x",
96
- "stacked-column-2x": "stacked-column-3x",
97
- "stacked-line-2x": "stacked-line-3x",
98
- "stacked-line-with-markers-2x": "stacked-line-with-markers-3x",
99
- "treemap-2x": "treemap",
100
- "treemap-3x": "treemap"
101
- });
102
-
103
- const ALIASES = Object.freeze({ donut: "doughnut" });
104
-
105
- /** Resolve a chart type id to its kept catalog id, or null for ids outside the catalog. */
83
+ /** Resolve a chart type (core's CHART_TYPES vocabulary, OPF 0.15) to its preview spec id, or null for any other value. */
106
84
  export function resolveChartType(type) {
107
- const raw = String(type ?? "").trim().toLowerCase();
108
- const id = ALIASES[raw] ?? DEPRECATED_CHART_TYPES[raw] ?? raw;
85
+ const id = String(type ?? "").trim().toLowerCase();
109
86
  return Object.hasOwn(CHART_TYPES, id) ? id : null;
110
87
  }
111
88
 
@@ -124,7 +101,8 @@ const RENDERERS = {
124
101
  box: renderBoxWhiskerChart,
125
102
  waterfall: renderWaterfallChart,
126
103
  funnel: renderFunnelChart,
127
- map: renderRegionMapChart
104
+ map: renderRegionMapChart,
105
+ combo: renderComboChart
128
106
  };
129
107
 
130
108
  // Chartex constructs also accept a lone value column (the exporter plots it
@@ -132,7 +110,7 @@ const RENDERERS = {
132
110
  const CHARTEX_KINDS = new Set(["treemap", "histogram", "pareto", "box", "waterfall", "funnel", "map"]);
133
111
 
134
112
  /**
135
- * Render a catalog chart. Returns null when the id is outside the catalog, has
113
+ * Render a chart of a known type. Returns null when the type is outside CHART_TYPES, has
136
114
  * no preview renderer, or carries no inline category-major data, so the caller
137
115
  * can keep its legacy output ("No chart data" for unresolved sources).
138
116
  */
@@ -147,6 +125,9 @@ export function renderCatalogChart(item, box, bound, options, svg) {
147
125
  // RR-35: a chart with axis titles, a legend position or data labels reserves their space before the plot is laid out. A chart
148
126
  // with none of them skips this block entirely, so its output is unchanged.
149
127
  const resolved = resolveOptions(item.value, spec);
128
+ // FA-14: a highlight changes colours only (no space is reserved), so it is resolved whether or not an option is active.
129
+ c.highlight = highlightFor(c, resolved, spec, data);
130
+ if (!resolved.active) reportOptionDiagnostics(c, resolved);
150
131
  if (resolved.active) {
151
132
  reportOptionDiagnostics(c, resolved);
152
133
  c.dataLabels = resolved.dataLabels;
@@ -162,20 +143,13 @@ export function renderCatalogChart(item, box, bound, options, svg) {
162
143
  // RR-54: the chart's data as the renderers plot it, from core's resolveChartData: inline columns (names or DataColumn objects),
163
144
  // a dataset reference and chart.mapping all resolve to one positional table, [category, (x,) ...series], with each column's number
164
145
  // format. Series cells have already gone through core's strict chartNumber. Returns null when the data does not resolve (an
165
- // external data source, an unknown dataset, no rows or columns), so the caller keeps its placeholder. A core without the resolver
166
- // reads the inline data as before.
146
+ // external data source, an unknown dataset, no rows or columns), so the caller keeps its placeholder.
167
147
  export function chartData(item, bound) {
168
148
  const chart = item.value;
169
- if (typeof opfComposition.resolveChartData === "function") {
170
- const resolved = opfComposition.resolveChartData(chart, bound?.presentation, { path: item.path });
171
- if (!resolved.ok) return null;
172
- const formats = resolved.formats.map((format) => typeof format === "string" && format !== "" && !opfComposition.numberFormatError?.(format) ? format : undefined);
173
- return { columns: resolved.columns, rows: resolved.rows, formats, trace: tracePaths(item, bound, resolved) };
174
- }
175
- const data = chart?.data;
176
- const rows = Array.isArray(data?.rows) ? data.rows.map((row) => Array.isArray(row) ? row : [row]) : [];
177
- const columns = Array.isArray(data?.columns) ? data.columns : [];
178
- return { columns, rows, formats: [], trace: null };
149
+ const resolved = resolveChartData(chart, bound?.presentation, { path: item.path });
150
+ if (!resolved.ok) return null;
151
+ const formats = resolved.formats.map((format) => typeof format === "string" && format !== "" && !numberFormatError(format) ? format : undefined);
152
+ return { columns: resolved.columns, rows: resolved.rows, formats, trace: tracePaths(item, bound, resolved), ...(resolved.combo ? { combo: resolved.combo } : {}) };
179
153
  }
180
154
 
181
155
  // The authored object at a composed path ("slides.0.blocks.1.chart"), or undefined.
@@ -202,19 +176,19 @@ export function isDatasetTable(item, bound) {
202
176
  }
203
177
 
204
178
  // Where a mark's trace path points. A dataset-backed chart has no `data.rows` or `data.columns` of its own, so every part reports
205
- // the chart's authored path; a chart with `mapping` reports the authored column index of each plotted column (the resolved
206
- // order is category, X, then the mapped series).
179
+ // the chart's authored path; a chart with `mapping` (or a combo chart, whose column series come before its lines) reports the
180
+ // authored column index of each plotted column (the resolved order is category, X, then the mapped series).
207
181
  function tracePaths(item, bound, resolved) {
208
182
  if (isDatasetChart(item, bound)) return { collapse: true };
209
183
  const authored = authoredAt(bound?.presentation, item.path);
210
184
  const authoredData = authored && typeof authored === "object" ? authored.data : undefined;
211
- if (!authored || typeof authored !== "object" || !authored.mapping || !Array.isArray(authoredData?.columns)) return null;
185
+ if (!authored || typeof authored !== "object" || (!authored.mapping && !resolved.combo) || !Array.isArray(authoredData?.columns)) return null;
212
186
  const names = authoredData.columns.map((column) => column !== null && typeof column === "object" ? column.name : column);
213
187
  const columnMap = resolved.columns.map((name, index) => { const found = names.indexOf(name); return found < 0 ? index : found; });
214
188
  return columnMap.every((column, index) => column === index) ? null : { columnMap };
215
189
  }
216
190
 
217
- function chartContext(item, box, bound, options, svg, { rows, columns, formats, trace }) {
191
+ function chartContext(item, box, bound, options, svg, { rows, columns, formats, trace, combo }) {
218
192
  const u = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
219
193
  const composition = bound.composition ?? bound.geometry.composition ?? {};
220
194
  const requested = 14, fontPx = Math.max(requested, composition.minFontSize ?? 16) * u;
@@ -232,7 +206,7 @@ function chartContext(item, box, bound, options, svg, { rows, columns, formats,
232
206
  return path.replace(/^(.*\.data\.(?:columns\.|rows\.\d+\.))(\d+)$/, (whole, head, column) => `${head}${trace.columnMap[Number(column)] ?? column}`);
233
207
  };
234
208
  const c = {
235
- item, box, bound, options, svg, rows, columns, formats, children, u, fontPx, surface, labelColor,
209
+ item, box, bound, options, svg, rows, columns, formats, combo, children, u, fontPx, surface, labelColor,
236
210
  path: item.path,
237
211
  pt: u * 4 / 3,
238
212
  pad: 10 * u,
@@ -276,21 +250,13 @@ function chartContext(item, box, bound, options, svg, { rows, columns, formats,
276
250
  }
277
251
 
278
252
  // RR-54: core's strict chart number (finite numbers and strict decimal strings; everything else is a gap), so the preview, the PPTX
279
- // export and the validator agree on which cells are numbers. A core without it keeps the previous lenient read.
280
- export function chartNumber(value) {
281
- if (typeof opfComposition.chartNumber === "function") return opfComposition.chartNumber(value);
282
- if (typeof value === "number") return Number.isFinite(value) ? value : null;
283
- if (typeof value === "string" && value.trim()) {
284
- const parsed = Number(value);
285
- return Number.isFinite(parsed) ? parsed : null;
286
- }
287
- return null;
288
- }
253
+ // export and the validator agree on which cells are numbers.
254
+ export { chartNumber };
289
255
 
290
256
  /** A number as its column's format shows it (core's formatDataNumber); without a format, the General form the preview always drew. */
291
257
  export function formatChartValue(value, format) {
292
- if (format === undefined || typeof opfComposition.formatDataNumber !== "function") return String(clean(value));
293
- return opfComposition.formatDataNumber(clean(value), format);
258
+ if (format === undefined) return String(clean(value));
259
+ return formatDataNumber(clean(value), format);
294
260
  }
295
261
 
296
262
  // ---------------------------------------------------------------------------
@@ -435,16 +401,17 @@ function legendEntries(c, entries) {
435
401
 
436
402
  // RR-35: the legend entries of a chart whatever the series count (an explicit legend position shows a single series too).
437
403
  function legendSource(c, spec) {
438
- const fromSeries = (series, key, marker = false) => series.map((s) => ({ name: s.name, color: c.colors[s.index % c.colors.length], path: `${c.path}.data.columns.${s.column}`, key, marker }));
404
+ const fromSeries = (series, key, marker = false) => series.map((s) => ({ name: s.name, color: seriesColor(c, s.index), path: `${c.path}.data.columns.${s.column}`, key, marker }));
439
405
  switch (spec.kind) {
440
406
  case "pie":
441
- case "doughnut": return c.rows.map((row, i) => ({ name: c.label(row[0]), color: c.colors[i % c.colors.length], path: `${c.path}.data.rows.${i}.0` }));
407
+ case "doughnut": return c.rows.map((row, i) => ({ name: c.label(row[0]), color: sliceColor(c, i), path: `${c.path}.data.rows.${i}.0` }));
442
408
  case "scatter": return fromSeries(scatterSeries(c.rows, c.columns, c.formats).series, "marker");
443
409
  case "line": return fromSeries(c.series(), "line", spec.markers);
444
410
  case "radar": return fromSeries(c.series(), spec.style === "filled" ? undefined : "line", spec.markers);
445
411
  case "box": return fromSeries(c.columns.length === 1 ? [{ name: c.label(c.columns[0]), column: 0, index: 0 }] : c.series());
446
412
  case "bar":
447
413
  case "area": return fromSeries(c.series());
414
+ case "combo": return comboLegendEntries(c, c.series());
448
415
  default: return [];
449
416
  }
450
417
  }
@@ -494,7 +461,7 @@ function placeLegend(c, spec, position) {
494
461
 
495
462
  function seriesLegend(c, series, key, marker = false) {
496
463
  return series.length > 1
497
- ? legendEntries(c, series.map((s) => ({ name: s.name, color: c.colors[s.index % c.colors.length], path: `${c.path}.data.columns.${s.column}`, key, marker })))
464
+ ? legendEntries(c, series.map((s) => ({ name: s.name, color: seriesColor(c, s.index), path: `${c.path}.data.columns.${s.column}`, key, marker })))
498
465
  : 0;
499
466
  }
500
467
 
@@ -845,9 +812,9 @@ function drawBars(c, spec, series, stacks, { plot, band, at, horizontal, scale,
845
812
  const geometry = barGeometry(band, series.length, spec.grouping);
846
813
  const labels = [];
847
814
  series.forEach((s, j) => {
848
- const color = c.colors[j % c.colors.length];
849
815
  stacks[j].forEach((point, i) => {
850
816
  if (!point) return;
817
+ const color = markColor(c, j, i);
851
818
  const slot = geometry.clustered ? j * geometry.width : 0;
852
819
  const a = at(base(point.from)), b = at(point.to);
853
820
  const path = `${c.path}.data.rows.${i}.${s.column}`;
@@ -871,8 +838,8 @@ function drawBars(c, spec, series, stacks, { plot, band, at, horizontal, scale,
871
838
 
872
839
  function drawLines(c, spec, series, stacks, { plot, band, at, categorySlot = (i) => i }) {
873
840
  const radius = 3 * c.pt;
874
- series.forEach((s, j) => {
875
- const color = c.colors[j % c.colors.length];
841
+ for (let j = 0; j < series.length; j++) {
842
+ const s = series[j], color = seriesColor(c, j);
876
843
  let run = [];
877
844
  const flush = () => {
878
845
  if (run.length > 1) c.mark("polyline", { points: run.map(([x, y]) => `${c.num(x)},${c.num(y)}`).join(" "), fill: "none", stroke: color, "stroke-width": 2 * c.pt, "stroke-linejoin": "round", "stroke-linecap": "round" }, `${c.path}.data.columns.${s.column}`);
@@ -883,10 +850,13 @@ function drawLines(c, spec, series, stacks, { plot, band, at, categorySlot = (i)
883
850
  run.push([plot.x + (categorySlot(i) + 0.5) * band, at(point.to)]);
884
851
  });
885
852
  flush();
886
- if (spec.markers) stacks[j].forEach((point, i) => {
887
- if (point) c.mark("circle", { cx: plot.x + (categorySlot(i) + 0.5) * band, cy: at(point.to), r: radius, fill: color, stroke: color, "stroke-width": 0.75 * c.pt }, `${c.path}.data.rows.${i}.${s.column}`);
853
+ // FA-14: with a highlight each point takes its own colour, and a line without markers marks the highlighted categories' points.
854
+ if (spec.markers || c.highlight?.categories.some(Boolean)) stacks[j].forEach((point, i) => {
855
+ if (!point || (!spec.markers && !c.highlight.categories[i])) return;
856
+ const fill = markColor(c, j, i);
857
+ c.mark("circle", { cx: plot.x + (categorySlot(i) + 0.5) * band, cy: at(point.to), r: radius, fill, stroke: fill, "stroke-width": 0.75 * c.pt }, `${c.path}.data.rows.${i}.${s.column}`);
888
858
  });
889
- });
859
+ }
890
860
  // RR-35: data labels sit beside each point, over every line.
891
861
  if (c.dataLabels) series.forEach((s, j) => stacks[j].forEach((point, i) => {
892
862
  if (point) drawPointLabel(c, labelString(c, { category: c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]), value: s.values[i], format: s.format }), [plot.x + (i + 0.5) * band, at(point.to)], `${c.path}.data.rows.${i}.${s.column}`);
@@ -894,20 +864,133 @@ function drawLines(c, spec, series, stacks, { plot, band, at, categorySlot = (i)
894
864
  }
895
865
 
896
866
  function drawAreas(c, series, stacks, { plot, band, at, crossing, categorySlot = (i) => i }) {
897
- series.forEach((s, j) => {
867
+ for (let j = 0; j < series.length; j++) {
868
+ const s = series[j];
898
869
  const points = stacks[j].map((point, i) => ({ x: plot.x + (categorySlot(i) + 0.5) * band, from: point.from, to: point.to }));
899
870
  const upper = points.map((p) => `${c.num(p.x)} ${c.num(at(p.to))}`);
900
871
  const lower = points.slice().reverse().map((p) => `${c.num(p.x)} ${c.num(p.from === 0 ? crossing : at(p.from))}`);
901
- c.mark("path", { d: `M ${upper.join(" L ")} L ${lower.join(" L ")} Z`, fill: c.colors[j % c.colors.length] }, `${c.path}.data.columns.${s.column}`);
902
- });
872
+ c.mark("path", { d: `M ${upper.join(" L ")} L ${lower.join(" L ")} Z`, fill: seriesColor(c, j) }, `${c.path}.data.columns.${s.column}`);
873
+ }
903
874
  // RR-35: an area's label sits in the area at each category, halfway between its lower and upper edge.
904
875
  if (c.dataLabels) series.forEach((s, j) => stacks[j].forEach((point, i) => {
905
876
  if (!point) return;
906
877
  const lowerEdge = point.from === 0 ? crossing : at(point.from);
907
- drawCenteredLabel(c, labelString(c, { category: c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]), value: s.values[i], format: s.format }), plot.x + (i + 0.5) * band, (lowerEdge + at(point.to)) / 2, `${c.path}.data.rows.${i}.${s.column}`, c.colors[j % c.colors.length]);
878
+ drawCenteredLabel(c, labelString(c, { category: c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]), value: s.values[i], format: s.format }), plot.x + (i + 0.5) * band, (lowerEdge + at(point.to)) / 2, `${c.path}.data.rows.${i}.${s.column}`, seriesColor(c, j));
908
879
  }));
909
880
  }
910
881
 
882
+ // ---------------------------------------------------------------------------
883
+ // Combo (FA-15): clustered columns and line series with markers in one plot. Core's resolveChartData orders the series
884
+ // (column series first, then lines) and says which lines use the secondary value axis. That axis sits at the right (the left,
885
+ // right to left) with its own scale and its tick labels in the first secondary series' number format; the gridlines follow
886
+ // the primary axis, whose ticks take the first column series' format. The PPTX export writes the same plan natively.
887
+
888
+ /** The combo plan of a chart: core's, or (a core without it) every series a column except the last, a line, all on the primary axis. */
889
+ function comboPlan(c, series) {
890
+ if (Array.isArray(c.combo) && c.combo.length === series.length) return c.combo;
891
+ return series.map((_, index) => ({ role: series.length > 1 && index === series.length - 1 ? "line" : "bar", axis: "primary" }));
892
+ }
893
+
894
+ /** Legend entries in series order: a square key for a column series, a line with a marker for a line series. */
895
+ function comboLegendEntries(c, series) {
896
+ const plan = comboPlan(c, series);
897
+ return series.map((s, index) => ({ name: s.name, color: c.colors[s.index % c.colors.length], path: `${c.path}.data.columns.${s.column}`, ...(plan[index].role === "line" ? { key: "line", marker: true } : {}) }));
898
+ }
899
+
900
+ function renderComboChart(c, spec) {
901
+ const { box, pad, fontPx } = c;
902
+ const series = c.series();
903
+ const plan = comboPlan(c, series);
904
+ const bars = series.filter((_, index) => plan[index].role === "bar");
905
+ const lines = series.filter((_, index) => plan[index].role === "line");
906
+ const onSecondary = (s) => plan[s.index].axis === "secondary";
907
+ const primaryLines = lines.filter((s) => !onSecondary(s)), secondaryLines = lines.filter(onSecondary);
908
+ const categories = c.rows.map((row) => c.label(row[0]));
909
+ const count = categories.length;
910
+ // Right to left (RR-05): the categories run from the right, the primary value axis moves to the right and the secondary one to the left.
911
+ const rtl = c.bound.geometry?.direction === "rtl";
912
+ const categorySlot = (i) => rtl ? count - 1 - i : i;
913
+ const extent = (list) => {
914
+ const values = list.flatMap((s) => s.values).filter((value) => value !== null);
915
+ return values.length ? [Math.min(...values), Math.max(...values)] : [0, 1];
916
+ };
917
+ const [primaryMin, primaryMax] = extent([...bars, ...primaryLines]);
918
+ const [secondaryMin, secondaryMax] = extent(secondaryLines);
919
+ const primaryFormat = (bars[0] ?? primaryLines[0])?.format, secondaryFormat = secondaryLines[0]?.format;
920
+ // The default legend sits at the far right, outside a right axis title (the secondary axis title, or right to left the primary
921
+ // one), as PowerPoint places it: the legend is laid out against the box before the title band was carved, and the title moves in.
922
+ const rightTitle = c.axisTitleBands?.some((band) => band.side === "right") && !c.legendManaged;
923
+ let legendWidth = 0;
924
+ if (series.length > 1) {
925
+ const inner = c.box;
926
+ if (rightTitle) c.box = { ...inner, width: inner.width + c.lineHeight };
927
+ legendWidth = legendEntries(c, comboLegendEntries(c, series));
928
+ c.box = inner;
929
+ if (rightTitle) c.axisTitleRightInset = legendWidth + c.pad / 2;
930
+ }
931
+ const right = box.x + box.width - pad - legendWidth;
932
+ const top = box.y + pad + c.lineHeight / 2;
933
+ const entries = categories.map((name, i) => ({ name, path: `${c.path}.data.rows.${i}.0` }));
934
+ if (rtl) entries.reverse();
935
+ const tickWidthOf = (scale, format) => Math.max(...scale.ticks.map((tick) => c.width(formatTick(tick, false, format)))) + fontPx * 0.5;
936
+ const axis = planCategoryAxis(c, entries, (reserve) => {
937
+ const height = Math.max(1, box.y + box.height - pad - reserve - top);
938
+ const intervals = maxIntervalsFor(height, c.lineHeight * 1.2);
939
+ const scale = niceScale(primaryMin, primaryMax, intervals);
940
+ const secondary = secondaryLines.length ? niceScale(secondaryMin, secondaryMax, intervals) : null;
941
+ const primaryWidth = tickWidthOf(scale, primaryFormat), secondaryWidth = secondary ? tickWidthOf(secondary, secondaryFormat) : 0;
942
+ const leftWidth = rtl ? secondaryWidth : primaryWidth, rightWidth = rtl ? primaryWidth : secondaryWidth;
943
+ const x = box.x + pad + (leftWidth || fontPx / 2);
944
+ const plotRight = right - (rightWidth || fontPx / 2);
945
+ return { scale, secondary, primaryWidth, secondaryWidth, plot: { x, y: top, width: Math.max(1, plotRight - x), height } };
946
+ });
947
+ const { plot, scale, secondary } = axis;
948
+ drawBottomLabels(c, axis.plan, plot, entries);
949
+ c.plotArea = plot;
950
+ const valueAt = (axisScale) => (value) => plot.y + axisFraction(value, axisScale, true) * plot.height;
951
+ const at = valueAt(scale), atSecondary = secondary ? valueAt(secondary) : at;
952
+ const crossing = at(Math.min(scale.max, Math.max(scale.min, 0)));
953
+ const tickLabel = (label, position, side, width) => side === "left"
954
+ ? c.text(label, { x: box.x + pad, y: position - c.lineHeight / 2, width: Math.max(1, plot.x - box.x - pad - fontPx * 0.35), height: c.lineHeight }, c.path, "right")
955
+ : c.text(label, { x: plot.x + plot.width + fontPx * 0.35, y: position - c.lineHeight / 2, width: Math.max(1, width - fontPx * 0.35), height: c.lineHeight }, c.path, "left");
956
+ for (const tick of scale.ticks) {
957
+ const position = at(tick);
958
+ gridline(c, plot.x, position, plot.x + plot.width, position);
959
+ tickLabel(formatTick(tick, false, primaryFormat), position, rtl ? "right" : "left", axis.primaryWidth);
960
+ }
961
+ if (secondary) for (const tick of secondary.ticks) tickLabel(formatTick(tick, false, secondaryFormat), atSecondary(tick), rtl ? "left" : "right", axis.secondaryWidth);
962
+ const band = plot.width / count;
963
+ // Column series come first in core's order, so a column's palette index is its series index.
964
+ if (bars.length) drawBars(c, spec, bars, stackCategoryValues(bars, count, "bar", "clustered", c.path), { plot, band, at, horizontal: false, scale, categorySlot });
965
+ const point = (s, i, value) => [plot.x + (categorySlot(i) + 0.5) * band, (onSecondary(s) ? atSecondary : at)(value)];
966
+ const radius = 3 * c.pt;
967
+ for (const s of lines) {
968
+ const color = c.colors[s.index % c.colors.length];
969
+ let run = [];
970
+ const flush = () => {
971
+ if (run.length > 1) c.mark("polyline", { points: run.map(([x, y]) => `${c.num(x)},${c.num(y)}`).join(" "), fill: "none", stroke: color, "stroke-width": 2 * c.pt, "stroke-linejoin": "round", "stroke-linecap": "round" }, `${c.path}.data.columns.${s.column}`);
972
+ run = [];
973
+ };
974
+ s.values.forEach((value, i) => { if (value === null) flush(); else run.push(point(s, i, value)); });
975
+ flush();
976
+ s.values.forEach((value, i) => {
977
+ if (value === null) return;
978
+ const [cx, cy] = point(s, i, value);
979
+ c.mark("circle", { cx, cy, r: radius, fill: color, stroke: color, "stroke-width": 0.75 * c.pt }, `${c.path}.data.rows.${i}.${s.column}`);
980
+ });
981
+ }
982
+ // Line labels sit beside each point at the combo's line position (core: above unless a point position was asked for).
983
+ if (c.dataLabels) for (const s of lines) s.values.forEach((value, i) => {
984
+ if (value === null) return;
985
+ const category = c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]);
986
+ drawPointLabel(c, labelString(c, { category, value, format: s.format }), point(s, i, value), `${c.path}.data.rows.${i}.${s.column}`, c.dataLabels.linePosition ?? "above");
987
+ });
988
+ const primaryX = rtl ? plot.x + plot.width : plot.x, secondaryX = rtl ? plot.x : plot.x + plot.width;
989
+ axisLine(c, primaryX, plot.y, primaryX, plot.y + plot.height);
990
+ if (secondary) axisLine(c, secondaryX, plot.y, secondaryX, plot.y + plot.height);
991
+ axisLine(c, plot.x, crossing, plot.x + plot.width, crossing);
992
+ }
993
+
911
994
  // ---------------------------------------------------------------------------
912
995
  // Pie and doughnut: first series only, clockwise from 12 o'clock, one colour per category.
913
996
 
@@ -916,7 +999,7 @@ function renderCircularChart(c, spec) {
916
999
  const categories = c.rows.map((row) => c.label(row[0]));
917
1000
  const values = c.rows.map((row) => Math.abs(chartNumber(row[1]) ?? 0));
918
1001
  const legendWidth = legendEntries(c, categories.map((name, i) => ({
919
- name, color: c.colors[i % c.colors.length], path: `${c.path}.data.rows.${i}.0`
1002
+ name, color: sliceColor(c, i), path: `${c.path}.data.rows.${i}.0`
920
1003
  })));
921
1004
  const area = { x: box.x + pad, y: box.y + pad, width: Math.max(1, box.width - 2 * pad - legendWidth), height: Math.max(1, box.height - 2 * pad) };
922
1005
  const cx = area.x + area.width / 2, cy = area.y + area.height / 2;
@@ -936,7 +1019,7 @@ function renderCircularChart(c, spec) {
936
1019
  let angle = -Math.PI / 2;
937
1020
  values.forEach((value, i) => {
938
1021
  const delta = value / total * Math.PI * 2, end = angle + delta;
939
- const fill = c.colors[i % c.colors.length], path = `${c.path}.data.rows.${i}.1`;
1022
+ const fill = sliceColor(c, i), path = `${c.path}.data.rows.${i}.1`;
940
1023
  if (delta >= Math.PI * 2 - 1e-9) {
941
1024
  if (inner) c.mark("path", { d: `${circlePath(c, cx, cy, r)} ${circlePath(c, cx, cy, inner)}`, fill, "fill-rule": "evenodd", ...border }, path);
942
1025
  else c.mark("circle", { cx, cy, r, fill, ...border }, path);
@@ -949,7 +1032,7 @@ function renderCircularChart(c, spec) {
949
1032
  let start = -Math.PI / 2;
950
1033
  values.forEach((value, i) => {
951
1034
  const delta = value / total * Math.PI * 2;
952
- if (delta > 0) drawSliceLabel(c, sliceTexts[i], { cx, cy, r, inner, mid: start + delta / 2 }, `${c.path}.data.rows.${i}.1`, c.colors[i % c.colors.length]);
1035
+ if (delta > 0) drawSliceLabel(c, sliceTexts[i], { cx, cy, r, inner, mid: start + delta / 2 }, `${c.path}.data.rows.${i}.1`, sliceColor(c, i));
953
1036
  start += delta;
954
1037
  });
955
1038
  }
@@ -1020,10 +1103,10 @@ function renderScatterChart(c) {
1020
1103
  const yCross = yAt(Math.min(yScale.max, Math.max(yScale.min, 0))), xCross = xAt(Math.min(xScale.max, Math.max(xScale.min, 0)));
1021
1104
  axisLine(c, xCross, plot.y, xCross, plot.y + plot.height);
1022
1105
  axisLine(c, plot.x, yCross, plot.x + plot.width, yCross);
1023
- series.forEach((s, j) => {
1024
- const color = c.colors[j % c.colors.length];
1106
+ for (let j = 0; j < series.length; j++) {
1107
+ const s = series[j], color = seriesColor(c, j);
1025
1108
  for (const point of s.points) c.mark("circle", { cx: xAt(point.x), cy: yAt(point.y), r: 3 * c.pt, fill: color, stroke: color, "stroke-width": 0.75 * c.pt }, `${c.path}.data.rows.${point.row}.${s.column}`);
1026
- });
1109
+ }
1027
1110
  // RR-35: a scatter label shows the Y value, and the X value as its category.
1028
1111
  if (c.dataLabels) series.forEach((s) => {
1029
1112
  for (const point of s.points) drawPointLabel(c, labelString(c, { category: formatChartValue(point.x, xFormat), value: point.y, format: s.format }), [xAt(point.x), yAt(point.y)], `${c.path}.data.rows.${point.row}.${s.column}`);
@@ -1092,12 +1175,12 @@ function renderRadarChart(c, spec) {
1092
1175
  c.text(text, rect, label.path, align);
1093
1176
  });
1094
1177
  reportTruncatedLabels(c, truncated);
1095
- series.forEach((s, j) => {
1096
- const color = c.colors[j % c.colors.length], path = `${c.path}.data.columns.${s.column}`;
1178
+ for (let j = 0; j < series.length; j++) {
1179
+ const s = series[j], color = seriesColor(c, j), path = `${c.path}.data.columns.${s.column}`;
1097
1180
  if (spec.style === "filled") {
1098
1181
  const outline = s.values.map((v, i) => point(i, v ?? scale.min).map(n).join(" "));
1099
1182
  c.mark("path", { d: `M ${outline.join(" L ")} Z`, fill: color }, path);
1100
- return;
1183
+ continue;
1101
1184
  }
1102
1185
  const at = (i) => point(i, s.values[i]).map(n).join(" ");
1103
1186
  let d = "";
@@ -1112,7 +1195,7 @@ function renderRadarChart(c, spec) {
1112
1195
  const [x, y] = point(i, v);
1113
1196
  c.mark("circle", { cx: x, cy: y, r: 3 * c.pt, fill: color, stroke: color, "stroke-width": 0.75 * c.pt }, `${c.path}.data.rows.${i}.${s.column}`);
1114
1197
  });
1115
- });
1198
+ }
1116
1199
  // Value axis labels sit on top of filled series, as in PowerPoint.
1117
1200
  for (const tick of scale.ticks) {
1118
1201
  const label = formatTick(tick, false, valueFormat), width = c.width(label) + fontPx * 0.5;
@@ -1,8 +1,8 @@
1
1
  // RR-28: the part of the slideshow player and the <opf-deck> element that has no DOM of its own. It reads an OPF document,
2
- // decides which slides play (hidden ones do not), renders each slide once with the renderer's own SVG output (renderSvg:
2
+ // decides which slides play (hidden ones do not), renders each slide once with the renderer's own SVG output (renderSlideSvg:
3
3
  // there is no second layout engine) behind a face-level font gate, and synchronizes windows over a BroadcastChannel.
4
4
  // Nothing here fetches anything but the font files the host's font root serves.
5
- import { renderSvg } from "./svg.js";
5
+ import { renderSlideSvg } from "./svg.js";
6
6
 
7
7
  /** Error raised for a deck that cannot be read, rendered or loaded. */
8
8
  export class DeckError extends Error {
@@ -173,38 +173,24 @@ export function localIsoDate(now = new Date()) {
173
173
 
174
174
  // --- font gate -----------------------------------------------------------------------------------------------------
175
175
 
176
- const MAX_FONT_ROUNDS = 4;
177
-
178
176
  /**
179
- * The gate every render goes through: nothing draws a document before the faces it needs are loaded. `registry` is a browser
180
- * font registry (`loadBrowserFontRegistry`); one without the lazy loaders gates nothing. Face level: only the vendored and
181
- * script faces the document draws are fetched, each hash-verified by the registry.
177
+ * The gate every render goes through: nothing draws a document before the faces it needs are loaded. `fonts` is a browser
178
+ * fonts handle (`loadFonts` from `/fonts-browser`); one without `pending` and `ensure` gates nothing. Face level: only the
179
+ * vendored and script faces the document draws are fetched, each hash-verified by the registry behind the handle.
182
180
  */
183
- export function createFontGate(registry) {
184
- const sources = (document, options) => {
185
- let lazy = [], scripts = [];
186
- try { scripts = registry?.pendingScripts?.(document, options) ?? []; } catch { /* the renderer reports the document itself */ }
187
- try { lazy = (registry?.pendingLazyFonts?.(document, options) ?? []).map((face) => face?.file ?? String(face)); } catch { /* likewise */ }
188
- return { lazy, scripts };
189
- };
181
+ export function createFontGate(fonts) {
182
+ // A document that does not resolve has nothing to load here: the renderer reports it when it draws.
183
+ const pending = (document, options) => { try { return fonts?.pending?.(document, options) ?? []; } catch { return []; } };
190
184
  return {
191
- pending(document, options) { const { lazy, scripts } = sources(document, options); return [...lazy, ...scripts]; },
185
+ pending,
192
186
  async ensure(document, { signal, renderOptions } = {}) {
193
- const call = { ...renderOptions, signal };
194
- try {
195
- for (let round = 0; round < MAX_FONT_ROUNDS; round++) {
196
- const { lazy, scripts } = sources(document, renderOptions);
197
- if (!lazy.length && !scripts.length) return;
198
- signal?.throwIfAborted?.();
199
- if (lazy.length) await registry.ensureLazyFonts?.(document, call);
200
- if (scripts.length) await registry.ensureScripts?.(document, call);
201
- }
202
- } catch (cause) {
187
+ if (!pending(document, renderOptions).length) return;
188
+ try { await fonts.ensure(document, { ...renderOptions, signal }); } catch (cause) {
203
189
  if (signal?.aborted) throw cause;
204
190
  throw new DeckError("fonts-unavailable", `Fonts for this deck could not be loaded: ${cause?.message ?? cause}`, { cause });
205
191
  }
206
- const left = sources(document, renderOptions);
207
- if (left.lazy.length || left.scripts.length) throw new DeckError("fonts-unavailable", `Fonts for this deck did not finish loading: ${[...left.lazy, ...left.scripts].join(", ")}.`);
192
+ const left = pending(document, renderOptions);
193
+ if (left.length) throw new DeckError("fonts-unavailable", `Fonts for this deck did not finish loading: ${left.join(", ")}.`);
208
194
  },
209
195
  };
210
196
  }
@@ -213,9 +199,9 @@ export function createFontGate(registry) {
213
199
 
214
200
  /**
215
201
  * One deck's rendering, shared by the element, the full-screen player and the speaker view: font loading once, each slide
216
- * rendered once by `renderSvg` and kept, so moving between slides never draws twice and every surface shows the same markup.
202
+ * rendered once by `renderSlideSvg` and kept, so moving between slides never draws twice and every surface shows the same markup.
217
203
  *
218
- * `fonts` is a browser font registry (or nothing: layout then uses estimated widths and the visitor's system sans-serif).
204
+ * `fonts` is a browser fonts handle (or nothing: layout then uses estimated widths and the visitor's system sans-serif).
219
205
  */
220
206
  export function createDeckStore({ document, fonts, renderOptions = {}, date } = {}) {
221
207
  const deck = parseDeckDocument(document);
@@ -224,7 +210,8 @@ export function createDeckStore({ document, fonts, renderOptions = {}, date } =
224
210
  const diagnostics = [];
225
211
  const options = () => ({
226
212
  ...renderOptions,
227
- ...(fonts?.textMeasurement ? { textMeasurement: fonts.textMeasurement } : {}),
213
+ // Inline slides use the page's own faces (the registry added them to the document), so only the measurement is passed: nothing is embedded.
214
+ ...(fonts?.textMeasurement ? { fonts: { textMeasurement: fonts.textMeasurement } } : {}),
228
215
  ...(renderOptions.date === undefined && date !== undefined ? { date } : {}),
229
216
  onDiagnostic(diagnostic) { diagnostics.push(diagnostic); renderOptions.onDiagnostic?.(diagnostic); },
230
217
  });
@@ -243,12 +230,12 @@ export function createDeckStore({ document, fonts, renderOptions = {}, date } =
243
230
  let svg = cache.get(index);
244
231
  if (svg === undefined) {
245
232
  try {
246
- svg = renderSvg(deck, { ...options(), slideIndex: index, validate: !validated });
233
+ svg = renderSlideSvg(deck, index, { ...options(), validate: !validated });
247
234
  } catch (cause) {
248
235
  if (cause instanceof DeckError) throw cause;
249
- // A document the schema rejects says where: the first issue's path and message.
250
- const issue = cause?.code === "invalid-opf" ? (cause.details?.issues ?? cause.issues)?.[0] : undefined;
251
- const where = issue ? ` (${issue.path || "/"}: ${issue.message})` : "";
236
+ // A document the schema rejects says where: the first finding's path and message.
237
+ const finding = cause?.code === "invalid-opf" ? cause.findings?.[0] : undefined;
238
+ const where = finding ? ` (${finding.path || "/"}: ${finding.message})` : "";
252
239
  throw new DeckError(cause?.code === "invalid-opf" ? "invalid-document" : "render-failed", `${cause?.message ?? "The slide could not be drawn."}${where}`, { cause });
253
240
  }
254
241
  validated = true;