@openpresentation/opf-render 0.11.9 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/charts.js CHANGED
@@ -8,14 +8,25 @@
8
8
  // for the exported chart; deprecated ids render exactly like their replacement;
9
9
  // any other id returns null so the caller keeps its legacy single-series preview.
10
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";
13
+ import { drawAxisTitles, drawBarLabel, drawCenteredLabel, drawPointLabel, drawSliceLabel, labelString, outsideLabelReserve, reportOptionDiagnostics, reserveAxisTitles, resolveOptions } from "./chart-options.js";
11
14
 
12
15
  // Ordered series palette written by opf-pptx (`CHART_COLORS`), before the same
13
- // per-surface contrast adjustment (`chartColorForFill`).
16
+ // per-surface adjustment (`chartPaletteForFill`: contrast against the card, without letting two series merge).
14
17
  export const CHART_SERIES_COLORS = Object.freeze([
15
18
  "#2874A6", "#1B4F72", "#5499C7", "#7BDBB2", "#3AC67A", "#24A89E",
16
19
  "#F59E0B", "#EF4444", "#8B5CF6", "#14B8A6", "#0F172A", "#64748B"
17
20
  ]);
18
21
 
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.
24
+ 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));
28
+ }
29
+
19
30
  const bar = (dir, grouping) => ({ kind: "bar", dir, grouping });
20
31
  const line = (grouping, markers) => ({ kind: "line", grouping, markers });
21
32
  const area = (grouping) => ({ kind: "area", grouping });
@@ -135,7 +146,18 @@ export function renderCatalogChart(item, box, bound, options, svg) {
135
146
  if (!render || !rows.length || columns.length < (CHARTEX_KINDS.has(spec.kind) ? 1 : 2)) return null;
136
147
  const c = chartContext(item, box, bound, options, svg, rows, columns);
137
148
  c.mark("rect", { x: box.x, y: box.y, width: box.width, height: box.height, fill: c.surface, stroke: bound.design.colors.border, "stroke-width": 1 }, item.path);
149
+ // RR-35: a chart with axis titles, a legend position or data labels reserves their space before the plot is laid out. A chart
150
+ // with none of them skips this block entirely, so its output is unchanged.
151
+ const resolved = resolveOptions(item.value, spec);
152
+ if (resolved.active) {
153
+ reportOptionDiagnostics(c, resolved);
154
+ c.dataLabels = resolved.dataLabels;
155
+ c.dataLabelsOff = resolved.dataLabelsOff === true;
156
+ if (resolved.legend !== undefined) placeLegend(c, spec, resolved.legend);
157
+ reserveAxisTitles(c, spec, resolved.axisTitles);
158
+ }
138
159
  render(c, spec);
160
+ if (resolved.active) drawAxisTitles(c);
139
161
  return svg.tag("g", { ...svg.traceAttrs(options, item.path), "data-opf-chart": options.trace ? id : undefined }, c.children.join("\n"));
140
162
  }
141
163
 
