@openpresentation/opf-render 0.11.2 → 0.11.3

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Aptos previews with Intos.** The Office pack includes Intos, Intos Display, Intos Narrow and Intos Serif (16 faces, about 12 MB, vendored under `fonts/intos/`, SIL OFL 1.1, pinned to upstream commit fef9315c14da9e4b23b4c3cac8e718998d4e4736 and per-file SHA-256, unmodified from the upstream files). They are the metric-compatible previews for Aptos, Aptos Display, Aptos Narrow and Aptos Serif, the default `aptos` font scheme: 0.000% mean and maximum width difference against Aptos 2.01 in all four styles, with equal vertical metrics. The letter shapes are Intos's own, not Aptos's. The exported PPTX still names Aptos; no Aptos file is bundled or embedded. A registry built without these faces (the base pack) has no Aptos substitute: visual policy falls back to Roboto or Carlito. Like the open families, Intos is `embed: "used"`: it is not in `registry.embeddedFonts` (the 33 eager npm faces), `prepareNodeFonts().options.embeddedFonts` supplies it, and an SVG embeds only the Intos faces its text draws. `registry.lazyFonts` lists the 51 vendored faces (35 open, 16 Intos) with package-relative file and sha256, and `loadBrowserFontRegistry(faces, {lazyFontsBaseUrl})` loads them on demand: `await registry.ensureLazyFonts(presentation)` fetches and verifies only the files the document's font families resolve to (the default Aptos scheme: Intos and Intos Display, 8 files, 5.9 MB), then adds them to the document and the registry together so measurement and painting never disagree. Hosts copy the vendored directories next to the page.
4
4
 
5
- Version 0.11.2 makes `scripts: 'auto'` load the script faces a font scheme names and adds `details.loadedFaceHasGlyph` to `missing-glyph` (no export change). Version 0.11.1 adds the open replacement font families the policy routes to and complete Red Hat styles (no API change). Version 0.11.0 requires `@openpresentation/opf` ^0.11.2 (cover centering, Intos Aptos previews, lazy font loading, automatic script fonts and glyph fallback); use it with PPTX 0.11.0 and editor 0.10.0 so preview and export resolve the same core. Version 0.10.0 required `@openpresentation/opf` ^0.11.1 (font policy table, script fonts and right-to-left paragraphs, socials, slide-image treatments, formatted slide-number and date furniture with the host `date` option, per-item alignment). See the changelog for the intentional preview-output changes.
5
+ Version 0.11.3 previews the classic chart types natively, draws deprecated chart ids as their replacement and scales value axes like Office (no API change; the preview of chart slides changes). Version 0.11.2 makes `scripts: 'auto'` load the script faces a font scheme names and adds `details.loadedFaceHasGlyph` to `missing-glyph` (no export change). Version 0.11.1 adds the open replacement font families the policy routes to and complete Red Hat styles (no API change). Version 0.11.0 requires `@openpresentation/opf` ^0.11.2 (cover centering, Intos Aptos previews, lazy font loading, automatic script fonts and glyph fallback); use it with PPTX 0.11.0 and editor 0.10.0 so preview and export resolve the same core. Version 0.10.0 required `@openpresentation/opf` ^0.11.1 (font policy table, script fonts and right-to-left paragraphs, socials, slide-image treatments, formatted slide-number and date furniture with the host `date` option, per-item alignment). See the changelog for the intentional preview-output changes.
6
6
 
7
7
  Version 0.9.0 required `@openpresentation/opf` ^0.11.0 and resolved content ColorRef / `variables` through core `resolveColorRef()`. Authored `#RRGGBB` paint stays authored. It retains the existing rendering APIs.
8
8
 
@@ -244,6 +244,8 @@ Dependency policy:
244
244
 
245
245
  Browser support boundary: `renderSvg`, `renderSvgDeck`, and `resolvePresentation` are browser-importable pure JavaScript APIs. `svgToPng` and `svgToPdf` are Node APIs in this package version because they depend on the Node build of resvg.
246
246
 
247
+ 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.
248
+
247
249
  ## Development
248
250
 
