@openpresentation/opf-render 0.12.2 → 0.13.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
@@ -378,6 +378,8 @@ Chartex chart previews (FF-22b) draw the constructs opf-pptx exports as Office 2
378
378
 
379
379
  Chart options (RR-35): a chart's optional `axisTitles`, `legend` and `dataLabels` (core `docs/chart-options.md`) are drawn. A named legend position is carved from the chart box before the plot is laid out, axis titles sit beside the axes (the value title rotated 270 degrees), and data labels follow the content (category, value, percent), position and separator the chart type supports. An option a type cannot show is dropped with a `chart-option-adapted` diagnostic; a chart without the fields draws as before.
380
380
 
381
+ Chart and table data (RR-54, needs the core release with `resolveChartData`; core `docs/chart-table-data.md`): every chart path plots the data core resolves, so `DataColumn` headers, `{ dataset, fields }` references to the top-level `datasets` map and `chart.mapping` (category, scatter X and series by column name) all draw, and a series is labelled with its column `name`. Chart numbers are core's strict `chartNumber` (`"12%"`, `"(5)"` and `"Q1"` are gaps, `"1e6"` is 1000000). A column's number `format` shapes its data labels and the value-axis tick labels (the first plotted series' format; the scatter X axis follows the X column; percent axes and pie percent labels stay percent; a 100% stacked chart's data labels show the values in their own format, as PowerPoint does), and table cells draw the formatted text. A formatted horizontal axis (horizontal bars, the scatter X axis) pulls its plot in until the first and last tick labels stay inside the chart; a General axis keeps its placement. A dataset chart or table draws exactly its inline equivalent and traces every part to its authored `slides.N.chart` or `slides.N.table` path; a chart with `mapping` traces each part to the authored column. The legacy sketch (a chart type outside the catalog) plots a gap as nothing. A document without the new fields draws byte for byte as before; a `ChartDataSource` still draws the "No chart data" placeholder.
382
+
381
383
  Classic chart previews keep finite axis coordinates for subnormal values and values up to `Number.MAX_VALUE`, including mixed positive/negative ranges. At these limits, available numeric precision can merge adjacent axis ticks or prevent extra headroom. Authored values are unchanged. Stacked, percentage or pie/doughnut totals that overflow the finite numeric range are still unsupported: rendering throws a `RangeError` naming the chart path and asking the caller to rescale the values. It does not substitute an empty chart or silently discard the overflowing values.
382
384
 
383
385
  Category-axis labels follow PowerPoint's automatic labelling instead of wrapping inside a band (column, bar, line and area charts, including stacked and 100%, and the chartex histogram, pareto, waterfall, funnel and box-and-whisker previews). A label is never broken inside a word: it stays on one line, or splits at its spaces onto two lines when a band is too narrow, so `Category 1` cannot become `Ca` over `t 1`. When the labels do not fit horizontally they turn to -45 degrees, then -90 degrees (the plot area shrinks to make room, by at most 40% of the chart height, and a slanted first label must stay inside the chart), and when even that collides the axis draws every n-th label (Office's automatic `tickLblSkip`): n is the smallest interval that leaves no two drawn labels overlapping, the first label is always drawn, and ties go to the less rotated arrangement. In horizontal bar charts and funnels, where the category axis is vertical, rows shorter than a line of text are skipped the same way, and the label gutter grows with the longest label up to 30% of the chart width. Only a label wider than that gutter (or wider than the whole chart on a horizontal axis) is ellipsized, and then the renderer reports one `chart-label-truncated` diagnostic per chart with the affected label paths in `labels`; nothing is shortened silently, and no arrangement raises `text-overflow` for an axis label. Radar spoke labels use the same one-line, two-line or ellipsis rule and leave out a spoke label that would collide with one already drawn. Rotated labels are wrapped in a `<g transform="rotate(...)">` around their usual text group, so the traced `data-opf-box-*` attributes describe the unrotated box. PowerPoint applies its own automatic rotation and skip to the exported chart: opf-pptx writes an automatic axis text body (`<a:bodyPr/>`, no `rot`) and no `tickLblSkip` for these axes, so nothing forces a wrap, and the preview and the export make the same kind of choice, not identical ones (the export uses 9 pt labels, the preview the readability floor).