@@ -160,7 +182,7 @@ function chartContext(item, box, bound, options, svg, rows, columns) {
160
182
  // clear space kept between neighbouring category labels.
161
183
  textHeight: fontPx * 1.22,
162
184
  labelGap: fontPx * 0.1,
163
- colors: CHART_SERIES_COLORS.map((color) => chartColorForFill(surface, color)),
185
+ colors: chartSeriesPalette(surface),
164
186
  gridColor: bound.design.colors.border,
165
187
  axisColor: "#888888",
166
188
  number: chartNumber,
@@ -172,11 +194,11 @@ function chartContext(item, box, bound, options, svg, rows, columns) {
172
194
  for (const [key, value] of Object.entries(attrs)) out[key] = numericAttrs.has(key) && typeof value === "number" ? n(value) : value;
173
195
  children.push(svg.tag(name, { ...out, ...(path ? svg.traceAttrs(options, path) : {}) }));
174
196
  },
175
- textElement(value, rect, path, align = "center") {
197
+ textElement(value, rect, path, align = "center", fill = labelColor) {
176
198
  if (!(rect.width > 0 && rect.height > 0)) return "";
177
199
  return svg.renderTextBox(String(value), rect, bound, {
178
200
  path, fontSize: requested, fontFamily: bound.design.fonts.body, fontWeight: 400,
179
- fill: labelColor, options, align, verticalAlign: "middle"
201
+ fill, options, align, verticalAlign: "middle"
180
202
  });
181
203
  },
182
204
  text(value, rect, path, align = "center") {
@@ -292,28 +314,97 @@ export function formatTick(value, percent = false) {
292
314
  return String(clean(value));
293
315
  }
294
316
 
295
- function legendEntries(c, entries) {
296
- // PowerPoint places the exported legend on the right (PptxGenJS legendPos r).
297
- if (!entries.length) return 0;
317
+ function legendMetrics(c, entries) {
298
318
  const swatch = c.fontPx * 0.6, gap = c.fontPx * 0.4, keyWidth = entries.some((entry) => entry.key === "line") ? c.fontPx * 1.4 : swatch;
299
- const textWidth = Math.max(...entries.map((entry) => c.width(entry.name)));
300
- const width = Math.min(c.box.width * 0.45, keyWidth + gap + textWidth + c.fontPx * 0.5);
319
+ return { swatch, gap, keyWidth, textWidth: Math.max(...entries.map((entry) => c.width(entry.name))) };
320
+ }
321
+
322
+ // One legend entry: its key at x, its name after it, in a row starting at y.
323
+ function drawLegendEntry(c, entry, x, y, rowHeight, { swatch, gap, keyWidth }, width) {
324
+ const middle = y + rowHeight / 2;
325
+ // Legend keys follow the series format: a line (with marker) for line/radar
326
+ // series, a marker for scatter series, a filled square otherwise.
327
+ if (entry.key === "line") c.mark("polyline", { points: `${c.num(x)},${c.num(middle)} ${c.num(x + keyWidth)},${c.num(middle)}`, fill: "none", stroke: entry.color, "stroke-width": 2 * c.pt }, entry.path);
328
+ if (entry.key === "marker" || (entry.key === "line" && entry.marker)) c.mark("circle", { cx: x + keyWidth / 2, cy: middle, r: 3 * c.pt, fill: entry.color, stroke: entry.color, "stroke-width": 0.75 * c.pt }, entry.path);
329
+ if (!entry.key) c.mark("rect", { x, y: middle - swatch / 2, width: swatch, height: swatch, fill: entry.color }, entry.path);
330
+ c.text(entry.name, { x: x + keyWidth + gap, y, width: Math.max(1, width - keyWidth - gap), height: rowHeight }, entry.path, "left");
331
+ }
332
+
333
+ function legendEntries(c, entries) {
334
+ // PowerPoint places the exported legend on the right (PptxGenJS legendPos r). A chart with an explicit legend option
335
+ // draws it in placeLegend instead, before the plot, so the renderers draw nothing here.
336
+ if (!entries.length || c.legendManaged) return 0;
337
+ const metrics = legendMetrics(c, entries);
338
+ const width = Math.min(c.box.width * 0.45, metrics.keyWidth + metrics.gap + metrics.textWidth + c.fontPx * 0.5);
301
339
  const rowHeight = Math.min(c.lineHeight, (c.box.height - 2 * c.pad) / entries.length);
302
340
  const x = c.box.x + c.box.width - c.pad - width;
303
341
  let y = c.box.y + Math.max(c.pad, (c.box.height - rowHeight * entries.length) / 2);
304
342
  for (const entry of entries) {
305
- const middle = y + rowHeight / 2;
306
- // Legend keys follow the series format: a line (with marker) for line/radar
307
- // series, a marker for scatter series, a filled square otherwise.
308
- if (entry.key === "line") c.mark("polyline", { points: `${c.num(x)},${c.num(middle)} ${c.num(x + keyWidth)},${c.num(middle)}`, fill: "none", stroke: entry.color, "stroke-width": 2 * c.pt }, entry.path);
309
- if (entry.key === "marker" || (entry.key === "line" && entry.marker)) c.mark("circle", { cx: x + keyWidth / 2, cy: middle, r: 3 * c.pt, fill: entry.color, stroke: entry.color, "stroke-width": 0.75 * c.pt }, entry.path);
310
- if (!entry.key) c.mark("rect", { x, y: middle - swatch / 2, width: swatch, height: swatch, fill: entry.color }, entry.path);
311
- c.text(entry.name, { x: x + keyWidth + gap, y, width: Math.max(1, width - keyWidth - gap), height: rowHeight }, entry.path, "left");
343
+ drawLegendEntry(c, entry, x, y, rowHeight, metrics, width);
312
344
  y += rowHeight;
313
345
  }
314
346
  return width + c.pad;
315
347
  }
316
348
 
349
+ // RR-35: the legend entries of a chart whatever the series count (an explicit legend position shows a single series too).
350
+ function legendSource(c, spec) {
351
+ 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 }));
352
+ switch (spec.kind) {
353
+ case "pie":
354
+ 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` }));
355
+ case "scatter": return fromSeries(scatterSeries(c.rows, c.columns).series, "marker");
356
+ case "line": return fromSeries(c.series(), "line", spec.markers);
357
+ case "radar": return fromSeries(c.series(), spec.style === "filled" ? undefined : "line", spec.markers);
358
+ case "box": return fromSeries(c.columns.length === 1 ? [{ name: c.label(c.columns[0]), column: 0, index: 0 }] : c.series());
359
+ case "bar":
360
+ case "area": return fromSeries(c.series());
361
+ default: return [];
362
+ }
363
+ }
364
+
365
+ // RR-35: an explicit legend position. The legend is drawn at its edge of the chart and the room it takes is carved out of
366
+ // c.box, so the renderer lays the plot out in what is left. "none" only switches the default right legend off.
367
+ function placeLegend(c, spec, position) {
368
+ c.legendManaged = true;
369
+ if (position === "none") return;
370
+ const entries = legendSource(c, spec);
371
+ if (!entries.length) return;
372
+ const metrics = legendMetrics(c, entries), outer = c.box;
373
+ if (position === "left" || position === "right") {
374
+ const width = Math.min(outer.width * 0.45, metrics.keyWidth + metrics.gap + metrics.textWidth + c.fontPx * 0.5);
375
+ const rowHeight = Math.min(c.lineHeight, (outer.height - 2 * c.pad) / entries.length);
376
+ const x = position === "right" ? outer.x + outer.width - c.pad - width : outer.x + c.pad;
377
+ let y = outer.y + Math.max(c.pad, (outer.height - rowHeight * entries.length) / 2);
378
+ for (const entry of entries) {
379
+ drawLegendEntry(c, entry, x, y, rowHeight, metrics, width);
380
+ y += rowHeight;
381
+ }
382
+ const reserve = width + c.pad;
383
+ c.box = { x: position === "left" ? outer.x + reserve : outer.x, y: outer.y, width: Math.max(1, outer.width - reserve), height: outer.height };
384
+ return;
385
+ }
386
+ // Top and bottom: entries flow left to right in rows, each row centred, wrapping when the row would pass the chart width.
387
+ const available = Math.max(1, outer.width - 2 * c.pad), spacing = c.fontPx * 0.8;
388
+ const sized = entries.map((entry) => ({ entry, width: Math.min(available, metrics.keyWidth + metrics.gap + c.width(entry.name) + spacing) }));
389
+ const rows = [];
390
+ for (const item of sized) {
391
+ const row = rows[rows.length - 1];
392
+ if (row && row.width + item.width <= available + 0.01) { row.items.push(item); row.width += item.width; } else rows.push({ items: [item], width: item.width });
393
+ }
394
+ const band = rows.length * c.lineHeight;
395
+ let y = position === "top" ? outer.y + c.pad : outer.y + outer.height - c.pad - band;
396
+ for (const row of rows) {
397
+ let x = outer.x + (outer.width - row.width + spacing) / 2;
398
+ for (const { entry, width } of row.items) {
399
+ drawLegendEntry(c, entry, x, y, c.lineHeight, metrics, width - spacing + c.fontPx * 0.4);
400
+ x += width;
401
+ }
402
+ y += c.lineHeight;
403
+ }
404
+ const reserve = band + c.pad;
405
+ c.box = { x: outer.x, y: position === "top" ? outer.y + reserve : outer.y, width: outer.width, height: Math.max(1, outer.height - reserve) };
406
+ }
407
+
317
408
  function seriesLegend(c, series, key, marker = false) {
318
409
  return series.length > 1
319
410
  ? 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 })))
@@ -572,6 +663,10 @@ function renderCategoryChart(c, spec) {
572
663
  const series = c.series();
573
664
  const categories = c.rows.map((row) => c.label(row[0]));
574
665
  const count = categories.length;
666
+ // Right to left (RR-05): column, line and area charts run their category axis from the right (PowerPoint's reversed categories,
667
+ // c:catAx orientation maxMin), which also puts the value axis at the right. Bar charts keep their vertical category axis.
668
+ const rtl = c.bound.geometry?.direction === "rtl" && !horizontal;
669
+ const categorySlot = (i) => rtl ? count - 1 - i : i;
575
670
  const stacks = stackCategoryValues(series, count, spec.kind, spec.grouping, c.path);
576
671
  // The scale follows the plotted values. Only stacked bars and areas plot from a
577
672
  // base (the running total below them); an unstacked series has a synthetic zero
@@ -582,7 +677,7 @@ function renderCategoryChart(c, spec) {
582
677
  const legendWidth = seriesLegend(c, series, spec.kind === "line" ? "line" : undefined, spec.markers);
583
678
  const right = box.x + box.width - pad - legendWidth;
584
679
  const top = box.y + pad + c.lineHeight / 2;
585
- let plot, scale;
680
+ let plot, scale, valueTickWidth;
586
681
  if (horizontal) {
587
682
  const categoryWidth = Math.min(box.width * 0.3, Math.max(0, ...categories.map((name) => c.width(flatLabel(name)))) + fontPx * 0.5);
588
683
  const x = box.x + pad + categoryWidth + c.pad / 2;
@@ -597,16 +692,23 @@ function renderCategoryChart(c, spec) {
597
692
  drawRowLabels(c, planRowLabels(c, entries, rowBand, categoryWidth), entries, rowBand, categoryWidth, box.x + pad, (i) => plot.y + plot.height - (i + 1) * rowBand);
598
693
  } else {
599
694
  const entries = categories.map((name, i) => ({ name, path: `${c.path}.data.rows.${i}.0` }));
695
+ if (rtl) entries.reverse();
600
696
  const axis = planCategoryAxis(c, entries, (reserve) => {
601
697
  const height = Math.max(1, box.y + box.height - pad - reserve - top);
602
698
  const fitted = niceScale(dataMin, dataMax, maxIntervalsFor(height, c.lineHeight * 1.2), { percent });
603
699
  const tickWidth = Math.max(...fitted.ticks.map((tick) => c.width(formatTick(tick, percent)))) + fontPx * 0.5;
700
+ if (rtl) {
701
+ const x = box.x + pad + fontPx / 2;
702
+ return { scale: fitted, plot: { x, y: top, width: Math.max(1, right - tickWidth - x), height }, tickWidth };
703
+ }
604
704
  const x = box.x + pad + tickWidth;
605
705
  return { scale: fitted, plot: { x, y: top, width: Math.max(1, right - x - fontPx / 2), height } };
606
706
  });
607
707
  ({ plot, scale } = axis);
708
+ valueTickWidth = axis.tickWidth;
608
709
  drawBottomLabels(c, axis.plan, plot, entries);
609
710
  }
711
+ c.plotArea = plot;
610
712
  const at = (value) => horizontal ? plot.x + axisFraction(value, scale) * plot.width : plot.y + axisFraction(value, scale, true) * plot.height;
611
713
  // Category axis crosses at zero when zero is on the axis (autoZero).
612
714
  const crossing = at(Math.min(scale.max, Math.max(scale.min, 0)));
@@ -618,18 +720,20 @@ function renderCategoryChart(c, spec) {
618
720
  c.text(label, { x: position - width / 2, y: plot.y + plot.height, width, height: c.lineHeight }, c.path);
619
721
  } else {
620
722
  gridline(c, plot.x, position, plot.x + plot.width, position);
621
- c.text(label, { x: box.x + pad, y: position - c.lineHeight / 2, width: plot.x - box.x - pad - fontPx * 0.35, height: c.lineHeight }, c.path, "right");
723
+ if (rtl) c.text(label, { x: plot.x + plot.width + fontPx * 0.35, y: position - c.lineHeight / 2, width: Math.max(1, valueTickWidth - fontPx * 0.35), height: c.lineHeight }, c.path, "left");
724
+ else c.text(label, { x: box.x + pad, y: position - c.lineHeight / 2, width: plot.x - box.x - pad - fontPx * 0.35, height: c.lineHeight }, c.path, "right");
622
725
  }
623
726
  }
624
727
  const band = (horizontal ? plot.height : plot.width) / count;
625
- if (spec.kind === "bar") drawBars(c, spec, series, stacks, { plot, band, at, horizontal, scale });
626
- else if (spec.kind === "line") drawLines(c, spec, series, stacks, { plot, band, at });
627
- else drawAreas(c, series, stacks, { plot, band, at, crossing });
728
+ if (spec.kind === "bar") drawBars(c, spec, series, stacks, { plot, band, at, horizontal, scale, categorySlot });
729
+ else if (spec.kind === "line") drawLines(c, spec, series, stacks, { plot, band, at, categorySlot });
730
+ else drawAreas(c, series, stacks, { plot, band, at, crossing, categorySlot });
628
731
  if (horizontal) {
629
732
  axisLine(c, crossing, plot.y, crossing, plot.y + plot.height);
630
733
  axisLine(c, plot.x, plot.y + plot.height, plot.x + plot.width, plot.y + plot.height);
631
734
  } else {
632
- axisLine(c, plot.x, plot.y, plot.x, plot.y + plot.height);
735
+ const valueAxisX = rtl ? plot.x + plot.width : plot.x;
736
+ axisLine(c, valueAxisX, plot.y, valueAxisX, plot.y + plot.height);
633
737
  axisLine(c, plot.x, crossing, plot.x + plot.width, crossing);
634
738
  }
635
739
  }
@@ -644,10 +748,11 @@ export function barGeometry(band, seriesCount, grouping) {
644
748
  return { group: width, width, offset: (band - width) / 2, clustered: false };
645
749
  }
646
750
 
647
- function drawBars(c, spec, series, stacks, { plot, band, at, horizontal, scale }) {
751
+ function drawBars(c, spec, series, stacks, { plot, band, at, horizontal, scale, categorySlot = (i) => i }) {
648
752
  // Bars grow from zero, or from the axis minimum (maximum, for all-negative data) when zero is off the axis.
649
753
  const base = (value) => Math.min(scale.max, Math.max(scale.min, value));
650
754
  const geometry = barGeometry(band, series.length, spec.grouping);
755
+ const labels = [];
651
756
  series.forEach((s, j) => {
652
757
  const color = c.colors[j % c.colors.length];
653
758
  stacks[j].forEach((point, i) => {
@@ -655,19 +760,25 @@ function drawBars(c, spec, series, stacks, { plot, band, at, horizontal, scale }
655
760
  const slot = geometry.clustered ? j * geometry.width : 0;
656
761
  const a = at(base(point.from)), b = at(point.to);
657
762
  const path = `${c.path}.data.rows.${i}.${s.column}`;
763
+ let rect;
658
764
  if (horizontal) {
659
765
  // Office bar charts draw the first category and first series nearest the origin (bottom).
660
766
  const y = plot.y + plot.height - i * band - geometry.offset - slot - geometry.width;
661
- c.mark("rect", { x: Math.min(a, b), y, width: Math.abs(b - a), height: geometry.width, fill: color }, path);
767
+ rect = { x: Math.min(a, b), y, width: Math.abs(b - a), height: geometry.width };
768
+ c.mark("rect", { ...rect, fill: color }, path);
662
769
  } else {
663
- const x = plot.x + i * band + geometry.offset + slot;
664
- c.mark("rect", { x, y: Math.min(a, b), width: geometry.width, height: Math.abs(b - a), fill: color }, path);
770
+ const x = plot.x + categorySlot(i) * band + geometry.offset + slot;
771
+ rect = { x, y: Math.min(a, b), width: geometry.width, height: Math.abs(b - a) };
772
+ c.mark("rect", { ...rect, fill: color }, path);
665
773
  }
774
+ if (c.dataLabels) labels.push({ rect, direction: horizontal ? (point.to >= point.from ? "right" : "left") : (point.to >= point.from ? "up" : "down"), path, color, text: labelString(c, { category: c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]), value: s.values[i] }) });
666
775
  });
667
776
  });
777
+ // RR-35: data labels sit on top of every bar.
778
+ for (const label of labels) drawBarLabel(c, label.text, label.rect, label.direction, label.path, label.color);
668
779
  }
669
780
 
670
- function drawLines(c, spec, series, stacks, { plot, band, at }) {
781
+ function drawLines(c, spec, series, stacks, { plot, band, at, categorySlot = (i) => i }) {
671
782
  const radius = 3 * c.pt;
672
783
  series.forEach((s, j) => {
673
784
  const color = c.colors[j % c.colors.length];
@@ -678,22 +789,32 @@ function drawLines(c, spec, series, stacks, { plot, band, at }) {
678
789
  };
679
790
  stacks[j].forEach((point, i) => {
680
791
  if (!point) { flush(); return; }
681
- run.push([plot.x + (i + 0.5) * band, at(point.to)]);
792
+ run.push([plot.x + (categorySlot(i) + 0.5) * band, at(point.to)]);
682
793
  });
683
794
  flush();
684
795
  if (spec.markers) stacks[j].forEach((point, i) => {
685
- if (point) c.mark("circle", { cx: plot.x + (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}`);
796
+ 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}`);
686
797
  });
687
798
  });
799
+ // RR-35: data labels sit beside each point, over every line.
800
+ if (c.dataLabels) series.forEach((s, j) => stacks[j].forEach((point, i) => {
801
+ 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] }), [plot.x + (i + 0.5) * band, at(point.to)], `${c.path}.data.rows.${i}.${s.column}`);
802
+ }));
688
803
  }
689
804
 
690
- function drawAreas(c, series, stacks, { plot, band, at, crossing }) {
805
+ function drawAreas(c, series, stacks, { plot, band, at, crossing, categorySlot = (i) => i }) {
691
806
  series.forEach((s, j) => {
692
- const points = stacks[j].map((point, i) => ({ x: plot.x + (i + 0.5) * band, from: point.from, to: point.to }));
807
+ const points = stacks[j].map((point, i) => ({ x: plot.x + (categorySlot(i) + 0.5) * band, from: point.from, to: point.to }));
693
808
  const upper = points.map((p) => `${c.num(p.x)} ${c.num(at(p.to))}`);
694
809
  const lower = points.slice().reverse().map((p) => `${c.num(p.x)} ${c.num(p.from === 0 ? crossing : at(p.from))}`);
695
810
  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}`);