249
251
  ```sh
package/dist/charts.js ADDED
@@ -0,0 +1,644 @@
1
+ // Chart previews for the OPF chart type catalog (FF-22).
2
+ //
3
+ // The core catalog keeps one chart type per Office chart construct and
4
+ // deprecates the rest with a replacement id (`deprecation.replacedBy`,
5
+ // `mappings.openxml`). The tables below are carried here so the preview stays
6
+ // synchronous and self-contained; test/chart-catalog.mjs checks them against
7
+ // the installed core catalog. Kept ids render the construct PowerPoint shows
8
+ // for the exported chart; deprecated ids render exactly like their replacement;
9
+ // any other id returns null so the caller keeps its legacy single-series preview.
10
+ import { chartColorForFill, resolveTextStyle, textColorForFill, textWidthMeasurer } from "@openpresentation/opf/composition";
11
+
12
+ // Ordered series palette written by opf-pptx (`CHART_COLORS`), before the same
13
+ // per-surface contrast adjustment (`chartColorForFill`).
14
+ export const CHART_SERIES_COLORS = Object.freeze([
15
+ "#2874A6", "#1B4F72", "#5499C7", "#7BDBB2", "#3AC67A", "#24A89E",
16
+ "#F59E0B", "#EF4444", "#8B5CF6", "#14B8A6", "#0F172A", "#64748B"
17
+ ]);
18
+
19
+ const bar = (dir, grouping) => ({ kind: "bar", dir, grouping });
20
+ const line = (grouping, markers) => ({ kind: "line", grouping, markers });
21
+ const area = (grouping) => ({ kind: "area", grouping });
22
+ const radar = (style) => ({ kind: "radar", style, markers: style === "marker" });
23
+
24
+ export const CHART_TYPES = Object.freeze({
25
+ column: bar("col", "clustered"),
26
+ "stacked-column-3x": bar("col", "stacked"),
27
+ "100pct-stacked-column-3x": bar("col", "percentStacked"),
28
+ bar: bar("bar", "clustered"),
29
+ "stacked-bar-3x": bar("bar", "stacked"),
30
+ "100pct-stacked-bar-3x": bar("bar", "percentStacked"),
31
+ line: line("standard", false),
32
+ "line-with-markers": line("standard", true),
33
+ "stacked-line-3x": line("stacked", false),
34
+ "stacked-line-with-markers-3x": line("stacked", true),
35
+ area: area("standard"),
36
+ "stacked-area-3x": area("stacked"),
37
+ "100pct-stacked-area-3x": area("percentStacked"),
38
+ pie: { kind: "pie" },
39
+ doughnut: { kind: "doughnut" },
40
+ scatter: { kind: "scatter" },
41
+ radar: radar("standard"),
42
+ "radar-with-markers": radar("marker"),
43
+ "filled-radar": radar("filled"),
44
+ treemap: { kind: "treemap" },
45
+ histogram: { kind: "histogram" },
46
+ pareto: { kind: "pareto" },
47
+ "box-and-whisker": { kind: "box" },
48
+ waterfall: { kind: "waterfall" },
49
+ funnel: { kind: "funnel" },
50
+ world: { kind: "map" }
51
+ });
52
+
53
+ const variants = (base, target) => Object.fromEntries([base, `${base}-2x`, `${base}-3x`].map((id) => [id, target]));
54
+ const suffixes = (base, target) => Object.fromEntries(["", "-2x", "-3x", "-4x", "-5x", "-6x"].map((suffix) => [`${base}${suffix}`, target]));
55
+
56
+ // Deprecated core ids -> kept replacement (core spec/catalogs/chart-types `deprecation.replacedBy`).
57
+ export const DEPRECATED_CHART_TYPES = Object.freeze({
58
+ ...variants("100pct-bullet-bar", "100pct-stacked-bar-3x"),
59
+ "100pct-progress-bar": "100pct-stacked-bar-3x",
60
+ "100pct-stacked-bar-2x": "100pct-stacked-bar-3x",
61
+ ...variants("100pct-bullet-column", "100pct-stacked-column-3x"),
62
+ "100pct-stacked-column-2x": "100pct-stacked-column-3x",
63
+ "100pct-stacked-area-2x": "100pct-stacked-area-3x",
64
+ australia: "world",
65
+ canada: "world",
66
+ "united-kingdom": "world",
67
+ "united-states": "world",
68
+ "box-and-whisker-2x": "box-and-whisker",
69
+ "box-and-whisker-3x": "box-and-whisker",
70
+ ...variants("bullet-bar", "bar"),
71
+ "clustered-bar-2x": "bar",
72
+ ...variants("bullet-column", "column"),
73
+ "clustered-column": "column",
74
+ ...suffixes("dot-plot", "scatter"),
75
+ dumbbell: "scatter",
76
+ "line-2x": "line",
77
+ "line-3x": "line",
78
+ "line-with-high-low": "line",
79
+ ...suffixes("sparkline", "line"),
80
+ "line-with-high-low-and-markers": "line-with-markers",
81
+ "line-with-markers-2x": "line-with-markers",
82
+ "line-with-markers-3x": "line-with-markers",
83
+ "stacked-area-2x": "stacked-area-3x",
84
+ "stacked-bar-2x": "stacked-bar-3x",
85
+ "stacked-column-2x": "stacked-column-3x",
86
+ "stacked-line-2x": "stacked-line-3x",
87
+ "stacked-line-with-markers-2x": "stacked-line-with-markers-3x",
88
+ "treemap-2x": "treemap",
89
+ "treemap-3x": "treemap"
90
+ });
91
+
92
+ const ALIASES = Object.freeze({ donut: "doughnut" });
93
+
94
+ /** Resolve a chart type id to its kept catalog id, or null for ids outside the catalog. */
95
+ export function resolveChartType(type) {
96
+ const raw = String(type ?? "").trim().toLowerCase();
97
+ const id = ALIASES[raw] ?? DEPRECATED_CHART_TYPES[raw] ?? raw;
98
+ return Object.hasOwn(CHART_TYPES, id) ? id : null;
99
+ }
100
+
101
+ const RENDERERS = {
102
+ bar: renderCategoryChart,
103
+ line: renderCategoryChart,
104
+ area: renderCategoryChart,
105
+ pie: renderCircularChart,
106
+ doughnut: renderCircularChart,
107
+ scatter: renderScatterChart,
108
+ radar: renderRadarChart
109
+ };
110
+
111
+ /**
112
+ * Render a catalog chart. Returns null when the id is outside the catalog, has
113
+ * no preview renderer, or carries no inline category-major data, so the caller
114
+ * can keep its legacy output ("No chart data" for unresolved sources).
115
+ */
116
+ export function renderCatalogChart(item, box, bound, options, svg) {
117
+ const id = resolveChartType(item.value?.type);
118
+ const spec = id && CHART_TYPES[id];
119
+ const render = spec && RENDERERS[spec.kind];
120
+ const data = item.value?.data;
121
+ const rows = Array.isArray(data?.rows) ? data.rows.map((row) => Array.isArray(row) ? row : [row]) : [];
122
+ const columns = Array.isArray(data?.columns) ? data.columns : [];
123
+ if (!render || !rows.length || columns.length < 2) return null;
124
+ const c = chartContext(item, box, bound, options, svg, rows, columns);
125
+ 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);
126
+ render(c, spec);
127
+ return svg.tag("g", { ...svg.traceAttrs(options, item.path), "data-opf-chart": options.trace ? id : undefined }, c.children.join("\n"));
128
+ }
129
+
130
+ function chartContext(item, box, bound, options, svg, rows, columns) {
131
+ const u = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
132
+ const composition = bound.composition ?? bound.geometry.composition ?? {};
133
+ const requested = 14, fontPx = Math.max(requested, composition.minFontSize ?? 16) * u;
134
+ const surface = bound.design.colors.surface;
135
+ const labelColor = textColorForFill(surface, bound.design.colors.text);
136
+ const style = resolveTextStyle({ fontFamily: bound.design.fonts.body, fontWeight: 400, italic: false, path: item.path }, options.textMeasurement);
137
+ const measure = textWidthMeasurer(style, options.textMeasurement);
138
+ const children = [];
139
+ const n = svg.stableNumber;
140
+ const numericAttrs = new Set(["x", "y", "width", "height", "cx", "cy", "r", "x1", "x2", "y1", "y2", "stroke-width"]);
141
+ const c = {
142
+ item, box, bound, options, svg, rows, columns, children, u, fontPx, surface, labelColor,
143
+ path: item.path,
144
+ pt: u * 4 / 3,
145
+ pad: 10 * u,
146
+ lineHeight: fontPx * 1.5,
147
+ colors: CHART_SERIES_COLORS.map((color) => chartColorForFill(surface, color)),
148
+ gridColor: bound.design.colors.border,
149
+ axisColor: "#888888",
150
+ number: chartNumber,
151
+ label: (value) => value === null || value === undefined ? "" : String(value),
152
+ width: (value) => measure(String(value), fontPx),
153
+ num: n,
154
+ mark(name, attrs, path) {
155
+ const out = {};
156
+ for (const [key, value] of Object.entries(attrs)) out[key] = numericAttrs.has(key) && typeof value === "number" ? n(value) : value;
157
+ children.push(svg.tag(name, { ...out, ...(path ? svg.traceAttrs(options, path) : {}) }));
158
+ },
159
+ text(value, rect, path, align = "center") {
160
+ if (!(rect.width > 0 && rect.height > 0)) return;
161
+ children.push(svg.renderTextBox(String(value), rect, bound, {
162
+ path, fontSize: requested, fontFamily: bound.design.fonts.body, fontWeight: 400,
163
+ fill: labelColor, options, align, verticalAlign: "middle"
164
+ }));
165
+ },
166
+ series(first = 1) {
167
+ return columns.slice(first).map((name, offset) => ({
168
+ name: c.label(name), column: first + offset, index: offset,
169
+ values: rows.map((row) => chartNumber(row[first + offset]))
170
+ }));
171
+ }
172
+ };
173
+ return c;
174
+ }
175
+
176
+ export function chartNumber(value) {
177
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
178
+ if (typeof value === "string" && value.trim()) {
179
+ const parsed = Number(value);
180
+ return Number.isFinite(parsed) ? parsed : null;
181
+ }
182
+ return null;
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Shared axis and legend helpers
187
+
188
+ /**
189
+ * Office-like automatic value axis: include zero unless every value sits in
190
+ * the top sixth of the range, add 5% headroom, then choose the smallest
191
+ * 1/2/5 x 10^k major unit that fits `maxIntervals`.
192
+ */
193
+ export function niceScale(dataMin, dataMax, maxIntervals = 10, { percent = false } = {}) {
194
+ const limit = Number.isFinite(maxIntervals) ? Math.max(1, Math.floor(maxIntervals)) : 10;
195
+ if (percent) {
196
+ const min = dataMin < -1e-9 ? -1 : 0, max = dataMax > 1e-9 || min === 0 ? 1 : 0;
197
+ return scaleFrom(min, max, pickStep(min, max, limit, [0.1, 0.2, 0.25, 0.5, 1]));
198
+ }
199
+ if (!Number.isFinite(dataMin) || !Number.isFinite(dataMax)) { dataMin = 0; dataMax = 1; }
200
+ let lo = Math.min(dataMin, 0), hi = Math.max(dataMax, 0);
201
+ if (dataMin > 0 && dataMax - dataMin < dataMax / 6) lo = dataMin;
202
+ if (dataMax < 0 && dataMax - dataMin < -dataMin / 6) hi = dataMax;
203
+ if (hi === lo) {
204
+ if (hi === 0) hi = 1;
205
+ else if (hi > 0) lo = 0;
206
+ else hi = 0;
207
+ }
208
+ const span = hi - lo;
209
+ const top = hi > 0 ? hi + span / 20 : hi;
210
+ const bottom = lo < 0 ? lo - span / 20 : lo > 0 ? Math.max(0, lo - span / 20) : lo;
211
+ const raw = (top - bottom) / limit;
212
+ if (!(raw > 0) || !Number.isFinite(raw)) return normalizedScale(dataMin, dataMax, limit);
213
+ let exponent = Math.floor(Math.log10(raw)) - 1;
214
+ for (; exponent <= 308; exponent++) {
215
+ for (const mantissa of [1, 2, 5]) {
216
+ const step = mantissa * 10 ** exponent;
217
+ if (!(step > 0) || !Number.isFinite(step)) continue;
218
+ const min = Math.floor(bottom / step + 1e-9) * step, max = Math.ceil(top / step - 1e-9) * step;
219
+ if (Number.isFinite(min) && Number.isFinite(max) && Math.round((max - min) / step) <= limit) return scaleFrom(clean(min), clean(max), step);
220
+ }
221
+ }
222
+ return normalizedScale(dataMin, dataMax, limit);
223
+ }
224
+
225
+ // Work in representable units when headroom or a tick step exceeds Number's
226
+ // range. Only axis bounds/ticks saturate at the representable endpoints; data
227
+ // values keep their actual positions. Subnormal rounding may merge ticks.
228
+ function normalizedScale(dataMin, dataMax, limit) {
229
+ const magnitude = Math.max(Math.abs(dataMin), Math.abs(dataMax));
230
+ const unit = 10 ** Math.floor(Math.log10(magnitude)) || Number.MIN_VALUE;
231
+ const normalized = niceScale(dataMin / unit, dataMax / unit, limit);
232
+ const restore = (value) => {
233
+ const scaled = value * unit;
234
+ return Number.isFinite(scaled) ? scaled : Math.sign(value) * Number.MAX_VALUE;
235
+ };
236
+ const min = Math.min(restore(normalized.min), dataMin);
237
+ const max = Math.max(restore(normalized.max), dataMax);
238
+ const ticks = [...new Set([min, ...normalized.ticks.map(restore), max])].sort((a, b) => a - b);
239
+ return { min, max, step: restore(normalized.step) || Number.MIN_VALUE, ticks };
240
+ }
241
+
242
+ // Keep ordinary arithmetic unchanged. A range spanning both finite endpoints
243
+ // can overflow even though every value/bound is finite; divide before subtracting.
244
+ function axisFraction(value, scale, reverse = false) {
245
+ const range = scale.max - scale.min;
246
+ const distance = reverse ? scale.max - value : value - scale.min;
247
+ if (Number.isFinite(range) && Number.isFinite(distance)) return distance / (range || 1);
248
+ const unit = Math.max(Math.abs(scale.min), Math.abs(scale.max));
249
+ const min = scale.min / unit, max = scale.max / unit, position = value / unit;
250
+ return (reverse ? max - position : position - min) / (max - min);
251
+ }
252
+
253
+ function pickStep(min, max, limit, steps) {
254
+ return steps.find((step) => Math.round((max - min) / step) <= limit) ?? max - min;
255
+ }
256
+
257
+ function scaleFrom(min, max, step) {
258
+ const ticks = [];
259
+ const count = Math.round((max - min) / step);
260
+ for (let index = 0; index <= count; index++) ticks.push(clean(min + index * step));
261
+ return { min, max, step, ticks };
262
+ }
263
+
264
+ function clean(value) {
265
+ const rounded = Number(value.toPrecision(12));
266
+ return Object.is(rounded, -0) ? 0 : rounded;
267
+ }
268
+
269
+ /** Office General / 0% tick labels. */
270
+ export function formatTick(value, percent = false) {
271
+ if (percent) return `${clean(value * 100)}%`;
272
+ return String(clean(value));
273
+ }
274
+
275
+ function legendEntries(c, entries) {
276
+ // PowerPoint places the exported legend on the right (PptxGenJS legendPos r).
277
+ if (!entries.length) return 0;
278
+ const swatch = c.fontPx * 0.6, gap = c.fontPx * 0.4, keyWidth = entries.some((entry) => entry.key === "line") ? c.fontPx * 1.4 : swatch;
279
+ const textWidth = Math.max(...entries.map((entry) => c.width(entry.name)));
280
+ const width = Math.min(c.box.width * 0.45, keyWidth + gap + textWidth + c.fontPx * 0.5);
281
+ const rowHeight = Math.min(c.lineHeight, (c.box.height - 2 * c.pad) / entries.length);
282
+ const x = c.box.x + c.box.width - c.pad - width;
283
+ let y = c.box.y + Math.max(c.pad, (c.box.height - rowHeight * entries.length) / 2);
284
+ for (const entry of entries) {
285
+ const middle = y + rowHeight / 2;
286
+ // Legend keys follow the series format: a line (with marker) for line/radar
287
+ // series, a marker for scatter series, a filled square otherwise.
288
+ 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);
289
+ 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);
290
+ if (!entry.key) c.mark("rect", { x, y: middle - swatch / 2, width: swatch, height: swatch, fill: entry.color }, entry.path);
291
+ c.text(entry.name, { x: x + keyWidth + gap, y, width: Math.max(1, width - keyWidth - gap), height: rowHeight }, entry.path, "left");
292
+ y += rowHeight;
293
+ }
294
+ return width + c.pad;
295
+ }
296
+
297
+ function seriesLegend(c, series, key, marker = false) {
298
+ return series.length > 1
299
+ ? 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 })))
300
+ : 0;
301
+ }
302
+
303
+ function gridline(c, x1, y1, x2, y2) {
304
+ c.mark("line", { x1, y1, x2, y2, stroke: c.gridColor, "stroke-opacity": 0.7, "stroke-width": c.pt });
305
+ }
306
+
307
+ function axisLine(c, x1, y1, x2, y2) {
308
+ c.mark("line", { x1, y1, x2, y2, stroke: c.axisColor, "stroke-width": c.pt });
309
+ }
310
+
311
+ function maxIntervalsFor(length, labelExtent) {
312
+ return Math.max(1, Math.min(10, Math.floor(length / Math.max(1, labelExtent))));
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Category charts: column, bar, line and area (standard, stacked, 100%).
317
+
318
+ /**
319
+ * Plotted extents per series point. Bars stack positive and negative values
320
+ * separately; lines and areas accumulate the signed sum. 100% groupings divide
321
+ * by the category's sum of absolute values first.
322
+ */
323
+ export function stackCategoryValues(series, count, kind, grouping, path = "chart") {
324
+ const percent = grouping === "percentStacked";
325
+ const stacked = grouping === "stacked" || percent;
326
+ const totals = Array.from({ length: count }, (_, i) => series.reduce((sum, s) => sum + Math.abs(s.values[i] ?? 0), 0));
327
+ if (percent && totals.some((total) => !Number.isFinite(total))) throw chartAggregateError(path, "percentage total");
328
+ const positive = new Array(count).fill(0), negative = new Array(count).fill(0), running = new Array(count).fill(0);
329
+ return series.map((s) => s.values.map((raw, i) => {
330
+ if (raw === null && kind !== "area") return null;
331
+ let value = raw ?? 0;
332
+ if (percent) value = totals[i] ? value / totals[i] : 0;
333
+ if (!stacked) return { value, from: 0, to: value };
334
+ if (kind === "bar") {
335
+ const base = value >= 0 ? positive : negative;
336
+ const from = base[i];
337
+ base[i] += value;
338
+ if (!Number.isFinite(base[i])) throw chartAggregateError(path, "stacked total");
339
+ return { value, from, to: base[i] };
340
+ }
341
+ const from = running[i];
342
+ running[i] += value;
343
+ if (!Number.isFinite(running[i])) throw chartAggregateError(path, "stacked total");
344
+ return { value, from, to: running[i] };
345
+ }));
346
+ }
347
+
348
+ function chartAggregateError(path, aggregate) {
349
+ return new RangeError(`Cannot render ${path}: ${aggregate} exceeds the finite numeric range. Rescale the chart values before rendering.`);
350
+ }
351
+
352
+ function renderCategoryChart(c, spec) {
353
+ const { box, pad, fontPx } = c;
354
+ const horizontal = spec.kind === "bar" && spec.dir === "bar";
355
+ const percent = spec.grouping === "percentStacked";
356
+ const series = c.series();
357
+ const categories = c.rows.map((row) => c.label(row[0]));
358
+ const count = categories.length;
359
+ const stacks = stackCategoryValues(series, count, spec.kind, spec.grouping, c.path);
360
+ // The scale follows the plotted values. Only stacked bars and areas plot from a
361
+ // base (the running total below them); an unstacked series has a synthetic zero
362
+ // `from`, which must not force zero onto the axis, and a line plots `to` alone.
363
+ const stackedGrouping = spec.grouping === "stacked" || percent;
364
+ const extents = stacks.flat().filter(Boolean).flatMap((point) => spec.kind === "line" || !stackedGrouping ? [point.to] : [point.from, point.to]);
365
+ const dataMin = extents.length ? Math.min(...extents) : 0, dataMax = extents.length ? Math.max(...extents) : 1;
366
+ const legendWidth = seriesLegend(c, series, spec.kind === "line" ? "line" : undefined, spec.markers);
367
+ const right = box.x + box.width - pad - legendWidth;
368
+ const top = box.y + pad + c.lineHeight / 2;
369
+ let plot, scale;
370
+ if (horizontal) {
371
+ const categoryWidth = Math.min(box.width * 0.3, Math.max(0, ...categories.map(c.width)) + fontPx * 0.5);
372
+ const x = box.x + pad + categoryWidth + c.pad / 2;
373
+ const bottom = box.y + box.height - pad - c.lineHeight;
374
+ const width = Math.max(1, right - x - fontPx);
375
+ const tickWidth = Math.max(c.width(formatTick(dataMax, percent)), c.width(formatTick(dataMin, percent)), c.width("100%")) + fontPx;
376
+ scale = niceScale(dataMin, dataMax, maxIntervalsFor(width, tickWidth), { percent });
377
+ plot = { x, y: top, width, height: Math.max(1, bottom - top) };
378
+ categories.forEach((name, i) => {
379
+ const band = plot.height / count;
380
+ c.text(name, { x: box.x + pad, y: plot.y + plot.height - (i + 1) * band, width: categoryWidth, height: band }, `${c.path}.data.rows.${i}.0`, "right");
381
+ });
382
+ } else {
383
+ const bottom = box.y + box.height - pad - c.lineHeight;
384
+ const height = Math.max(1, bottom - top);
385
+ scale = niceScale(dataMin, dataMax, maxIntervalsFor(height, c.lineHeight * 1.2), { percent });
386
+ const tickWidth = Math.max(...scale.ticks.map((tick) => c.width(formatTick(tick, percent)))) + fontPx * 0.5;
387
+ const x = box.x + pad + tickWidth;
388
+ plot = { x, y: top, width: Math.max(1, right - x - fontPx / 2), height };
389
+ const band = plot.width / count;
390
+ categories.forEach((name, i) => c.text(name, { x: plot.x + i * band, y: plot.y + plot.height, width: band, height: c.lineHeight }, `${c.path}.data.rows.${i}.0`));
391
+ }
392
+ const at = (value) => horizontal ? plot.x + axisFraction(value, scale) * plot.width : plot.y + axisFraction(value, scale, true) * plot.height;
393
+ // Category axis crosses at zero when zero is on the axis (autoZero).
394
+ const crossing = at(Math.min(scale.max, Math.max(scale.min, 0)));
395
+ for (const tick of scale.ticks) {
396
+ const position = at(tick), label = formatTick(tick, percent);
397
+ if (horizontal) {
398
+ gridline(c, position, plot.y, position, plot.y + plot.height);
399
+ const width = c.width(label) + fontPx;
400
+ c.text(label, { x: position - width / 2, y: plot.y + plot.height, width, height: c.lineHeight }, c.path);
401
+ } else {
402
+ gridline(c, plot.x, position, plot.x + plot.width, position);
403
+ 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");
404
+ }
405
+ }
406
+ const band = (horizontal ? plot.height : plot.width) / count;
407
+ if (spec.kind === "bar") drawBars(c, spec, series, stacks, { plot, band, at, horizontal, scale });
408
+ else if (spec.kind === "line") drawLines(c, spec, series, stacks, { plot, band, at });
409
+ else drawAreas(c, series, stacks, { plot, band, at, crossing });
410
+ if (horizontal) {
411
+ axisLine(c, crossing, plot.y, crossing, plot.y + plot.height);
412
+ axisLine(c, plot.x, plot.y + plot.height, plot.x + plot.width, plot.y + plot.height);
413
+ } else {
414
+ axisLine(c, plot.x, plot.y, plot.x, plot.y + plot.height);
415
+ axisLine(c, plot.x, crossing, plot.x + plot.width, crossing);
416
+ }
417
+ }
418
+
419
+ // PptxGenJS gap widths: clustered 150%, stacked 50% (overlap 100).
420
+ export function barGeometry(band, seriesCount, grouping) {
421
+ if (grouping === "clustered") {
422
+ const group = band / 2.5;
423
+ return { group, width: group / seriesCount, offset: (band - group) / 2, clustered: true };
424
+ }
425
+ const width = band / 1.5;
426
+ return { group: width, width, offset: (band - width) / 2, clustered: false };
427
+ }
428
+
429
+ function drawBars(c, spec, series, stacks, { plot, band, at, horizontal, scale }) {
430
+ // Bars grow from zero, or from the axis minimum (maximum, for all-negative data) when zero is off the axis.
431
+ const base = (value) => Math.min(scale.max, Math.max(scale.min, value));
432
+ const geometry = barGeometry(band, series.length, spec.grouping);
433
+ series.forEach((s, j) => {
434
+ const color = c.colors[j % c.colors.length];
435
+ stacks[j].forEach((point, i) => {
436
+ if (!point) return;
437
+ const slot = geometry.clustered ? j * geometry.width : 0;
438
+ const a = at(base(point.from)), b = at(point.to);
439
+ const path = `${c.path}.data.rows.${i}.${s.column}`;
440
+ if (horizontal) {
441
+ // Office bar charts draw the first category and first series nearest the origin (bottom).
442
+ const y = plot.y + plot.height - i * band - geometry.offset - slot - geometry.width;
443
+ c.mark("rect", { x: Math.min(a, b), y, width: Math.abs(b - a), height: geometry.width, fill: color }, path);
444
+ } else {
445
+ const x = plot.x + i * band + geometry.offset + slot;
446
+ c.mark("rect", { x, y: Math.min(a, b), width: geometry.width, height: Math.abs(b - a), fill: color }, path);
447
+ }
448
+ });
449
+ });
450
+ }
451
+
452
+ function drawLines(c, spec, series, stacks, { plot, band, at }) {
453
+ const radius = 3 * c.pt;
454
+ series.forEach((s, j) => {
455
+ const color = c.colors[j % c.colors.length];
456
+ let run = [];
457
+ const flush = () => {
458
+ if (run.length > 1) c.mark("polyline", { points: run.map(([x, y]) => `${c.num(x)},${c.num(y)}`).join(" "), fill: "none", stroke: color, "stroke-width": 2 * c.pt, "stroke-linejoin": "round", "stroke-linecap": "round" }, `${c.path}.data.columns.${s.column}`);
459
+ run = [];
460
+ };
461
+ stacks[j].forEach((point, i) => {
462
+ if (!point) { flush(); return; }
463
+ run.push([plot.x + (i + 0.5) * band, at(point.to)]);
464
+ });
465
+ flush();
466
+ if (spec.markers) stacks[j].forEach((point, i) => {
467
+ 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}`);
468
+ });
469
+ });
470
+ }
471
+
472
+ function drawAreas(c, series, stacks, { plot, band, at, crossing }) {
473
+ series.forEach((s, j) => {
474
+ const points = stacks[j].map((point, i) => ({ x: plot.x + (i + 0.5) * band, from: point.from, to: point.to }));
475
+ const upper = points.map((p) => `${c.num(p.x)} ${c.num(at(p.to))}`);
476
+ const lower = points.slice().reverse().map((p) => `${c.num(p.x)} ${c.num(p.from === 0 ? crossing : at(p.from))}`);
477
+ 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}`);
478
+ });
479
+ }
480
+
481
+ // ---------------------------------------------------------------------------
482
+ // Pie and doughnut: first series only, clockwise from 12 o'clock, one colour per category.
483
+
484
+ function renderCircularChart(c, spec) {
485
+ const { box, pad } = c;
486
+ const categories = c.rows.map((row) => c.label(row[0]));
487
+ const values = c.rows.map((row) => Math.abs(chartNumber(row[1]) ?? 0));
488
+ const legendWidth = legendEntries(c, categories.map((name, i) => ({
489
+ name, color: c.colors[i % c.colors.length], path: `${c.path}.data.rows.${i}.0`
490
+ })));
491
+ 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) };
492
+ const cx = area.x + area.width / 2, cy = area.y + area.height / 2;
493
+ const r = Math.max(1, Math.min(area.width, area.height) / 2 * 0.9);
494
+ const inner = spec.kind === "doughnut" ? r * 0.5 : 0;
495
+ const total = values.reduce((sum, value) => sum + value, 0);
496
+ if (!Number.isFinite(total)) throw chartAggregateError(c.path, "slice total");
497
+ if (!total) {
498
+ c.text("No positive chart values", area, c.path);
499
+ return;
500
+ }
501
+ const border = { stroke: "#F9F9F9", "stroke-width": 0.75 * c.pt };
502
+ let angle = -Math.PI / 2;
503
+ values.forEach((value, i) => {
504
+ const delta = value / total * Math.PI * 2, end = angle + delta;
505
+ const fill = c.colors[i % c.colors.length], path = `${c.path}.data.rows.${i}.1`;
506
+ if (delta >= Math.PI * 2 - 1e-9) {
507
+ if (inner) c.mark("path", { d: `${circlePath(c, cx, cy, r)} ${circlePath(c, cx, cy, inner)}`, fill, "fill-rule": "evenodd", ...border }, path);
508
+ else c.mark("circle", { cx, cy, r, fill, ...border }, path);
509
+ } else if (delta > 0) {
510
+ c.mark("path", { d: slicePath(c, cx, cy, r, inner, angle, end), fill, ...border }, path);
511
+ }
512
+ angle = end;
513
+ });
514
+ }
515
+
516
+ function polar(cx, cy, r, angle) {
517
+ return [cx + r * Math.cos(angle), cy + r * Math.sin(angle)];
518
+ }
519
+
520
+ function circlePath(c, cx, cy, r) {
521
+ const n = c.num;
522
+ return `M ${n(cx - r)} ${n(cy)} A ${n(r)} ${n(r)} 0 1 0 ${n(cx + r)} ${n(cy)} A ${n(r)} ${n(r)} 0 1 0 ${n(cx - r)} ${n(cy)} Z`;
523
+ }
524
+
525
+ function slicePath(c, cx, cy, r, inner, start, end) {
526
+ const n = c.num, large = end - start > Math.PI ? 1 : 0;
527
+ const [x1, y1] = polar(cx, cy, r, start), [x2, y2] = polar(cx, cy, r, end);
528
+ if (!inner) return `M ${n(cx)} ${n(cy)} L ${n(x1)} ${n(y1)} A ${n(r)} ${n(r)} 0 ${large} 1 ${n(x2)} ${n(y2)} Z`;
529
+ const [x3, y3] = polar(cx, cy, inner, end), [x4, y4] = polar(cx, cy, inner, start);
530
+ return `M ${n(x1)} ${n(y1)} A ${n(r)} ${n(r)} 0 ${large} 1 ${n(x2)} ${n(y2)} L ${n(x3)} ${n(y3)} A ${n(inner)} ${n(inner)} 0 ${large} 0 ${n(x4)} ${n(y4)} Z`;
531
+ }
532
+
533
+ // ---------------------------------------------------------------------------
534
+ // Scatter: X from columns[1], Y series from columns[2..], markers only.
535
+
536
+ export function scatterSeries(rows, columns) {
537
+ const numericX = columns.length > 2;
538
+ const xs = rows.map((row, i) => numericX ? chartNumber(row[1]) : i + 1);
539
+ const first = numericX ? 2 : 1;
540
+ const series = columns.slice(first).map((name, offset) => ({
541
+ name: name === null || name === undefined ? "" : String(name), column: first + offset, index: offset,
542
+ points: rows.map((row, i) => ({ row: i, x: xs[i], y: chartNumber(row[first + offset]) })).filter((p) => p.x !== null && p.y !== null)
543
+ }));
544
+ return { series, xs };
545
+ }
546
+
547
+ function renderScatterChart(c) {
548
+ const { box, pad, fontPx } = c;
549
+ const { series } = scatterSeries(c.rows, c.columns);
550
+ const points = series.flatMap((s) => s.points);
551
+ const xs = points.map((p) => p.x), ys = points.map((p) => p.y);
552
+ const legendWidth = seriesLegend(c, series, "marker");
553
+ const top = box.y + pad + c.lineHeight / 2, bottom = box.y + box.height - pad - c.lineHeight;
554
+ const height = Math.max(1, bottom - top);
555
+ const yScale = niceScale(ys.length ? Math.min(...ys) : 0, ys.length ? Math.max(...ys) : 1, maxIntervalsFor(height, c.lineHeight * 1.2));
556
+ const tickWidth = Math.max(...yScale.ticks.map((tick) => c.width(formatTick(tick)))) + fontPx * 0.5;
557
+ const plot = { x: box.x + pad + tickWidth, y: top, height };
558
+ plot.width = Math.max(1, box.x + box.width - pad - legendWidth - fontPx - plot.x);
559
+ const xLabelWidth = Math.max(c.width(formatTick(xs.length ? Math.max(...xs) : 1)), c.width(formatTick(xs.length ? Math.min(...xs) : 0))) + fontPx;
560
+ const xScale = niceScale(xs.length ? Math.min(...xs) : 0, xs.length ? Math.max(...xs) : 1, maxIntervalsFor(plot.width, xLabelWidth));
561
+ const xAt = (v) => plot.x + axisFraction(v, xScale) * plot.width;
562
+ const yAt = (v) => plot.y + axisFraction(v, yScale, true) * plot.height;
563
+ for (const tick of yScale.ticks) {
564
+ const y = yAt(tick);
565
+ gridline(c, plot.x, y, plot.x + plot.width, y);
566
+ 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");
567
+ }
568
+ for (const tick of xScale.ticks) {
569
+ const label = formatTick(tick), width = c.width(label) + fontPx;
570
+ c.text(label, { x: xAt(tick) - width / 2, y: plot.y + plot.height, width, height: c.lineHeight }, c.path);
571
+ }
572
+ const yCross = yAt(Math.min(yScale.max, Math.max(yScale.min, 0))), xCross = xAt(Math.min(xScale.max, Math.max(xScale.min, 0)));
573
+ axisLine(c, xCross, plot.y, xCross, plot.y + plot.height);
574
+ axisLine(c, plot.x, yCross, plot.x + plot.width, yCross);
575
+ series.forEach((s, j) => {
576
+ const color = c.colors[j % c.colors.length];
577
+ 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}`);
578
+ });
579
+ }
580
+
581
+ // ---------------------------------------------------------------------------
582
+ // Radar: categories clockwise from 12 o'clock, value axis on the vertical spoke.
583
+
584
+ function renderRadarChart(c, spec) {
585
+ const { box, pad, fontPx } = c;
586
+ const series = c.series();
587
+ const categories = c.rows.map((row) => c.label(row[0]));
588
+ const count = categories.length;
589
+ const values = series.flatMap((s) => s.values).filter((v) => v !== null);
590
+ const legendWidth = seriesLegend(c, series, spec.style === "filled" ? undefined : "line", spec.markers);
591
+ const labelWidth = Math.min(box.width * 0.2, Math.max(0, ...categories.map(c.width)) + fontPx * 0.5);
592
+ 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) };
593
+ const cx = area.x + area.width / 2, cy = area.y + area.height / 2;
594
+ const radius = Math.max(1, Math.min(area.width / 2 - labelWidth, area.height / 2 - c.lineHeight));
595
+ const scale = niceScale(values.length ? Math.min(...values) : 0, values.length ? Math.max(...values) : 1, maxIntervalsFor(radius, c.lineHeight));
596
+ const angle = (i) => -Math.PI / 2 + i / count * Math.PI * 2;
597
+ const rAt = (v) => axisFraction(Math.min(scale.max, Math.max(scale.min, v)), scale) * radius;
598
+ const point = (i, v) => polar(cx, cy, rAt(v), angle(i));
599
+ const n = c.num;
600
+ for (const tick of scale.ticks) {
601
+ if (tick === scale.min) continue;
602
+ const ring = categories.map((_, i) => point(i, tick).map(n).join(" "));
603
+ c.mark("path", { d: `M ${ring.join(" L ")} Z`, fill: "none", stroke: c.gridColor, "stroke-opacity": 0.7, "stroke-width": c.pt });
604
+ }
605
+ categories.forEach((name, i) => {
606
+ const [x, y] = polar(cx, cy, radius, angle(i));
607
+ axisLine(c, cx, cy, x, y);
608
+ const [lx, ly] = polar(cx, cy, radius + fontPx * 0.4, angle(i));
609
+ const cos = Math.cos(angle(i)), sin = Math.sin(angle(i));
610
+ const align = Math.abs(cos) < 0.2 ? "center" : cos > 0 ? "left" : "right";
611
+ const rect = {
612
+ x: align === "center" ? lx - labelWidth / 2 : align === "left" ? lx : lx - labelWidth,
613
+ y: Math.abs(sin) < 0.2 ? ly - c.lineHeight / 2 : sin < 0 ? ly - c.lineHeight : ly,
614
+ width: labelWidth, height: c.lineHeight
615
+ };
616
+ c.text(name, rect, `${c.path}.data.rows.${i}.0`, align);
617
+ });
618
+ series.forEach((s, j) => {
619
+ const color = c.colors[j % c.colors.length], path = `${c.path}.data.columns.${s.column}`;
620
+ if (spec.style === "filled") {
621
+ const outline = s.values.map((v, i) => point(i, v ?? scale.min).map(n).join(" "));
622
+ c.mark("path", { d: `M ${outline.join(" L ")} Z`, fill: color }, path);
623
+ return;
624
+ }
625
+ const at = (i) => point(i, s.values[i]).map(n).join(" ");
626
+ let d = "";
627
+ if (s.values.every((v) => v !== null)) d = `M ${s.values.map((_, i) => at(i)).join(" L ")}${count > 2 ? " Z" : ""}`;
628
+ else for (let i = 0; i < count; i++) {
629
+ const next = (i + 1) % count;
630
+ if (s.values[i] !== null && s.values[next] !== null && next !== i) d += `${d ? " " : ""}M ${at(i)} L ${at(next)}`;
631
+ }
632
+ if (d) c.mark("path", { d, fill: "none", stroke: color, "stroke-width": 2 * c.pt, "stroke-linejoin": "round", "stroke-linecap": "round" }, path);
633
+ if (spec.markers) s.values.forEach((v, i) => {
634
+ if (v === null) return;
635
+ const [x, y] = point(i, v);
636
+ c.mark("circle", { cx: x, cy: y, r: 3 * c.pt, fill: color, stroke: color, "stroke-width": 0.75 * c.pt }, `${c.path}.data.rows.${i}.${s.column}`);
637
+ });
638
+ });
639
+ // Value axis labels sit on top of filled series, as in PowerPoint.
640
+ for (const tick of scale.ticks) {
641
+ const label = formatTick(tick), width = c.width(label) + fontPx * 0.5;
642
+ c.text(label, { x: cx - width - fontPx * 0.2, y: cy - rAt(tick) - c.lineHeight / 2, width, height: c.lineHeight }, c.path, "right");
643
+ }
644
+ }
package/dist/svg.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  import * as opfCore from "@openpresentation/opf";
10
10
  import { createScriptFonts } from "./script-fonts.js";