@@ -79,12 +79,12 @@ export function drawAxisTitles(c) {
79
79
 
80
80
  const INSIDE = new Set(["center", "inside-end", "inside-base"]);
81
81
 
82
- /** The label text for one mark: the selected parts, General number format, integer percent. */
83
- export function labelString(c, { category, value, share }) {
82
+ /** The label text for one mark: the selected parts, the series' number format (General when it has none), integer percent. */
83
+ export function labelString(c, { category, value, share, format }) {
84
84
  const labels = c.dataLabels;
85
85
  const parts = {};
86
86
  if (category !== undefined && category !== null) parts.category = String(category);
87
- if (typeof value === "number" && Number.isFinite(value)) parts.value = core.formatChartLabelNumber(value);
87
+ if (typeof value === "number" && Number.isFinite(value)) parts.value = format !== undefined && typeof core.formatDataNumber === "function" ? core.formatDataNumber(value, format) : core.formatChartLabelNumber(value);
88
88
  if (typeof share === "number" && Number.isFinite(share)) parts.percent = core.formatChartLabelPercent(share);
89
89
  return core.chartLabelText(parts, labels.content, labels.separator);
90
90
  }
package/dist/charts.js CHANGED
@@ -140,11 +140,9 @@ export function renderCatalogChart(item, box, bound, options, svg) {
140
140
  const id = resolveChartType(item.value?.type);
141
141
  const spec = id && CHART_TYPES[id];
142
142
  const render = spec && RENDERERS[spec.kind];
143
- const data = item.value?.data;
144
- const rows = Array.isArray(data?.rows) ? data.rows.map((row) => Array.isArray(row) ? row : [row]) : [];
145
- const columns = Array.isArray(data?.columns) ? data.columns : [];
146
- if (!render || !rows.length || columns.length < (CHARTEX_KINDS.has(spec.kind) ? 1 : 2)) return null;
147
- const c = chartContext(item, box, bound, options, svg, rows, columns);
143
+ const data = render ? chartData(item, bound) : null;
144
+ if (!data || !data.rows.length || data.columns.length < (CHARTEX_KINDS.has(spec.kind) ? 1 : 2)) return null;
145
+ const c = chartContext(item, box, bound, options, svg, data);
148
146
  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
147
  // 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
148
  // with none of them skips this block entirely, so its output is unchanged.
@@ -161,7 +159,62 @@ export function renderCatalogChart(item, box, bound, options, svg) {
161
159
  return svg.tag("g", { ...svg.traceAttrs(options, item.path), "data-opf-chart": options.trace ? id : undefined }, c.children.join("\n"));
162
160
  }
163
161
 
164
- function chartContext(item, box, bound, options, svg, rows, columns) {
162
+ // RR-54: the chart's data as the renderers plot it, from core's resolveChartData: inline columns (names or DataColumn objects),
163
+ // a dataset reference and chart.mapping all resolve to one positional table, [category, (x,) ...series], with each column's number
164
+ // 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.
167
+ export function chartData(item, bound) {
168
+ 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 };
179
+ }
180
+
181
+ // The authored object at a composed path ("slides.0.blocks.1.chart"), or undefined.
182
+ function authoredAt(presentation, path) {
183
+ let value = presentation;
184
+ for (const key of String(path).split(".")) {
185
+ if (value === null || typeof value !== "object") return undefined;
186
+ value = value[key];
187
+ }
188
+ return value;
189
+ }
190
+
191
+ /** True when the chart's authored data is a dataset reference: its parts then report the chart's own path. */
192
+ export function isDatasetChart(item, bound) {
193
+ const authored = authoredAt(bound?.presentation, item.path);
194
+ const data = authored && typeof authored === "object" ? authored.data : undefined;
195
+ return Boolean(data) && typeof data === "object" && typeof data.dataset === "string";
196
+ }
197
+
198
+ /** True when the table's authored form is a dataset reference (`{ dataset, fields }`): its cells then report the table's own path. */
199
+ export function isDatasetTable(item, bound) {
200
+ const authored = authoredAt(bound?.presentation, item.path);
201
+ return Boolean(authored) && typeof authored === "object" && typeof authored.dataset === "string";
202
+ }
203
+
204
+ // 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).
207
+ function tracePaths(item, bound, resolved) {
208
+ if (isDatasetChart(item, bound)) return { collapse: true };
209
+ const authored = authoredAt(bound?.presentation, item.path);
210
+ const authoredData = authored && typeof authored === "object" ? authored.data : undefined;
211
+ if (!authored || typeof authored !== "object" || !authored.mapping || !Array.isArray(authoredData?.columns)) return null;
212
+ const names = authoredData.columns.map((column) => column !== null && typeof column === "object" ? column.name : column);
213
+ const columnMap = resolved.columns.map((name, index) => { const found = names.indexOf(name); return found < 0 ? index : found; });
214
+ return columnMap.every((column, index) => column === index) ? null : { columnMap };
215
+ }
216
+
217
+ function chartContext(item, box, bound, options, svg, { rows, columns, formats, trace }) {
165
218
  const u = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
166
219
  const composition = bound.composition ?? bound.geometry.composition ?? {};
167
220
  const requested = 14, fontPx = Math.max(requested, composition.minFontSize ?? 16) * u;
@@ -172,8 +225,14 @@ function chartContext(item, box, bound, options, svg, rows, columns) {
172
225
  const children = [];
173
226
  const n = svg.stableNumber;
174
227
  const numericAttrs = new Set(["x", "y", "width", "height", "cx", "cy", "r", "x1", "x2", "y1", "y2", "stroke-width"]);
228
+ const prefix = `${item.path}.data.`;
229
+ const tracePath = !trace ? (path) => path : (path) => {
230
+ if (typeof path !== "string" || !path.startsWith(prefix)) return path;
231
+ if (trace.collapse) return item.path;
232
+ return path.replace(/^(.*\.data\.(?:columns\.|rows\.\d+\.))(\d+)$/, (whole, head, column) => `${head}${trace.columnMap[Number(column)] ?? column}`);
233
+ };
175
234
  const c = {
176
- item, box, bound, options, svg, rows, columns, children, u, fontPx, surface, labelColor,
235
+ item, box, bound, options, svg, rows, columns, formats, children, u, fontPx, surface, labelColor,
177
236
  path: item.path,
178
237
  pt: u * 4 / 3,
179
238
  pad: 10 * u,
@@ -192,12 +251,12 @@ function chartContext(item, box, bound, options, svg, rows, columns) {
192
251
  mark(name, attrs, path) {
193
252
  const out = {};
194
253
  for (const [key, value] of Object.entries(attrs)) out[key] = numericAttrs.has(key) && typeof value === "number" ? n(value) : value;
195
- children.push(svg.tag(name, { ...out, ...(path ? svg.traceAttrs(options, path) : {}) }));
254
+ children.push(svg.tag(name, { ...out, ...(path ? svg.traceAttrs(options, tracePath(path)) : {}) }));
196
255
  },
197
256
  textElement(value, rect, path, align = "center", fill = labelColor) {
198
257
  if (!(rect.width > 0 && rect.height > 0)) return "";
199
258
  return svg.renderTextBox(String(value), rect, bound, {
200
- path, fontSize: requested, fontFamily: bound.design.fonts.body, fontWeight: 400,
259
+ path: tracePath(path), fontSize: requested, fontFamily: bound.design.fonts.body, fontWeight: 400,
201
260
  fill, options, align, verticalAlign: "middle"
202
261
  });
203
262
  },
@@ -208,6 +267,7 @@ function chartContext(item, box, bound, options, svg, rows, columns) {
208
267
  series(first = 1) {
209
268
  return columns.slice(first).map((name, offset) => ({
210
269
  name: c.label(name), column: first + offset, index: offset,
270
+ ...(formats[first + offset] !== undefined ? { format: formats[first + offset] } : {}),
211
271
  values: rows.map((row) => chartNumber(row[first + offset]))
212
272
  }));
213
273
  }
@@ -215,7 +275,10 @@ function chartContext(item, box, bound, options, svg, rows, columns) {
215
275
  return c;
216
276
  }
217
277
 
278
+ // 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.
218
280
  export function chartNumber(value) {
281
+ if (typeof opfComposition.chartNumber === "function") return opfComposition.chartNumber(value);
219
282
  if (typeof value === "number") return Number.isFinite(value) ? value : null;
220
283
  if (typeof value === "string" && value.trim()) {
221
284
  const parsed = Number(value);
@@ -224,6 +287,12 @@ export function chartNumber(value) {
224
287
  return null;
225
288
  }
226
289
 
290
+ /** A number as its column's format shows it (core's formatDataNumber); without a format, the General form the preview always drew. */
291
+ export function formatChartValue(value, format) {
292
+ if (format === undefined || typeof opfComposition.formatDataNumber !== "function") return String(clean(value));
293
+ return opfComposition.formatDataNumber(clean(value), format);
294
+ }
295
+
227
296
  // ---------------------------------------------------------------------------
228
297
  // Shared axis and legend helpers
229
298
 
@@ -308,10 +377,28 @@ function clean(value) {
308
377
  return Object.is(rounded, -0) ? 0 : rounded;
309
378
  }
310
379
 
311
- /** Office General / 0% tick labels. */
312
- export function formatTick(value, percent = false) {
380
+ /** Office General / 0% tick labels; a value axis takes its first plotted series' number format (RR-54). */
381
+ export function formatTick(value, percent = false, format = undefined) {
313
382
  if (percent) return `${clean(value * 100)}%`;
314
- return String(clean(value));
383
+ return format === undefined ? String(clean(value)) : formatChartValue(value, format);
384
+ }
385
+
386
+ // RR-54: a formatted horizontal axis (horizontal bars, the scatter X axis) can have end tick labels far wider than the margin the layout
387
+ // leaves beside the plot (a General label is a few characters, a long format code is not). Pull the plot in until the first and
388
+ // last labels, centred on their ticks, stay inside the chart, and lay the ticks out again for the new width. Only formatted axes call it,
389
+ // so a General axis keeps its placement.
390
+ function insetForEndLabels(c, x, width, format, scaleFor) {
391
+ const left = c.box.x + c.pad, right = c.box.x + c.box.width - c.pad;
392
+ let scale = scaleFor(width);
393
+ for (let pass = 0; pass < 3; pass++) {
394
+ const ticks = scale.ticks;
395
+ if (!ticks.length) break;
396
+ const first = c.width(formatTick(ticks[0], false, format)) / 2, last = c.width(formatTick(ticks[ticks.length - 1], false, format)) / 2;
397
+ const nextX = Math.max(x, left + first), nextWidth = Math.max(1, Math.min(x + width, right - last) - nextX);
398
+ if (nextX === x && nextWidth === width) break;
399
+ x = nextX; width = nextWidth; scale = scaleFor(width);
400
+ }
401
+ return { x, width, scale };
315
402
  }
316
403
 
317
404
  function legendMetrics(c, entries) {
@@ -352,7 +439,7 @@ function legendSource(c, spec) {
352
439
  switch (spec.kind) {
353
440
  case "pie":
354
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` }));
355
- case "scatter": return fromSeries(scatterSeries(c.rows, c.columns).series, "marker");
442
+ case "scatter": return fromSeries(scatterSeries(c.rows, c.columns, c.formats).series, "marker");
356
443
  case "line": return fromSeries(c.series(), "line", spec.markers);
357
444
  case "radar": return fromSeries(c.series(), spec.style === "filled" ? undefined : "line", spec.markers);
358
445
  case "box": return fromSeries(c.columns.length === 1 ? [{ name: c.label(c.columns[0]), column: 0, index: 0 }] : c.series());
@@ -661,6 +748,7 @@ function renderCategoryChart(c, spec) {
661
748
  const horizontal = spec.kind === "bar" && spec.dir === "bar";
662
749
  const percent = spec.grouping === "percentStacked";
663
750
  const series = c.series();
751
+ const valueFormat = series[0]?.format;
664
752
  const categories = c.rows.map((row) => c.label(row[0]));
665
753
  const count = categories.length;
666
754
  // Right to left (RR-05): column, line and area charts run their category axis from the right (PowerPoint's reversed categories,
@@ -680,11 +768,14 @@ function renderCategoryChart(c, spec) {
680
768
  let plot, scale, valueTickWidth;
681
769
  if (horizontal) {
682
770
  const categoryWidth = Math.min(box.width * 0.3, Math.max(0, ...categories.map((name) => c.width(flatLabel(name)))) + fontPx * 0.5);
683
- const x = box.x + pad + categoryWidth + c.pad / 2;
771
+ let x = box.x + pad + categoryWidth + c.pad / 2;
684
772
  const bottom = box.y + box.height - pad - c.lineHeight;
685
- const width = Math.max(1, right - x - fontPx);
686
- const tickWidth = Math.max(c.width(formatTick(dataMax, percent)), c.width(formatTick(dataMin, percent)), c.width("100%")) + fontPx;
687
- scale = niceScale(dataMin, dataMax, maxIntervalsFor(width, tickWidth), { percent });
773
+ let width = Math.max(1, right - x - fontPx);
774
+ const tickWidth = Math.max(c.width(formatTick(dataMax, percent, valueFormat)), c.width(formatTick(dataMin, percent, valueFormat)), c.width("100%")) + fontPx;
775
+ const scaleFor = (w) => niceScale(dataMin, dataMax, maxIntervalsFor(w, tickWidth), { percent });
776
+ scale = scaleFor(width);
777
+ // RR-54: a formatted value axis keeps its end labels inside the chart.
778
+ if (valueFormat !== undefined && !percent) ({ x, width, scale } = insetForEndLabels(c, x, width, valueFormat, scaleFor));
688
779
  plot = { x, y: top, width, height: Math.max(1, bottom - top) };
689
780
  // Office bar charts draw the first category nearest the origin (bottom).
690
781
  const entries = categories.map((name, i) => ({ name, path: `${c.path}.data.rows.${i}.0` }));
@@ -696,7 +787,7 @@ function renderCategoryChart(c, spec) {
696
787
  const axis = planCategoryAxis(c, entries, (reserve) => {
697
788
  const height = Math.max(1, box.y + box.height - pad - reserve - top);
698
789
  const fitted = niceScale(dataMin, dataMax, maxIntervalsFor(height, c.lineHeight * 1.2), { percent });
699
- const tickWidth = Math.max(...fitted.ticks.map((tick) => c.width(formatTick(tick, percent)))) + fontPx * 0.5;
790
+ const tickWidth = Math.max(...fitted.ticks.map((tick) => c.width(formatTick(tick, percent, valueFormat)))) + fontPx * 0.5;
700
791
  if (rtl) {
701
792
  const x = box.x + pad + fontPx / 2;
702
793
  return { scale: fitted, plot: { x, y: top, width: Math.max(1, right - tickWidth - x), height }, tickWidth };
@@ -713,7 +804,7 @@ function renderCategoryChart(c, spec) {
713
804
  // Category axis crosses at zero when zero is on the axis (autoZero).
714
805
  const crossing = at(Math.min(scale.max, Math.max(scale.min, 0)));
715
806
  for (const tick of scale.ticks) {
716
- const position = at(tick), label = formatTick(tick, percent);
807
+ const position = at(tick), label = formatTick(tick, percent, valueFormat);
717
808
  if (horizontal) {
718
809
  gridline(c, position, plot.y, position, plot.y + plot.height);
719
810
  const width = c.width(label) + fontPx;
@@ -771,7 +862,7 @@ function drawBars(c, spec, series, stacks, { plot, band, at, horizontal, scale,
771
862
  rect = { x, y: Math.min(a, b), width: geometry.width, height: Math.abs(b - a) };
772
863
  c.mark("rect", { ...rect, fill: color }, path);
773
864
  }
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] }) });
865
+ 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], format: s.format }) });
775
866
  });
776
867
  });
777
868
  // RR-35: data labels sit on top of every bar.
@@ -798,7 +889,7 @@ function drawLines(c, spec, series, stacks, { plot, band, at, categorySlot = (i)
798
889
  });
799
890
  // RR-35: data labels sit beside each point, over every line.
800
891
  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}`);
892
+ 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}`);
802
893
  }));
803
894
  }
804
895
 
@@ -813,7 +904,7 @@ function drawAreas(c, series, stacks, { plot, band, at, crossing, categorySlot =
813
904
  if (c.dataLabels) series.forEach((s, j) => stacks[j].forEach((point, i) => {
814
905
  if (!point) return;
815
906
  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]);
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]);
817
908
  }));
818
909
  }
819
910
 
@@ -832,7 +923,7 @@ function renderCircularChart(c, spec) {
832
923
  const total = values.reduce((sum, value) => sum + value, 0);
833
924
  if (!Number.isFinite(total)) throw chartAggregateError(c.path, "slice total");
834
925
  // 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;
926
+ 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, format: c.formats[1] })) : null;
836
927
  const outside = sliceTexts && spec.kind === "pie" && (c.dataLabels.position ?? "outside-end") === "outside-end";
837
928
  const reserve = outside ? outsideLabelReserve(c, sliceTexts) : { x: 0, y: 0 };
838
929
  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);
@@ -884,12 +975,13 @@ function slicePath(c, cx, cy, r, inner, start, end) {
884
975
  // ---------------------------------------------------------------------------
885
976
  // Scatter: X from columns[1], Y series from columns[2..], markers only.
886
977
 
887
- export function scatterSeries(rows, columns) {
978
+ export function scatterSeries(rows, columns, formats = []) {
888
979
  const numericX = columns.length > 2;
889
980
  const xs = rows.map((row, i) => numericX ? chartNumber(row[1]) : i + 1);
890
981
  const first = numericX ? 2 : 1;
891
982
  const series = columns.slice(first).map((name, offset) => ({
892
983
  name: name === null || name === undefined ? "" : String(name), column: first + offset, index: offset,
984
+ ...(formats[first + offset] !== undefined ? { format: formats[first + offset] } : {}),
893
985
  points: rows.map((row, i) => ({ row: i, x: xs[i], y: chartNumber(row[first + offset]) })).filter((p) => p.x !== null && p.y !== null)
894
986
  }));
895
987
  return { series, xs };
@@ -897,28 +989,32 @@ export function scatterSeries(rows, columns) {
897
989
 
898
990
  function renderScatterChart(c) {
899
991
  const { box, pad, fontPx } = c;
900
- const { series } = scatterSeries(c.rows, c.columns);
992
+ const { series } = scatterSeries(c.rows, c.columns, c.formats);
993
+ const yFormat = series[0]?.format, xFormat = c.columns.length > 2 ? c.formats[1] : undefined;
901
994
  const points = series.flatMap((s) => s.points);
902
995
  const xs = points.map((p) => p.x), ys = points.map((p) => p.y);
903
996
  const legendWidth = seriesLegend(c, series, "marker");
904
997
  const top = box.y + pad + c.lineHeight / 2, bottom = box.y + box.height - pad - c.lineHeight;
905
998
  const height = Math.max(1, bottom - top);
906
999
  const yScale = niceScale(ys.length ? Math.min(...ys) : 0, ys.length ? Math.max(...ys) : 1, maxIntervalsFor(height, c.lineHeight * 1.2));
907
- const tickWidth = Math.max(...yScale.ticks.map((tick) => c.width(formatTick(tick)))) + fontPx * 0.5;
1000
+ const tickWidth = Math.max(...yScale.ticks.map((tick) => c.width(formatTick(tick, false, yFormat)))) + fontPx * 0.5;
908
1001
  const plot = { x: box.x + pad + tickWidth, y: top, height };
909
1002
  plot.width = Math.max(1, box.x + box.width - pad - legendWidth - fontPx - plot.x);
910
- const xLabelWidth = Math.max(c.width(formatTick(xs.length ? Math.max(...xs) : 1)), c.width(formatTick(xs.length ? Math.min(...xs) : 0))) + fontPx;
911
- const xScale = niceScale(xs.length ? Math.min(...xs) : 0, xs.length ? Math.max(...xs) : 1, maxIntervalsFor(plot.width, xLabelWidth));
1003
+ const xLabelWidth = Math.max(c.width(formatTick(xs.length ? Math.max(...xs) : 1, false, xFormat)), c.width(formatTick(xs.length ? Math.min(...xs) : 0, false, xFormat))) + fontPx;
1004
+ const scaleForX = (w) => niceScale(xs.length ? Math.min(...xs) : 0, xs.length ? Math.max(...xs) : 1, maxIntervalsFor(w, xLabelWidth));
1005
+ let xScale = scaleForX(plot.width);
1006
+ // RR-54: a formatted X axis keeps its end labels inside the chart.
1007
+ if (xFormat !== undefined) ({ x: plot.x, width: plot.width, scale: xScale } = insetForEndLabels(c, plot.x, plot.width, xFormat, scaleForX));
912
1008
  c.plotArea = plot;
913
1009
  const xAt = (v) => plot.x + axisFraction(v, xScale) * plot.width;
914
1010
  const yAt = (v) => plot.y + axisFraction(v, yScale, true) * plot.height;
915
1011
  for (const tick of yScale.ticks) {
916
1012
  const y = yAt(tick);
917
1013
  gridline(c, plot.x, y, plot.x + plot.width, y);
918
- c.text(formatTick(tick), { x: box.x + pad, y: y - c.lineHeight / 2, width: plot.x - box.x - pad - fontPx * 0.35, height: c.lineHeight }, c.path, "right");
1014
+ c.text(formatTick(tick, false, yFormat), { x: box.x + pad, y: y - c.lineHeight / 2, width: plot.x - box.x - pad - fontPx * 0.35, height: c.lineHeight }, c.path, "right");
919
1015
  }
920
1016
  for (const tick of xScale.ticks) {
921
- const label = formatTick(tick), width = c.width(label) + fontPx;
1017
+ const label = formatTick(tick, false, xFormat), width = c.width(label) + fontPx;
922
1018
  c.text(label, { x: xAt(tick) - width / 2, y: plot.y + plot.height, width, height: c.lineHeight }, c.path);
923
1019
  }
924
1020
  const yCross = yAt(Math.min(yScale.max, Math.max(yScale.min, 0))), xCross = xAt(Math.min(xScale.max, Math.max(xScale.min, 0)));
@@ -930,7 +1026,7 @@ function renderScatterChart(c) {
930
1026
  });
931
1027
  // RR-35: a scatter label shows the Y value, and the X value as its category.
932
1028
  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}`);
1029
+ 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}`);
934
1030
  });
935
1031
  }
936
1032
 
@@ -940,6 +1036,7 @@ function renderScatterChart(c) {
940
1036
  function renderRadarChart(c, spec) {
941
1037
  const { box, pad, fontPx } = c;
942
1038
  const series = c.series();
1039
+ const valueFormat = series[0]?.format;
943
1040
  const categories = c.rows.map((row) => c.label(row[0]));
944
1041
  const count = categories.length;
945
1042
  const values = series.flatMap((s) => s.values).filter((v) => v !== null);
@@ -1018,12 +1115,12 @@ function renderRadarChart(c, spec) {
1018
1115
  });
1019
1116
  // Value axis labels sit on top of filled series, as in PowerPoint.
1020
1117
  for (const tick of scale.ticks) {
1021
- const label = formatTick(tick), width = c.width(label) + fontPx * 0.5;
1118
+ const label = formatTick(tick, false, valueFormat), width = c.width(label) + fontPx * 0.5;
1022
1119
  c.text(label, { x: cx - width - fontPx * 0.2, y: cy - rAt(tick) - c.lineHeight / 2, width, height: c.lineHeight }, c.path, "right");
1023
1120
  }
1024
1121
  // RR-35: radar data labels sit above each point (PowerPoint has no position choice for a radar).
1025
1122
  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}`);
1123
+ if (v !== null) drawPointLabel(c, labelString(c, { category: categories[i], value: v, format: s.format }), point(i, v), `${c.path}.data.rows.${i}.${s.column}`);
1027
1124
  }));
1028
1125
  }
1029
1126
 
@@ -1104,12 +1201,12 @@ function chartexPlot(c, { categories, legendWidth = 0, tickLabels = [], percentA
1104
1201
  return { plot, band: plot.width / Math.max(1, categories.length) };
1105
1202
  }
1106
1203
 
1107
- function valueAxis(c, plot, scale) {
1204
+ function valueAxis(c, plot, scale, format) {
1108
1205
  const at = (value) => plot.y + axisFraction(value, scale, true) * plot.height;
1109
1206
  for (const tick of scale.ticks) {
1110
1207
  const y = at(tick);
1111
1208
  gridline(c, plot.x, y, plot.x + plot.width, y);
1112
- c.text(formatTick(tick), { x: c.box.x + c.pad, y: y - c.lineHeight / 2, width: plot.x - c.box.x - c.pad - c.fontPx * 0.35, height: c.lineHeight }, c.path, "right");
1209
+ c.text(formatTick(tick, false, format), { x: c.box.x + c.pad, y: y - c.lineHeight / 2, width: plot.x - c.box.x - c.pad - c.fontPx * 0.35, height: c.lineHeight }, c.path, "right");
1113
1210
  }
1114
1211
  const crossing = at(Math.min(scale.max, Math.max(scale.min, 0)));
1115
1212
  axisLine(c, plot.x, plot.y, plot.x, plot.y + plot.height);
@@ -1121,6 +1218,8 @@ function valueAxis(c, plot, scale) {
1121
1218
  function renderHistogramChart(c, spec) {
1122
1219
  const pareto = spec.kind === "pareto";
1123
1220
  const { hasCategories, column, values, categories } = chartexValues(c.rows, c.columns);
1221
+ // A histogram of a lone column plots bin counts, which carry no number format; with categories the bars are the column's values.
1222
+ const format = hasCategories ? c.formats[column] : undefined;
1124
1223
  let bars;
1125
1224
  if (hasCategories) {
1126
1225
  bars = values.map((value, i) => ({ value, name: categories[i], path: `${c.path}.data.rows.${i}.${column}`, labelPath: `${c.path}.data.rows.${i}.0` })).filter((bar) => bar.value !== null);
@@ -1132,9 +1231,9 @@ function renderHistogramChart(c, spec) {
1132
1231
  if (!Number.isFinite(total)) throw chartAggregateError(c.path, "cumulative total");
1133
1232
  const dataMax = bars.length ? Math.max(...bars.map((bar) => bar.value)) : 1, dataMin = bars.length ? Math.min(0, ...bars.map((bar) => bar.value)) : 0;
1134
1233
  const probe = niceScale(dataMin, dataMax, 10);
1135
- const { plot, band } = chartexPlot(c, { categories: bars.map((bar) => ({ name: bar.name, path: bar.labelPath })), tickLabels: probe.ticks.map((tick) => formatTick(tick)), percentAxis: pareto });
1234
+ const { plot, band } = chartexPlot(c, { categories: bars.map((bar) => ({ name: bar.name, path: bar.labelPath })), tickLabels: probe.ticks.map((tick) => formatTick(tick, false, format)), percentAxis: pareto });
1136
1235
  const scale = niceScale(dataMin, dataMax, maxIntervalsFor(plot.height, c.lineHeight * 1.2));
1137
- const { at, crossing } = valueAxis(c, plot, scale);
1236
+ const { at, crossing } = valueAxis(c, plot, scale, format);
1138
1237
  const width = band / 1.06, offset = (band - width) / 2;
1139
1238
  bars.forEach((bar, i) => {
1140
1239
  const y = at(bar.value);
@@ -1143,7 +1242,7 @@ function renderHistogramChart(c, spec) {
1143
1242
  // RR-35: data labels on the columns (a histogram label is the bin's count).
1144
1243
  if (c.dataLabels) bars.forEach((bar, i) => {
1145
1244
  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]);
1245
+ drawBarLabel(c, labelString(c, { category: bar.name, value: bar.value, format }), { 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
1246
  });
1148
1247
  if (pareto && bars.length) {
1149
1248
  // Percentage axis on the right, 0-100%, and the cumulative line through the bar centres.
@@ -1167,6 +1266,7 @@ function renderHistogramChart(c, spec) {
1167
1266
  // Waterfall: floating bars from the running total, increases and decreases in the first two palette colours, connector lines between bars.
1168
1267
  function renderWaterfallChart(c) {
1169
1268
  const { column, values, categories } = chartexValues(c.rows, c.columns);
1269
+ const format = c.formats[column];
1170
1270
  let running = 0;
1171
1271
  const bars = values.map((value, i) => {
1172
1272
  if (value === null) return null;
@@ -1178,9 +1278,9 @@ function renderWaterfallChart(c) {
1178
1278
  const extents = bars.flatMap((bar) => [bar.from, bar.to]);
1179
1279
  const dataMin = extents.length ? Math.min(0, ...extents) : 0, dataMax = extents.length ? Math.max(0, ...extents) : 1;
1180
1280
  const probe = niceScale(dataMin, dataMax, 10);
1181
- const { plot, band } = chartexPlot(c, { categories: categories.map((name, i) => ({ name, path: `${c.path}.data.rows.${i}.0` })), tickLabels: probe.ticks.map((tick) => formatTick(tick)) });
1281
+ const { plot, band } = chartexPlot(c, { categories: categories.map((name, i) => ({ name, path: `${c.path}.data.rows.${i}.0` })), tickLabels: probe.ticks.map((tick) => formatTick(tick, false, format)) });
1182
1282
  const scale = niceScale(dataMin, dataMax, maxIntervalsFor(plot.height, c.lineHeight * 1.2));
1183
- const { at } = valueAxis(c, plot, scale);
1283
+ const { at } = valueAxis(c, plot, scale, format);
1184
1284
  const width = band / 1.5, offset = (band - width) / 2;
1185
1285
  bars.forEach((bar, k) => {
1186
1286
  const a = at(bar.from), b = at(bar.to);
@@ -1192,7 +1292,7 @@ function renderWaterfallChart(c) {
1192
1292
  // RR-35: data labels show each step's value (not the running total) on the floating bars.
1193
1293
  if (c.dataLabels) bars.forEach((bar) => {
1194
1294
  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]);
1295
+ drawBarLabel(c, labelString(c, { category: categories[bar.i], value: bar.value, format }), { 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
1296
  });
1197
1297
  }
1198
1298
 
@@ -1200,6 +1300,7 @@ function renderWaterfallChart(c) {
1200
1300
  function renderFunnelChart(c) {
1201
1301
  const { box, pad, fontPx } = c;
1202
1302
  const { column, values, categories } = chartexValues(c.rows, c.columns);
1303
+ const format = c.formats[column];
1203
1304
  const count = categories.length;
1204
1305
  const max = Math.max(0, ...values.filter((value) => value !== null));
1205
1306
  const labelWidth = Math.min(box.width * 0.3, Math.max(0, ...categories.map((name) => c.width(flatLabel(name)))) + fontPx * 0.5);
@@ -1217,7 +1318,7 @@ function renderFunnelChart(c) {
1217
1318
  c.mark("rect", { x, y: y + offset, width, height, fill: c.colors[0] }, `${c.path}.data.rows.${i}.${column}`);
1218
1319
  // RR-35: the funnel labels its bars with values by default; dataLabels picks the content, false removes them.
1219
1320
  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}`);
1321
+ c.text(c.dataLabels ? labelString(c, { category: name, value, format }) : formatTick(value, false, format), { x: plot.x, y: y + offset, width: plot.width, height }, `${c.path}.data.rows.${i}.${column}`);
1221
1322
  });
1222
1323
  }
1223
1324
 
@@ -1271,6 +1372,7 @@ export function squarify(items, x, y, width, height) {
1271
1372
  function renderTreemapChart(c) {
1272
1373
  const { box, pad, fontPx } = c;
1273
1374
  const { column, values, categories } = chartexValues(c.rows, c.columns);
1375
+ const format = c.formats[column];
1274
1376
  const items = values.map((value) => ({ value: value !== null && value > 0 ? value : 0 }));
1275
1377
  const total = items.reduce((sum, item) => sum + item.value, 0);
1276
1378
  if (!Number.isFinite(total)) throw chartAggregateError(c.path, "tile total");
@@ -1286,7 +1388,7 @@ function renderTreemapChart(c) {
1286
1388
  // RR-35: the treemap labels its tiles with category names by default; dataLabels picks the content, false removes them. PowerPoint
1287
1389
  // 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
1390
  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");
1391
+ c.text(c.dataLabels ? labelString(c, { category: categories[i], value: values[i], format }) : 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");
1290
1392
  }
1291
1393
  });
1292
1394
  }
@@ -1319,7 +1421,8 @@ export function boxStatistics(values) {
1319
1421
  function renderBoxWhiskerChart(c) {
1320
1422
  // A lone value column has no category column: like the exporter, its values plot against their row numbers (one box per row).
1321
1423
  const lone = c.columns.length === 1;
1322
- const series = lone ? [{ name: c.label(c.columns[0]), column: 0, index: 0, values: c.rows.map((row) => chartNumber(row[0])) }] : c.series();
1424
+ const series = lone ? [{ name: c.label(c.columns[0]), column: 0, index: 0, ...(c.formats[0] !== undefined ? { format: c.formats[0] } : {}), values: c.rows.map((row) => chartNumber(row[0])) }] : c.series();
1425
+ const format = series[0]?.format;
1323
1426
  const groups = [];
1324
1427
  c.rows.forEach((row, i) => {
1325
1428
  const name = lone ? String(i + 1) : c.label(row[0]);
@@ -1332,9 +1435,9 @@ function renderBoxWhiskerChart(c) {
1332
1435
  const dataMin = all.length ? Math.min(...all) : 0, dataMax = all.length ? Math.max(...all) : 1;
1333
1436
  const legendWidth = seriesLegend(c, series);
1334
1437
  const probe = niceScale(dataMin, dataMax, 10);
1335
- const { plot, band } = chartexPlot(c, { categories: groups.map((group) => ({ name: group.name, path: lone ? c.path : `${c.path}.data.rows.${group.first}.0` })), legendWidth, tickLabels: probe.ticks.map((tick) => formatTick(tick)) });
1438
+ const { plot, band } = chartexPlot(c, { categories: groups.map((group) => ({ name: group.name, path: lone ? c.path : `${c.path}.data.rows.${group.first}.0` })), legendWidth, tickLabels: probe.ticks.map((tick) => formatTick(tick, false, format)) });
1336
1439
  const scale = niceScale(dataMin, dataMax, maxIntervalsFor(plot.height, c.lineHeight * 1.2));
1337
- const { at } = valueAxis(c, plot, scale);
1440
+ const { at } = valueAxis(c, plot, scale, format);
1338
1441
  const group = band / 2, width = group / series.length, offset = (band - group) / 2;
1339
1442
  series.forEach((s, j) => {
1340
1443
  const color = c.colors[j % c.colors.length], path = `${c.path}.data.columns.${s.column}`;
@@ -1373,6 +1476,7 @@ export function mixHex(from, to, t) {
1373
1476
  function renderRegionMapChart(c) {
1374
1477
  const { box, pad, fontPx } = c;
1375
1478
  const { column, values, categories } = chartexValues(c.rows, c.columns);
1479
+ const format = c.formats[column];
1376
1480
  const count = categories.length;
1377
1481
  const area = { x: box.x + pad, y: box.y + pad, width: Math.max(1, box.width - 2 * pad), height: Math.max(1, box.height - 2 * pad) };
1378
1482
  const columns = Math.max(1, Math.min(count, Math.round(Math.sqrt(count * area.width / Math.max(1, area.height)))));
@@ -1389,7 +1493,7 @@ function renderRegionMapChart(c) {
1389
1493
  const inset = { x: x + fontPx * 0.25, width: Math.max(1, tileWidth - fontPx * 0.5) };
1390
1494
  if (tileHeight >= c.lineHeight * 2) {
1391
1495
  c.text(name, { ...inset, y: y + tileHeight / 2 - c.lineHeight, height: c.lineHeight }, `${c.path}.data.rows.${i}.0`);
1392
- if (value !== null) c.text(formatTick(value), { ...inset, y: y + tileHeight / 2, height: c.lineHeight }, `${c.path}.data.rows.${i}.${column}`);
1496
+ if (value !== null) c.text(formatTick(value, false, format), { ...inset, y: y + tileHeight / 2, height: c.lineHeight }, `${c.path}.data.rows.${i}.${column}`);
1393
1497
  } else if (tileHeight >= c.lineHeight) {
1394
1498
  c.text(name, { ...inset, y: y + (tileHeight - c.lineHeight) / 2, height: c.lineHeight }, `${c.path}.data.rows.${i}.0`);
1395
1499
  }
package/dist/svg.js CHANGED
@@ -10,7 +10,7 @@ import * as opfCore from "@openpresentation/opf";
10
10
  import { adjustedFontSize, baselineShift, createScriptFonts } from "./script-fonts.js";
11
11
  import { disabledFeaturesStyle } from "./font-compatibility.js";
12
12
  import { fontPolicyFor } from "./font-policy.js";
13
- import { renderCatalogChart } from "./charts.js";
13
+ import { isDatasetChart, isDatasetTable, renderCatalogChart } from "./charts.js";
14
14
  import { renderCaption, renderFootnotes } from "./annotations.js";
15
15
 
16
16
  export const packageName = "@openpresentation/opf-render";
@@ -737,6 +737,8 @@ function bindSlide(presentation, slide, layout, index, context) {
737
737
  textMeasurement,
738
738
  geometry,
739
739
  assets: presentation.assets ?? {},
740
+ // RR-54: the whole document, so a chart reads its dataset and a trace path can tell a dataset-backed chart from an inline one.
741
+ presentation,
740
742
  index,
741
743
  path: slidePath,
742
744
  slide,
@@ -1429,8 +1431,10 @@ function renderTimeline(item, box, bound, options) {
1429
1431
 
1430
1432
  function renderTable(item, box, bound, options) {
1431
1433
  const scale = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
1432
- const layout = layoutTable(item.value, box, {scale, minFontSize:(bound.composition ?? bound.geometry.composition).minFontSize, fontFamily:bound.design.fonts.body, textMeasurement:options.textMeasurement, path:item.path, ...(bound.geometry?.direction === "rtl" ? {direction:"rtl"} : {})});
1434
+ const layout = layoutTable(item.value, box, {scale, minFontSize:(bound.composition ?? bound.geometry.composition).minFontSize, fontFamily:bound.design.fonts.body, textMeasurement:options.textMeasurement, path:item.path, presentation:bound.presentation, ...(bound.geometry?.direction === "rtl" ? {direction:"rtl"} : {})});
1433
1435
  const children = [];
1436
+ // RR-54: a dataset table has no rows or columns of its own to point at; every cell reports the table's authored path.
1437
+ const own = isDatasetTable(item, bound) ? () => item.path : (path) => path;
1434
1438
  const separateBorders = layout.rows.some(row => row.cells.some(cell => cell.style?.borders));
1435
1439
  const defaultEdges = [], explicitEdges = [];
1436
1440
  for (const row of layout.rows) for (const cell of row.cells) {
@@ -1448,19 +1452,19 @@ function renderTable(item, box, bound, options) {
1448
1452
  width: stableNumber(cell.box.width), height: stableNumber(cell.box.height),
1449
1453
  fill,
1450
1454
  stroke: separateBorders ? undefined : bound.design.colors.border, "stroke-width":separateBorders ? undefined : 1,
1451
- ...traceAttrs(options, cell.sourcePath ?? cell.path)
1455
+ ...traceAttrs(options, own(cell.sourcePath ?? cell.path))
1452
1456
  }));
1453
1457
  if (separateBorders) {
1454
1458
  const {x, y, width, height} = cell.box;
1455
1459
  const edges = {top:[x,y,x+width,y],right:[x+width,y,x+width,y+height],bottom:[x,y+height,x+width,y+height],left:[x,y,x,y+height]};
1456
1460
  for (const [edge, coordinates] of Object.entries(edges)) {
1457
1461
  const border = style.borders?.[edge];
1458
- (border ? explicitEdges : defaultEdges).push({coordinates, border, path:`${cell.sourcePath ?? cell.path}.style.borders.${edge}`});
1462
+ (border ? explicitEdges : defaultEdges).push({coordinates, border, path:own(`${cell.sourcePath ?? cell.path}.style.borders.${edge}`)});
1459
1463
  }
1460
1464
  }
1461
1465
  // Core layout has already applied vertical alignment to cell.textBox.y.
1462
1466
  children.push((cell.rich ? renderRichTextBox : renderTextBox)(cell.rich ? cell.value : flattenText(cell.value ?? ""), cell.textBox, bound, {
1463
- path:cell.path, fontSize:15, fontFamily:bound.design.fonts.body,
1467
+ path:own(cell.path), fontSize:15, fontFamily:bound.design.fonts.body,
1464
1468
  fontWeight:cell.header ? 700 : 400, textStyle:cell.textStyle, fit:cell.fit,
1465
1469
  fill:textFill, align:style.align, options
1466
1470
  }));
@@ -1514,7 +1518,9 @@ function renderChart(item, box, bound, options) {
1514
1518
  if (rendered) return rendered;
1515
1519
  const chart = item.value ?? {};
1516
1520
  const chartType = chart.type ?? engineDefaults.chartTypes[0];
1517
- const data = inlineChartRows(chart.data);
1521
+ const data = inlineChartRows(legacyChartData(chart, bound));
1522
+ // A dataset-backed chart has no data.rows of its own: its parts report the chart's authored path.
1523
+ const part = isDatasetChart(item, bound) ? () => item.path : (path) => path;
1518
1524
  const plot = inset(box, 28);
1519
1525
  const max = Math.max(1, ...data.map((row) => Math.abs(row.value)));
1520
1526
  const panelFill = bound.design.colors.surface;
@@ -1559,14 +1565,14 @@ function renderChart(item, box, bound, options) {
1559
1565
  fill: "none",
1560
1566
  stroke: primary,
1561
1567
  "stroke-width": 4,
1562
- ...traceAttrs(options, `${item.path}.data`)
1568
+ ...traceAttrs(options, part(`${item.path}.data`))
1563
1569
  }));
1564
1570
  points.forEach(([x, y], index) => children.push(tag("circle", {
1565
1571
  cx: stableNumber(x),
1566
1572
  cy: stableNumber(y),
1567
1573
  r: 5,
1568
1574
  fill: primary,
1569
- ...traceAttrs(options, `${item.path}.data.rows.${index}`)
1575
+ ...traceAttrs(options, part(`${item.path}.data.rows.${index}`))
1570
1576
  })));
1571
1577
  } else {
1572
1578
  const gap = 10;
@@ -1581,7 +1587,7 @@ function renderChart(item, box, bound, options) {
1581
1587
  width: stableNumber(barWidth),
1582
1588
  height: stableNumber(barHeight),
1583
1589
  fill: index % 2 === 0 ? primary : secondary,
1584
- ...traceAttrs(options, `${item.path}.data.rows.${index}`)
1590
+ ...traceAttrs(options, part(`${item.path}.data.rows.${index}`))
1585
1591
  }));
1586
1592
  });
1587
1593
  }
@@ -1592,7 +1598,7 @@ function renderChart(item, box, bound, options) {
1592
1598
  width: plot.width,
1593
1599
  height: 24
1594
1600
  }, bound, {
1595
- path: `${item.path}.data`,
1601
+ path: part(`${item.path}.data`),
1596
1602
  fontSize: 12,
1597
1603
  fontFamily: bound.design.fonts.body,
1598
1604
  fontWeight: 400,
@@ -1604,11 +1610,21 @@ function renderChart(item, box, bound, options) {
1604
1610
  return tag("g", traceAttrs(options, item.path), children.join("\n"));
1605
1611
  }
1606
1612
 
1613
+ // RR-54: the legacy sketch reads the same resolved rows as the catalog charts (mapping, datasets and strict numbers). Data that does
1614
+ // not resolve, or a core without the resolver, is read as authored.
1615
+ function legacyChartData(chart, bound) {
1616
+ if (typeof opfCore.resolveChartData !== "function") return chart.data;
1617
+ const resolved = opfCore.resolveChartData(chart, bound.presentation);
1618
+ return resolved.ok ? { rows: resolved.rows, resolved: true } : chart.data;
1619
+ }
1620
+
1607
1621
  function inlineChartRows(data) {
1608
1622
  const rows = Array.isArray(data?.rows) ? data.rows : [];
1609
1623
  return rows.map((row, index) => {
1610
1624
  const cells = Array.isArray(row) ? row : [row];
1611
- const value = cells.find((cell) => typeof cell === "number") ?? Number(cells.find((cell) => Number.isFinite(Number(cell))) ?? 0);
1625
+ // Resolved rows already hold strict numbers (a gap is null), so only a number cell is a value: reading a numeric-looking label
1626
+ // ("2020") or a boolean as the value of a gap would plot it.
1627
+ const value = cells.find((cell) => typeof cell === "number") ?? (data?.resolved ? 0 : Number(cells.find((cell) => Number.isFinite(Number(cell))) ?? 0));
1612
1628
  return {
1613
1629
  label: flattenText(cells.find((cell) => typeof cell === "string") ?? `Row ${index + 1}`),
1614
1630
  value: Number.isFinite(value) ? value : 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-render",
3
- "version": "0.12.2",
3
+ "version": "0.13.0",
4
4
  "description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -138,7 +138,7 @@
138
138
  "@expo-google-fonts/roboto": "0.4.3",
139
139
  "@expo-google-fonts/roboto-mono": "0.4.2",
140
140
  "@expo-google-fonts/tinos": "0.4.2",
141
- "@openpresentation/opf": "^0.12.2",
141
+ "@openpresentation/opf": "^0.13.0",
142
142
  "@resvg/resvg-js": "^2.6.2",
143
143
  "bidi-js": "1.1.0",
144
144
  "fontkit": "^2.0.4",