696
811
  });
812
+ // RR-35: an area's label sits in the area at each category, halfway between its lower and upper edge.
813
+ if (c.dataLabels) series.forEach((s, j) => stacks[j].forEach((point, i) => {
814
+ if (!point) return;
815
+ const lowerEdge = point.from === 0 ? crossing : at(point.from);
816
+ drawCenteredLabel(c, labelString(c, { category: c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]), value: s.values[i] }), plot.x + (i + 0.5) * band, (lowerEdge + at(point.to)) / 2, `${c.path}.data.rows.${i}.${s.column}`, c.colors[j % c.colors.length]);
817
+ }));
697
818
  }
698
819
 
699
820
  // ---------------------------------------------------------------------------
@@ -708,10 +829,14 @@ function renderCircularChart(c, spec) {
708
829
  })));
709
830
  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) };
710
831
  const cx = area.x + area.width / 2, cy = area.y + area.height / 2;
711
- const r = Math.max(1, Math.min(area.width, area.height) / 2 * 0.9);
712
- const inner = spec.kind === "doughnut" ? r * 0.5 : 0;
713
832
  const total = values.reduce((sum, value) => sum + value, 0);
714
833
  if (!Number.isFinite(total)) throw chartAggregateError(c.path, "slice total");
834
+ // RR-35: a slice's label text (category, the signed value, the share of the total); outside-end labels shrink the pie to leave room.
835
+ const sliceTexts = c.dataLabels ? values.map((value, i) => labelString(c, { category: categories[i], value: chartNumber(c.rows[i][1]) ?? 0, share: total ? value / total : 0 })) : null;
836
+ const outside = sliceTexts && spec.kind === "pie" && (c.dataLabels.position ?? "outside-end") === "outside-end";
837
+ const reserve = outside ? outsideLabelReserve(c, sliceTexts) : { x: 0, y: 0 };
838
+ const r = outside ? Math.max(1, Math.min(area.width / 2 - reserve.x, area.height / 2 - reserve.y)) : Math.max(1, Math.min(area.width, area.height) / 2 * 0.9);
839
+ const inner = spec.kind === "doughnut" ? r * 0.5 : 0;
715
840
  if (!total) {
716
841
  c.text("No positive chart values", area, c.path);
717
842
  return;
@@ -729,6 +854,14 @@ function renderCircularChart(c, spec) {
729
854
  }
730
855
  angle = end;
731
856
  });
