@openpresentation/opf-render 0.20.4 → 0.22.2

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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/charts.js +26 -107
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -596,6 +596,8 @@ Combo charts (FA-15): `type: "combo"` draws clustered columns with line series (
596
596
 
597
597
  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.
598
598
 
599
+ Chart colours, text and value axis (RR-89, RR-90, RR-92; need core 0.22.0: `chartSeriesPalette`, `chartTextSize` and the composed `chart.valueAxis`): series are drawn in core's `chartSeriesPalette` of the deck's resolved colour scheme (`accent1` to `accent6`, then the same accents tinted toward white), adjusted per chart panel by `chartPaletteForFill`, so `design.colorScheme` recolours charts; chart axis, legend, category and data-label text is core's `chartTextSize` (20 px, shrunk only for a small chart box, never below `minFontSize`); and a column, bar, line or area chart draws the value axis (`min`, `max`, `majorUnit`) core composes for its box, the axis the PPTX export writes. Other chart types keep their own scale.
600
+
599
601
  Chart emphasis (FA-14): `chart.highlight` names the series (data columns) and categories (row labels) that carry the message. Highlighted marks are filled with the deck primary and every other mark with a muted neutral derived from the chart panel and text colors (core `chartHighlightColors`; core `chartHighlightMarks` decides which marks are named: a mark is highlighted when its series or its category is). It colors columns, bars, line series and (for a category highlight) line points, areas, scatter and radar series, and pie and doughnut slices, with legend keys and data label contrast following; the chartex constructs draw as before and the `chart-option-adapted` diagnostic names the dropped part. Series keep their order and a chart without `highlight` draws the same SVG as before.
600
602
 
601
603
  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 data source by file or asset (the removed `ChartDataSource`) is an `invalid-opf` error.
package/dist/charts.js CHANGED
@@ -4,19 +4,14 @@
4
4
  // The table below is carried here so the preview stays synchronous and self-contained; test/chart-catalog.mjs checks it
5
5
  // against core's CHART_TYPES. Each type renders the construct PowerPoint shows for the exported chart; any other value
6
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";
7
+ import { chartHighlightColors, chartHighlightMarks, chartNumber, chartPaletteForFill, chartSeriesPalette as coreChartSeriesPalette, chartTextSize, formatDataNumber, maxIntervalsFor, niceScale, stackCategoryValues, numberFormatError, resolveChartData, resolveTextStyle, textColorForFill, textWidthMeasurer } from "@openpresentation/opf/composition";
8
8
  import { drawAxisTitles, drawBarLabel, drawCenteredLabel, drawPointLabel, drawSliceLabel, labelString, outsideLabelReserve, reportOptionDiagnostics, reserveAxisTitles, resolveOptions } from "./chart-options.js";
9
9
 
10
- // Ordered series palette written by opf-pptx (`CHART_COLORS`), before the same
11
- // per-surface adjustment (`chartPaletteForFill`: contrast against the card, without letting two series merge).
12
- export const CHART_SERIES_COLORS = Object.freeze([
13
- "#2874A6", "#1B4F72", "#5499C7", "#7BDBB2", "#3AC67A", "#24A89E",
14
- "#F59E0B", "#EF4444", "#8B5CF6", "#14B8A6", "#0F172A", "#64748B"
15
- ]);
16
-
17
- // The series colours for a chart panel: core's `chartPaletteForFill` (contrast against the panel without letting two series merge).
18
- export function chartSeriesPalette(surface) {
19
- return chartPaletteForFill(surface, CHART_SERIES_COLORS);
10
+ // The series colours for a chart panel: core's `chartSeriesPalette` (the deck colour scheme's accents, RR-89, with the chart's own
11
+ // `colors` entries replacing their positions) adjusted by core's `chartPaletteForFill` (contrast against the panel without letting
12
+ // two series merge). The PPTX export and the validator use the same pair, so the preview draws the colours they write.
13
+ export function chartSeriesPalette(surface, colorScheme, colors, context) {
14
+ return chartPaletteForFill(surface, coreChartSeriesPalette(colorScheme, colors, context));
20
15
  }
21
16
 
22
17
  // FA-14: chart.highlight. Core resolves which series and categories are named (chartHighlightMarks) and the two colours
@@ -191,7 +186,8 @@ function tracePaths(item, bound, resolved) {
191
186
  function chartContext(item, box, bound, options, svg, { rows, columns, formats, trace, combo }) {
192
187
  const u = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
193
188
  const composition = bound.composition ?? bound.geometry.composition ?? {};
194
- const requested = 14, fontPx = Math.max(requested, composition.minFontSize ?? 16) * u;
189
+ // RR-90: core's chart text size (20 px, shrunk only for a small box, never below minFontSize), the size core's value-axis density assumes.
190
+ const requested = chartTextSize({ width: box.width, height: box.height, scale: u, minFontSize: composition.minFontSize }), fontPx = requested * u;
195
191
  const surface = bound.design.colors.surface;
196
192
  const labelColor = textColorForFill(surface, bound.design.colors.text);
197
193
  const style = resolveTextStyle({ fontFamily: bound.design.fonts.body, fontWeight: 400, italic: false, path: item.path }, options.textMeasurement);
@@ -215,7 +211,9 @@ function chartContext(item, box, bound, options, svg, { rows, columns, formats,
215
211
  // clear space kept between neighbouring category labels.
216
212
  textHeight: fontPx * 1.22,
217
213
  labelGap: fontPx * 0.1,
218
- colors: chartSeriesPalette(surface),
214
+ colors: chartSeriesPalette(surface, bound.design.colorScheme, item.value?.colors, { roles: bound.design.colors, variables: bound.design.variables }),
215
+ // RR-92: the value axis core composed for a column, bar, line or area chart ({ min, max, majorUnit }); absent for the other types.
216
+ valueAxis: item.chart?.valueAxis,
219
217
  gridColor: bound.design.colors.border,
220
218
  axisColor: "#888888",
221
219
  number: chartNumber,
@@ -262,60 +260,6 @@ export function formatChartValue(value, format) {
262
260
  // ---------------------------------------------------------------------------
263
261
  // Shared axis and legend helpers
264
262
 
265
- /**
266
- * Office-like automatic value axis: include zero unless every value sits in
267
- * the top sixth of the range, add 5% headroom, then choose the smallest
268
- * 1/2/5 x 10^k major unit that fits `maxIntervals`.
269
- */
270
- export function niceScale(dataMin, dataMax, maxIntervals = 10, { percent = false } = {}) {
271
- const limit = Number.isFinite(maxIntervals) ? Math.max(1, Math.floor(maxIntervals)) : 10;
272
- if (percent) {
273
- const min = dataMin < -1e-9 ? -1 : 0, max = dataMax > 1e-9 || min === 0 ? 1 : 0;
274
- return scaleFrom(min, max, pickStep(min, max, limit, [0.1, 0.2, 0.25, 0.5, 1]));
275
- }
276
- if (!Number.isFinite(dataMin) || !Number.isFinite(dataMax)) { dataMin = 0; dataMax = 1; }
277
- let lo = Math.min(dataMin, 0), hi = Math.max(dataMax, 0);
278
- if (dataMin > 0 && dataMax - dataMin < dataMax / 6) lo = dataMin;
279
- if (dataMax < 0 && dataMax - dataMin < -dataMin / 6) hi = dataMax;
280
- if (hi === lo) {
281
- if (hi === 0) hi = 1;
282
- else if (hi > 0) lo = 0;
283
- else hi = 0;
284
- }
285
- const span = hi - lo;
286
- const top = hi > 0 ? hi + span / 20 : hi;
287
- const bottom = lo < 0 ? lo - span / 20 : lo > 0 ? Math.max(0, lo - span / 20) : lo;
288
- const raw = (top - bottom) / limit;
289
- if (!(raw > 0) || !Number.isFinite(raw)) return normalizedScale(dataMin, dataMax, limit);
290
- let exponent = Math.floor(Math.log10(raw)) - 1;
291
- for (; exponent <= 308; exponent++) {
292
- for (const mantissa of [1, 2, 5]) {
293
- const step = mantissa * 10 ** exponent;
294
- if (!(step > 0) || !Number.isFinite(step)) continue;
295
- const min = Math.floor(bottom / step + 1e-9) * step, max = Math.ceil(top / step - 1e-9) * step;
296
- if (Number.isFinite(min) && Number.isFinite(max) && Math.round((max - min) / step) <= limit) return scaleFrom(clean(min), clean(max), step);
297
- }
298
- }
299
- return normalizedScale(dataMin, dataMax, limit);
300
- }
301
-
302
- // Work in representable units when headroom or a tick step exceeds Number's
303
- // range. Only axis bounds/ticks saturate at the representable endpoints; data
304
- // values keep their actual positions. Subnormal rounding may merge ticks.
305
- function normalizedScale(dataMin, dataMax, limit) {
306
- const magnitude = Math.max(Math.abs(dataMin), Math.abs(dataMax));
307
- const unit = 10 ** Math.floor(Math.log10(magnitude)) || Number.MIN_VALUE;
308
- const normalized = niceScale(dataMin / unit, dataMax / unit, limit);
309
- const restore = (value) => {
310
- const scaled = value * unit;
311
- return Number.isFinite(scaled) ? scaled : Math.sign(value) * Number.MAX_VALUE;
312
- };
313
- const min = Math.min(restore(normalized.min), dataMin);
314
- const max = Math.max(restore(normalized.max), dataMax);
315
- const ticks = [...new Set([min, ...normalized.ticks.map(restore), max])].sort((a, b) => a - b);
316
- return { min, max, step: restore(normalized.step) || Number.MIN_VALUE, ticks };
317
- }
318
-
319
263
  // Keep ordinary arithmetic unchanged. A range spanning both finite endpoints
320
264
  // can overflow even though every value/bound is finite; divide before subtracting.
321
265
  function axisFraction(value, scale, reverse = false) {
@@ -327,10 +271,6 @@ function axisFraction(value, scale, reverse = false) {
327
271
  return (reverse ? max - position : position - min) / (max - min);
328
272
  }
329
273
 
330
- function pickStep(min, max, limit, steps) {
331
- return steps.find((step) => Math.round((max - min) / step) <= limit) ?? max - min;
332
- }
333
-
334
274
  function scaleFrom(min, max, step) {
335
275
  const ticks = [];
336
276
  const count = Math.round((max - min) / step);
@@ -473,10 +413,6 @@ function axisLine(c, x1, y1, x2, y2) {
473
413
  c.mark("line", { x1, y1, x2, y2, stroke: c.axisColor, "stroke-width": c.pt });
474
414
  }
475
415
 
476
- function maxIntervalsFor(length, labelExtent) {
477
- return Math.max(1, Math.min(10, Math.floor(length / Math.max(1, labelExtent))));
478
- }
479
-
480
416
  // ---------------------------------------------------------------------------
481
417
  // Category-axis labels. PowerPoint labels a category axis automatically: a label
482
418
  // stays on one line unless it has spaces and fits two lines in its category
@@ -676,40 +612,23 @@ function drawRowLabels(c, plan, entries, band, gutter, x, rowTop) {
676
612
  // ---------------------------------------------------------------------------
677
613
  // Category charts: column, bar, line and area (standard, stacked, 100%).
678
614
 
679
- /**
680
- * Plotted extents per series point. Bars stack positive and negative values
681
- * separately; lines and areas accumulate the signed sum. 100% groupings divide
682
- * by the category's sum of absolute values first.
683
- */
684
- export function stackCategoryValues(series, count, kind, grouping, path = "chart") {
685
- const percent = grouping === "percentStacked";
686
- const stacked = grouping === "stacked" || percent;
687
- const totals = Array.from({ length: count }, (_, i) => series.reduce((sum, s) => sum + Math.abs(s.values[i] ?? 0), 0));
688
- if (percent && totals.some((total) => !Number.isFinite(total))) throw chartAggregateError(path, "percentage total");
689
- const positive = new Array(count).fill(0), negative = new Array(count).fill(0), running = new Array(count).fill(0);
690
- return series.map((s) => s.values.map((raw, i) => {
691
- if (raw === null && kind !== "area") return null;
692
- let value = raw ?? 0;
693
- if (percent) value = totals[i] ? value / totals[i] : 0;
694
- if (!stacked) return { value, from: 0, to: value };
695
- if (kind === "bar") {
696
- const base = value >= 0 ? positive : negative;
697
- const from = base[i];
698
- base[i] += value;
699
- if (!Number.isFinite(base[i])) throw chartAggregateError(path, "stacked total");
700
- return { value, from, to: base[i] };
701
- }
702
- const from = running[i];
703
- running[i] += value;
704
- if (!Number.isFinite(running[i])) throw chartAggregateError(path, "stacked total");
705
- return { value, from, to: running[i] };
706
- }));
707
- }
708
-
709
615
  function chartAggregateError(path, aggregate) {
710
616
  return new RangeError(`Cannot render ${path}: ${aggregate} exceeds the finite numeric range. Rescale the chart values before rendering.`);
711
617
  }
712
618
 
619
+ // RR-92: a column, bar, line or area chart draws the value axis core composed for its box (c.valueAxis), the one the PPTX export
620
+ // writes. Without it (a chart the composition has no axis for) the scale is fitted to the plot's length as before.
621
+ // A range so wide that min + n x majorUnit leaves Number's range (finite data up to Number.MAX_VALUE) has no ticks to rebuild from the
622
+ // three numbers; it takes the saturating scale `niceScale` computes.
623
+ function categoryScale(c, dataMin, dataMax, length, labelExtent, percent) {
624
+ const axis = c.valueAxis;
625
+ if (axis && Number.isFinite(axis.max - axis.min) && (axis.max - axis.min) / axis.majorUnit <= 1000) {
626
+ const scale = scaleFrom(axis.min, axis.max, axis.majorUnit);
627
+ if (scale.ticks.every(Number.isFinite)) return scale;
628
+ }
629
+ return niceScale(dataMin, dataMax, maxIntervalsFor(length, labelExtent), { percent });
630
+ }
631
+
713
632
  function renderCategoryChart(c, spec) {
714
633
  const { box, pad, fontPx } = c;
715
634
  const horizontal = spec.kind === "bar" && spec.dir === "bar";
@@ -738,7 +657,7 @@ function renderCategoryChart(c, spec) {
738
657
  const bottom = box.y + box.height - pad - c.lineHeight;
739
658
  let width = Math.max(1, right - x - fontPx);
740
659
  const tickWidth = Math.max(c.width(formatTick(dataMax, percent, valueFormat)), c.width(formatTick(dataMin, percent, valueFormat)), c.width("100%")) + fontPx;
741
- const scaleFor = (w) => niceScale(dataMin, dataMax, maxIntervalsFor(w, tickWidth), { percent });
660
+ const scaleFor = (w) => categoryScale(c, dataMin, dataMax, w, tickWidth, percent);
742
661
  scale = scaleFor(width);
743
662
  // RR-54: a formatted value axis keeps its end labels inside the chart.
744
663
  if (valueFormat !== undefined && !percent) ({ x, width, scale } = insetForEndLabels(c, x, width, valueFormat, scaleFor));
@@ -752,7 +671,7 @@ function renderCategoryChart(c, spec) {
752
671
  if (rtl) entries.reverse();
753
672
  const axis = planCategoryAxis(c, entries, (reserve) => {
754
673
  const height = Math.max(1, box.y + box.height - pad - reserve - top);
755
- const fitted = niceScale(dataMin, dataMax, maxIntervalsFor(height, c.lineHeight * 1.2), { percent });
674
+ const fitted = categoryScale(c, dataMin, dataMax, height, c.lineHeight * 1.2, percent);
756
675
  const tickWidth = Math.max(...fitted.ticks.map((tick) => c.width(formatTick(tick, percent, valueFormat)))) + fontPx * 0.5;
757
676
  if (rtl) {
758
677
  const x = box.x + pad + fontPx / 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-render",
3
- "version": "0.20.4",
3
+ "version": "0.22.2",
4
4
  "description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -142,7 +142,7 @@
142
142
  "test:forced-colors-browser": "node test/forced-colors-browser.mjs artifacts/forced-colors"
143
143
  },
144
144
  "dependencies": {
145
- "@openpresentation/opf": "^0.20.5",
145
+ "@openpresentation/opf": "^0.22.0",
146
146
  "bidi-js": "1.1.0",
147
147
  "fontkit": "^2.0.4",
148
148
  "harfbuzzjs": "1.6.3",