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