@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.
- package/README.md +2 -0
- package/dist/charts.js +26 -107
- 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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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) =>
|
|
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 =
|
|
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.
|
|
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.
|
|
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",
|