857
+ if (sliceTexts) {
858
+ let start = -Math.PI / 2;
859
+ values.forEach((value, i) => {
860
+ const delta = value / total * Math.PI * 2;
861
+ 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]);
862
+ start += delta;
863
+ });
864
+ }
732
865
  }
733
866
 
734
867
  function polar(cx, cy, r, angle) {
@@ -776,6 +909,7 @@ function renderScatterChart(c) {
776
909
  plot.width = Math.max(1, box.x + box.width - pad - legendWidth - fontPx - plot.x);
777
910
  const xLabelWidth = Math.max(c.width(formatTick(xs.length ? Math.max(...xs) : 1)), c.width(formatTick(xs.length ? Math.min(...xs) : 0))) + fontPx;
778
911
  const xScale = niceScale(xs.length ? Math.min(...xs) : 0, xs.length ? Math.max(...xs) : 1, maxIntervalsFor(plot.width, xLabelWidth));
912
+ c.plotArea = plot;
779
913
  const xAt = (v) => plot.x + axisFraction(v, xScale) * plot.width;
780
914
  const yAt = (v) => plot.y + axisFraction(v, yScale, true) * plot.height;
781
915
  for (const tick of yScale.ticks) {
@@ -794,6 +928,10 @@ function renderScatterChart(c) {
794
928
  const color = c.colors[j % c.colors.length];
795
929
  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}`);
796
930
  });
931
+ // RR-35: a scatter label shows the Y value, and the X value as its category.
932
+ if (c.dataLabels) series.forEach((s) => {
933
+ for (const point of s.points) drawPointLabel(c, labelString(c, { category: String(clean(point.x)), value: point.y }), [xAt(point.x), yAt(point.y)], `${c.path}.data.rows.${point.row}.${s.column}`);
934
+ });
797
935
  }
798
936
 
799
937
  // ---------------------------------------------------------------------------
@@ -883,6 +1021,10 @@ function renderRadarChart(c, spec) {
883
1021
  const label = formatTick(tick), width = c.width(label) + fontPx * 0.5;
884
1022
  c.text(label, { x: cx - width - fontPx * 0.2, y: cy - rAt(tick) - c.lineHeight / 2, width, height: c.lineHeight }, c.path, "right");
885
1023
  }
1024
+ // RR-35: radar data labels sit above each point (PowerPoint has no position choice for a radar).
1025
+ if (c.dataLabels) series.forEach((s) => s.values.forEach((v, i) => {
1026
+ if (v !== null) drawPointLabel(c, labelString(c, { category: categories[i], value: v }), point(i, v), `${c.path}.data.rows.${i}.${s.column}`);
1027
+ }));
886
1028
  }
887
1029
 
888
1030
  // ---------------------------------------------------------------------------
@@ -958,6 +1100,7 @@ function chartexPlot(c, { categories, legendWidth = 0, tickLabels = [], percentA
958
1100
  plot: { x, y: top, width: Math.max(1, right - x), height: Math.max(1, box.y + box.height - pad - reserve - top) }
959
1101
  }));
960
1102
  drawBottomLabels(c, plan, plot, categories);
1103
+ c.plotArea = plot;
961
1104
  return { plot, band: plot.width / Math.max(1, categories.length) };
962
1105
  }
963
1106
 
@@ -997,6 +1140,11 @@ function renderHistogramChart(c, spec) {
997
1140
  const y = at(bar.value);
998
1141
  c.mark("rect", { x: plot.x + i * band + offset, y: Math.min(y, crossing), width, height: Math.abs(crossing - y), fill: c.colors[0] }, bar.path);
999
1142
  });
1143
+ // RR-35: data labels on the columns (a histogram label is the bin's count).
1144
+ if (c.dataLabels) bars.forEach((bar, i) => {
1145
+ const y = at(bar.value);
1146
+ drawBarLabel(c, labelString(c, { category: bar.name, value: bar.value }), { x: plot.x + i * band + offset, y: Math.min(y, crossing), width, height: Math.abs(crossing - y) }, bar.value >= 0 ? "up" : "down", bar.path, c.colors[0]);
1147
+ });
1000
1148
  if (pareto && bars.length) {
1001
1149
  // Percentage axis on the right, 0-100%, and the cumulative line through the bar centres.
1002
1150
  const percent = niceScale(0, 1, maxIntervalsFor(plot.height, c.lineHeight * 1.2), { percent: true });
@@ -1041,6 +1189,11 @@ function renderWaterfallChart(c) {
1041
1189
  const next = bars[k + 1];
1042
1190
  if (next) c.mark("line", { x1: x + width, y1: b, x2: plot.x + next.i * band + offset, y2: b, stroke: c.axisColor, "stroke-width": c.pt });
1043
1191
  });
1192
+ // RR-35: data labels show each step's value (not the running total) on the floating bars.
1193
+ if (c.dataLabels) bars.forEach((bar) => {
1194
+ const a = at(bar.from), b = at(bar.to);
1195
+ drawBarLabel(c, labelString(c, { category: categories[bar.i], value: bar.value }), { x: plot.x + bar.i * band + offset, y: Math.min(a, b), width, height: Math.abs(b - a) }, bar.value >= 0 ? "up" : "down", `${c.path}.data.rows.${bar.i}.${column}`, c.colors[bar.value < 0 ? 1 : 0]);
1196
+ });
1044
1197
  }
1045
1198
 
1046
1199
  // Funnel: centred bars from the top, widths proportional to the value, value labels inside and category labels on the left.
@@ -1051,6 +1204,7 @@ function renderFunnelChart(c) {
1051
1204
  const max = Math.max(0, ...values.filter((value) => value !== null));
1052
1205
  const labelWidth = Math.min(box.width * 0.3, Math.max(0, ...categories.map((name) => c.width(flatLabel(name)))) + fontPx * 0.5);
1053
1206
  const plot = { x: box.x + pad + labelWidth + pad / 2, y: box.y + pad, width: Math.max(1, box.width - 2 * pad - labelWidth - pad / 2), height: Math.max(1, box.height - 2 * pad) };
1207
+ c.plotArea = plot;
1054
1208
  const band = plot.height / count, height = band / 1.06, offset = (band - height) / 2;
1055
1209
  const entries = categories.map((name, i) => ({ name, path: `${c.path}.data.rows.${i}.0` }));
1056
1210
  drawRowLabels(c, planRowLabels(c, entries, band, labelWidth), entries, band, labelWidth, box.x + pad, (i) => plot.y + i * band);
@@ -1061,7 +1215,9 @@ function renderFunnelChart(c) {
1061
1215
  const width = plot.width * Math.min(1, value / max);
1062
1216
  const x = plot.x + (plot.width - width) / 2;
1063
1217
  c.mark("rect", { x, y: y + offset, width, height, fill: c.colors[0] }, `${c.path}.data.rows.${i}.${column}`);
1064
- c.text(formatTick(value), { x: plot.x, y: y + offset, width: plot.width, height }, `${c.path}.data.rows.${i}.${column}`);
1218
+ // RR-35: the funnel labels its bars with values by default; dataLabels picks the content, false removes them.
1219
+ if (c.dataLabelsOff) return;
1220
+ c.text(c.dataLabels ? labelString(c, { category: name, value }) : formatTick(value), { x: plot.x, y: y + offset, width: plot.width, height }, `${c.path}.data.rows.${i}.${column}`);
1065
1221
  });
1066
1222
  }
1067
1223
 
@@ -1127,8 +1283,10 @@ function renderTreemapChart(c) {
1127
1283
  rects.forEach((rect, i) => {
1128
1284
  if (!rect || !(rect.width > 0) || !(rect.height > 0)) return;
1129
1285
  c.mark("rect", { x: rect.x, y: rect.y, width: rect.width, height: rect.height, fill: c.colors[i % c.colors.length], stroke: "#FFFFFF", "stroke-width": c.pt }, `${c.path}.data.rows.${i}.${column}`);
1130
- if (rect.width >= fontPx * 2 && rect.height >= c.lineHeight) {
1131
- c.text(categories[i], { x: rect.x + fontPx * 0.25, y: rect.y, width: rect.width - fontPx * 0.5, height: rect.height }, `${c.path}.data.rows.${i}.0`);
1286
+ // RR-35: the treemap labels its tiles with category names by default; dataLabels picks the content, false removes them. PowerPoint
1287
+ // draws the label at the bottom left of its tile whatever the position attribute says (native check 2026-10-01), so the preview does.
1288
+ if (!c.dataLabelsOff && rect.width >= fontPx * 2 && rect.height >= c.lineHeight) {
1289
+ c.text(c.dataLabels ? labelString(c, { category: categories[i], value: values[i] }) : categories[i], { x: rect.x + fontPx * 0.25, y: rect.y + rect.height - c.lineHeight - fontPx * 0.2, width: rect.width - fontPx * 0.5, height: c.lineHeight }, `${c.path}.data.rows.${i}.0`, "left");
1132
1290
  }
1133
1291
  });
1134
1292
  }
@@ -0,0 +1,32 @@
1
+ // Colour fonts (FF-45). Noto Color Emoji ships COLRv1 and OT-SVG colour glyphs with empty outlines in `glyf`. Browsers
2
+ // draw them in colour (Chromium and Firefox from COLRv1, Safari from the SVG table), and fontkit shapes and measures the
3
+ // face, so the SVG names it. resvg (the raster path) draws neither COLRv1 nor OT-SVG glyphs and would paint nothing, so
4
+ // the raster path draws the package's monochrome stand-in (Noto Emoji) instead: measured advances are pinned by
5
+ // textLength where runs are positioned, and the raster shows a black-and-white silhouette, which is a documented limit.
6
+ import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
7
+
8
+ /** Every colour face of the manifest: its family, package-relative file, colour format and the monochrome family the raster path draws. */
9
+ export const COLOR_FONT_FACES = Object.freeze(BUNDLED_FONT_MANIFEST.packages.filter(pkg => pkg.color).flatMap(pkg => pkg.faces.map(face => Object.freeze({
10
+ family: face.family, file: face.file, package: pkg.name, format: pkg.color.format, rasterFamily: pkg.color.rasterFamily,
11
+ }))));
12
+ const rasterFamilyOf = new Map(COLOR_FONT_FACES.map(face => [face.family.toLowerCase(), face.rasterFamily]));
13
+ const colorFiles = COLOR_FONT_FACES.map(face => face.file.replace(/\\/g, "/"));
14
+
15
+ /** `files` without the colour faces, which resvg cannot draw (they would also catch its glyph fallback and paint nothing). */
16
+ export function rasterFontFiles(files) {
17
+ return files.filter(file => { const normalized = String(file).replace(/\\/g, "/"); return !colorFiles.some(suffix => normalized.endsWith(`/${suffix}`)); });
18
+ }
19
+
20
+ const fontFamilyAttribute = /(\sfont-family\s*=\s*)(?:"([^"]*)"|'([^']*)')/g;
21
+ const unquote = name => name.trim().replace(/^(["'])(.*)\1$/, "$2");
22
+ /** SVG about to be rasterized only (never the emitted SVG): text in a colour family is drawn with its monochrome stand-in. */
23
+ export function monochromeColorFonts(svg) {
24
+ if (!rasterFamilyOf.size || !/<(?:text|tspan)[\s>]/.test(svg)) return svg;
25
+ return svg.replace(fontFamilyAttribute, (match, prefix, doubleQuoted, singleQuoted) => {
26
+ const value = doubleQuoted ?? singleQuoted;
27
+ const families = value.split(",");
28
+ if (!families.some(name => rasterFamilyOf.has(unquote(name).toLowerCase()))) return match;
29
+ const replaced = families.map(name => rasterFamilyOf.get(unquote(name).toLowerCase()) ?? name.trim()).join(", ");
30
+ return `${prefix}"${replaced}"`;
31
+ });
32
+ }