11
11
  import { disabledFeaturesStyle } from "./font-compatibility.js";
12
+ import { renderCatalogChart } from "./charts.js";
12
13
 
13
14
  export const packageName = "@openpresentation/opf-render";
14
15
 
@@ -1217,88 +1218,11 @@ function renderTableBorders(defaultEdges, explicitEdges, scale, bound, options)
1217
1218
  });
1218
1219
  }
1219
1220
 
1220
- function renderImportedChart(item, box, bound, options) {
1221
- const chart=item.value, rows=chart.data?.rows??[], columns=chart.data?.columns??[];
1222
- if(!rows.length||columns.length<2)return null;
1223
- const kind=chart.type, circular=['pie','donut','doughnut'].includes(kind);
1224
- const colors=[bound.design.colors.primary,bound.design.colors.secondary,'#9B6BCC','#D98944','#429B85','#CB5D79'].map(color=>chartColorForFill(bound.design.colors.surface,color));
1225
- const children=[];
1226
- const labelColor=textColorForFill(bound.design.colors.surface,bound.design.colors.text);
1227
- const text=(value,rect,path,size=16,align='center')=>renderTextBox(String(value),rect,bound,{path,fontSize:size,fontFamily:bound.design.fonts.body,fontWeight:400,fill:labelColor,options,align,verticalAlign:'middle'});
1228
- const number=value=>typeof value==='number'&&Number.isFinite(value)?value:typeof value==='string'&&value.trim()&&Number.isFinite(Number(value))?Number(value):null;
1229
- const series=columns.slice(1).map((name,j)=>({name,values:rows.map(row=>number(row[j+1]))}));
1230
- children.push(tag('rect',{x:box.x,y:box.y,width:box.width,height:box.height,fill:bound.design.colors.surface,stroke:bound.design.colors.border,...traceAttrs(options,item.path)}));
1231
- if(circular){
1232
- if(series.length!==1||series[0].values.some(v=>v!==null&&v<0)){
1233
- children.push(text('Pie charts need one nonnegative series',box,item.path,18));return tag('g',traceAttrs(options,item.path),children.join('\n'));
1234
- }
1235
- const values=series[0].values,total=values.reduce((sum,v)=>sum+(v??0),0),cx=box.x+box.width*.35,cy=box.y+box.height*.5,r=Math.max(1,Math.min(box.width*.3,box.height*.42));
1236
- if(!total){children.push(text('No positive chart values',box,item.path,18));return tag('g',traceAttrs(options,item.path),children.join('\n'));}
1237
- let angle=-Math.PI/2;
1238
- values.forEach((v,i)=>{
1239
- const delta=(v??0)/total*Math.PI*2,end=angle+delta,color=colors[i%colors.length];
1240
- if(delta>=Math.PI*2-1e-8)children.push(tag('circle',{cx,cy,r,fill:color,...traceAttrs(options,`${item.path}.data.rows.${i}.1`)}));
1241
- else if(delta>0)children.push(tag('path',{d:`M ${cx} ${cy} L ${cx+r*Math.cos(angle)} ${cy+r*Math.sin(angle)} A ${r} ${r} 0 ${delta>Math.PI?1:0} 1 ${cx+r*Math.cos(end)} ${cy+r*Math.sin(end)} Z`,fill:color,...traceAttrs(options,`${item.path}.data.rows.${i}.1`)}));
1242
- angle=end;
1243
- const y=box.y+15+i*Math.min(30,(box.height-30)/rows.length);
1244
- children.push(tag('rect',{x:box.x+box.width*.69,y:y+6,width:12,height:12,fill:color}));
1245
- children.push(text(`${rows[i][0]}: ${v??'—'}`,{x:box.x+box.width*.69+20,y,width:box.width*.29-20,height:28},`${item.path}.data.rows.${i}`,14,'left'));
1246
- });
1247
- if(kind!=='pie')children.push(tag('circle',{cx,cy,r:r*.57,fill:bound.design.colors.surface}));
1248
- }else{
1249
- const horizontal=kind==='bar',legendHeight=series.length>1?34:8;
1250
- const plot={x:box.x+(horizontal?100:64),y:box.y+legendHeight+14,width:Math.max(1,box.width-(horizontal?124:84)),height:Math.max(1,box.height-legendHeight-62)};
1251
- const values=series.flatMap(s=>s.values).filter(v=>v!==null),min=Math.min(0,...values),rawMax=Math.max(0,...values),max=rawMax===min?min+1:rawMax,range=max-min;
1252
- const fraction=(v,reverse=false)=>{
1253
- const distance=reverse?max-v:v-min;
1254
- if(Number.isFinite(range)&&Number.isFinite(distance))return distance/range;
1255
- // Finite endpoints can overflow during subtraction. Keep ordinary
1256
- // arithmetic unchanged and normalize only that exceptional range.
1257
- const unit=Math.max(Math.abs(min),Math.abs(max)),low=min/unit,high=max/unit,position=v/unit;
1258
- return (reverse?high-position:position-low)/(high-low);
1259
- };
1260
- const xValue=v=>plot.x+fraction(v)*plot.width,yValue=v=>plot.y+fraction(v,true)*plot.height;
1261
- const zero=horizontal?xValue(0):yValue(0);
1262
- for(let tick=0;tick<=4;tick++){
1263
- const direct=min+range*tick/4;
1264
- const value=Number.isFinite(direct)?direct:min*(1-tick/4)+max*(tick/4);
1265
- const at=horizontal?xValue(value):yValue(value),rounded=Number(value.toPrecision(4));
1266
- // Rounding MAX_VALUE to four digits can overflow although the tick is
1267
- // finite. Its exact shortest decimal remains truthful and parseable.
1268
- const label=Number.isFinite(rounded)?rounded:String(value);
1269
- children.push(tag('line',{x1:horizontal?at:plot.x,x2:horizontal?at:plot.x+plot.width,y1:horizontal?plot.y:at,y2:horizontal?plot.y+plot.height:at,stroke:bound.design.colors.border,'stroke-width':1}));
1270
- children.push(text(label,horizontal?{x:at-32,y:plot.y+plot.height+6,width:64,height:26}:{x:box.x+4,y:at-12,width:52,height:24},item.path,13,horizontal?'center':'right'));
1271
- }
1272
- if(series.length>1)series.forEach((s,j)=>{
1273
- const width=(box.width-24)/series.length,x=box.x+12+j*width;
1274
- children.push(tag('rect',{x,y:box.y+12,width:12,height:12,fill:colors[j%colors.length]}));
1275
- children.push(text(s.name,{x:x+18,y:box.y+5,width:Math.max(1,width-22),height:28},`${item.path}.data.columns.${j+1}`,14,'left'));
1276
- });
1277
- const categorySize=(horizontal?plot.height:plot.width)/rows.length;
1278
- rows.forEach((row,i)=>children.push(text(row[0],horizontal?{x:box.x+4,y:plot.y+i*categorySize,width:88,height:categorySize}:{x:plot.x+i*categorySize,y:plot.y+plot.height+7,width:categorySize,height:30},`${item.path}.data.rows.${i}.0`,14,horizontal?'right':'center')));
1279
- series.forEach((s,j)=>{
1280
- const color=colors[j%colors.length];
1281
- if(kind==='line'||kind==='area'){
1282
- let points=[];
1283
- const flush=()=>{if(!points.length)return;const pairs=points.map(p=>p.join(',')).join(' ');if(kind==='area')children.push(tag('polygon',{points:`${points[0][0]},${zero} ${pairs} ${points.at(-1)[0]},${zero}`,fill:color,'fill-opacity':.18}));children.push(tag('polyline',{points:pairs,fill:'none',stroke:color,'stroke-width':3,...traceAttrs(options,`${item.path}.data`)}));points=[];};
1284
- s.values.forEach((v,i)=>{if(v===null){flush();return;}const x=plot.x+(i+.5)*categorySize,y=yValue(v);points.push([x,y]);children.push(tag('circle',{cx:x,cy:y,r:4,fill:color,...traceAttrs(options,`${item.path}.data.rows.${i}.${j+1}`)}));});flush();
1285
- }else{
1286
- const bar=categorySize*.8/series.length;
1287
- s.values.forEach((v,i)=>{if(v===null)return;const at=(horizontal?plot.y:plot.x)+i*categorySize+categorySize*.1+j*bar;
1288
- children.push(tag('rect',horizontal?{x:Math.min(zero,xValue(v)),y:at,width:Math.abs(xValue(v)-zero),height:Math.max(.1,bar*.9),fill:color,...traceAttrs(options,`${item.path}.data.rows.${i}.${j+1}`)}:{x:at,y:Math.min(zero,yValue(v)),width:Math.max(.1,bar*.9),height:Math.abs(yValue(v)-zero),fill:color,...traceAttrs(options,`${item.path}.data.rows.${i}.${j+1}`)}));
1289
- });
1290
- }
1291
- });
1292
- children.push(tag('line',{x1:horizontal?zero:plot.x,x2:horizontal?zero:plot.x+plot.width,y1:horizontal?plot.y:zero,y2:horizontal?plot.y+plot.height:zero,stroke:labelColor,'stroke-width':1}));
1293
- }
1294
- return tag('g',traceAttrs(options,item.path),children.join('\n'));
1295
- }
1296
-
1297
1221
  function renderChart(item, box, bound, options) {
1298
- if (["column","bar","line","area","pie","donut","doughnut"].includes(item.value?.type)) {
1299
- const rendered = renderImportedChart(item, box, bound, options);
1300
- if (rendered) return rendered;
1301
- }
1222
+ // Catalog chart types (kept, deprecated and aliased ids) preview the native
1223
+ // construct opf-pptx exports; other ids keep the legacy single-series preview.
1224
+ const rendered = renderCatalogChart(item, box, bound, options, { tag, traceAttrs, stableNumber, renderTextBox });
1225
+ if (rendered) return rendered;
1302
1226
  const chart = item.value ?? {};
1303
1227
  const chartType = chart.type ?? engineDefaults.chartTypes[0];
1304
1228
  const data = inlineChartRows(chart.data);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-render",
3
- "version": "0.11.2",
3
+ "version": "0.11.3",
4
4
  "description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -56,9 +56,9 @@
56
56
  "sideEffects": false,
57
57
  "scripts": {
58
58
  "build": "node scripts/build.mjs",
59
- "typecheck": "node --check src/index.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check test/golden.mjs && node --check src/fonts.js && node --check src/script-fonts.js && node --check src/script-font-pack.js && node --check test/script-fonts.mjs && node --check test/glyph-fallback.mjs && node --check test/script-fonts-browser.mjs && node --check test/script-fonts-auto.mjs && node --check test/script-fonts-auto-robust.mjs && node --check test/script-fonts-auto-browser.mjs && node --check src/fonts-node.js && node --check src/font-compatibility.js && node --check test/office-fonts.mjs && node --check test/font-policy.mjs && node --check test/open-fonts.mjs && node --check test/aptos-preview.mjs && node --check test/aptos-preview-browser.mjs && node --check test/lazy-fonts.mjs && node --check test/lazy-fonts-browser.mjs && node --check test/lazy-face-fallback.mjs && node --check test/lazy-face-fallback-browser.mjs && node --check src/font-policy.js && node --check scripts/update-font-policy.mjs && node --check src/fonts-browser.js && node --check test/fonts-browser.mjs && node --check src/svg.js && node --check src/raster.js && node --check src/raster-images.js && node --check test/webp.mjs && node --check test/jpeg-orientation.mjs && node --check scripts/build-browser-check.mjs && node --check test/jpeg-browser.js && node --check test/rich-table.mjs && node --check test/rich-tab.mjs && node --check test/rich-tab-browser.mjs && node --check test/rich-tab-estimated-browser.mjs && node --check test/styled-table.mjs && node --check test/color-references.mjs && node --check test/default-font-scheme.mjs && node --check test/chart-colors.mjs && node --check test/chart-axis.mjs && node --check test/image-placeholders.mjs && node --check test/image-placeholders-browser.mjs && node --check test/accepted-text.mjs && node --check test/accepted-text-fixtures.mjs && node --check test/accepted-text-browser.mjs && node --check test/rich-spacing-browser.mjs && node --check test/rich-flow-browser.mjs && node --check test/rich-flow-review-sheets.mjs && node --check test/furniture.mjs && node --check test/socials.mjs && node --check test/slide-image.mjs && node --check test/slide-image-treatments.mjs && node --check test/item-alignment.mjs && node --check test/cover-centering.mjs && node --check scripts/font-license.mjs && node --check test/font-licenses.mjs && node --check scripts/check-font-hotlinks.mjs && node --check scripts/check-font-hotlinks.test.mjs",
59
+ "typecheck": "node --check src/index.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check test/golden.mjs && node --check src/fonts.js && node --check src/script-fonts.js && node --check src/script-font-pack.js && node --check test/script-fonts.mjs && node --check test/glyph-fallback.mjs && node --check test/script-fonts-browser.mjs && node --check test/script-fonts-auto.mjs && node --check test/script-fonts-auto-robust.mjs && node --check test/script-fonts-auto-browser.mjs && node --check src/fonts-node.js && node --check src/font-compatibility.js && node --check test/office-fonts.mjs && node --check test/font-policy.mjs && node --check test/open-fonts.mjs && node --check test/aptos-preview.mjs && node --check test/aptos-preview-browser.mjs && node --check test/lazy-fonts.mjs && node --check test/lazy-fonts-browser.mjs && node --check test/lazy-face-fallback.mjs && node --check test/lazy-face-fallback-browser.mjs && node --check src/font-policy.js && node --check scripts/update-font-policy.mjs && node --check src/fonts-browser.js && node --check test/fonts-browser.mjs && node --check src/svg.js && node --check src/raster.js && node --check src/raster-images.js && node --check test/webp.mjs && node --check test/jpeg-orientation.mjs && node --check scripts/build-browser-check.mjs && node --check test/jpeg-browser.js && node --check test/rich-table.mjs && node --check test/rich-tab.mjs && node --check test/rich-tab-browser.mjs && node --check test/rich-tab-estimated-browser.mjs && node --check test/styled-table.mjs && node --check test/color-references.mjs && node --check test/default-font-scheme.mjs && node --check test/chart-colors.mjs && node --check test/chart-axis.mjs && node --check src/charts.js && node --check test/chart-types.mjs && node --check test/chart-scale.mjs && node --check test/chart-catalog.mjs && node --check test/image-placeholders.mjs && node --check test/image-placeholders-browser.mjs && node --check test/accepted-text.mjs && node --check test/accepted-text-fixtures.mjs && node --check test/accepted-text-browser.mjs && node --check test/rich-spacing-browser.mjs && node --check test/rich-flow-browser.mjs && node --check test/rich-flow-review-sheets.mjs && node --check test/furniture.mjs && node --check test/socials.mjs && node --check test/slide-image.mjs && node --check test/slide-image-treatments.mjs && node --check test/item-alignment.mjs && node --check test/cover-centering.mjs && node --check scripts/font-license.mjs && node --check test/font-licenses.mjs && node --check scripts/check-font-hotlinks.mjs && node --check scripts/check-font-hotlinks.test.mjs",
60
60
  "validate": "node scripts/validate-package.mjs",
61
- "test": "npm run build && npm run check:fonts && node test/furniture.mjs && node test/socials.mjs && node test/font-outlines.mjs && node test/accepted-text.mjs && node test/image-placeholders.mjs && node test/slide-image.mjs && node test/slide-image-treatments.mjs && node test/chart-colors.mjs && node test/chart-axis.mjs && node test/smoke.mjs && node test/webp.mjs && node test/jpeg-orientation.mjs && node test/fonts.mjs && node test/office-fonts.mjs && node test/font-policy.mjs && node test/open-fonts.mjs && node test/aptos-preview.mjs && node test/lazy-fonts.mjs && node test/lazy-face-fallback.mjs && node test/timeline.mjs && node test/quote-footer.mjs && node test/shared-quote.mjs && node test/rich-trace.mjs && node test/rich-table.mjs && node test/rich-tab.mjs && node test/color-references.mjs && node test/default-font-scheme.mjs && node test/golden-gate.mjs && node test/golden.mjs && node test/fonts-browser.mjs && node test/script-fonts.mjs && node test/glyph-fallback.mjs && node test/script-fonts-auto.mjs && node test/script-fonts-auto-robust.mjs && node test/design-preview.mjs && node test/item-alignment.mjs && node test/cover-centering.mjs && npm run build:browser-check && npm run test:styled-table",
61
+ "test": "npm run build && npm run check:fonts && node test/furniture.mjs && node test/socials.mjs && node test/font-outlines.mjs && node test/accepted-text.mjs && node test/image-placeholders.mjs && node test/slide-image.mjs && node test/slide-image-treatments.mjs && node test/chart-colors.mjs && node test/chart-axis.mjs && node test/chart-types.mjs && node test/chart-scale.mjs && node test/chart-catalog.mjs && node test/smoke.mjs && node test/webp.mjs && node test/jpeg-orientation.mjs && node test/fonts.mjs && node test/office-fonts.mjs && node test/font-policy.mjs && node test/open-fonts.mjs && node test/aptos-preview.mjs && node test/lazy-fonts.mjs && node test/lazy-face-fallback.mjs && node test/timeline.mjs && node test/quote-footer.mjs && node test/shared-quote.mjs && node test/rich-trace.mjs && node test/rich-table.mjs && node test/rich-tab.mjs && node test/color-references.mjs && node test/default-font-scheme.mjs && node test/golden-gate.mjs && node test/golden.mjs && node test/fonts-browser.mjs && node test/script-fonts.mjs && node test/glyph-fallback.mjs && node test/script-fonts-auto.mjs && node test/script-fonts-auto-robust.mjs && node test/design-preview.mjs && node test/item-alignment.mjs && node test/cover-centering.mjs && npm run build:browser-check && npm run test:styled-table",
62
62
  "check:fonts": "node test/font-licenses.mjs && npm run check:font-hotlinks",
63
63
  "check:font-hotlinks": "node --test scripts/check-font-hotlinks.test.mjs && node scripts/check-font-hotlinks.mjs",
64
64
  "test:golden": "npm run build && node test/golden.mjs",