@openpresentation/opf-render 0.13.1 → 0.15.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 +123 -86
- package/dist/chart-options.js +26 -21
- package/dist/charts.js +206 -123
- package/dist/deck-runtime.js +21 -34
- package/dist/element.d.ts +11 -6
- package/dist/element.js +28 -18
- package/dist/export-browser.d.ts +7 -1
- package/dist/export-browser.js +7 -6
- package/dist/font-compatibility.js +4 -4
- package/dist/font-registry.js +324 -0
- package/dist/fonts-browser.d.ts +25 -3
- package/dist/fonts-browser.js +54 -3
- package/dist/fonts-node.d.ts +32 -16
- package/dist/fonts-node.js +110 -50
- package/dist/fonts.d.ts +48 -27
- package/dist/fonts.js +7 -320
- package/dist/index.d.ts +45 -40
- package/dist/lazy-font-list.js +1 -1
- package/dist/lazy-fonts.js +7 -7
- package/dist/pdf-fonts.js +1 -1
- package/dist/player.d.ts +4 -4
- package/dist/player.js +5 -5
- package/dist/preview-fonts.d.ts +4 -4
- package/dist/preview-fonts.js +6 -5
- package/dist/raster.js +24 -9
- package/dist/script-font-pack.js +25 -31
- package/dist/script-fonts.js +6 -17
- package/dist/svg.d.ts +39 -38
- package/dist/svg.js +394 -376
- package/dist/symbol-fonts.js +22 -55
- package/package.json +2 -2
- package/dist/font-policy.js +0 -2734
- package/dist/symbol-encodings.js +0 -7
package/dist/charts.js
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
|
-
// Chart previews for the OPF chart
|
|
1
|
+
// Chart previews for the OPF chart types (FF-22).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
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
|
-
// 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";
|
|
3
|
+
// OPF 0.15 makes chart.type an engine vocabulary: core's schema enum and CHART_TYPES, one type per Office chart construct.
|
|
4
|
+
// The table below is carried here so the preview stays synchronous and self-contained; test/chart-catalog.mjs checks it
|
|
5
|
+
// against core's CHART_TYPES. Each type renders the construct PowerPoint shows for the exported chart; any other value
|
|
6
|
+
// returns null so the caller keeps its legacy single-series preview.
|
|
7
|
+
import { chartHighlightColors, chartHighlightMarks, chartNumber, chartPaletteForFill, formatDataNumber, numberFormatError, resolveChartData, resolveTextStyle, textColorForFill, textWidthMeasurer } from "@openpresentation/opf/composition";
|
|
13
8
|
import { drawAxisTitles, drawBarLabel, drawCenteredLabel, drawPointLabel, drawSliceLabel, labelString, outsideLabelReserve, reportOptionDiagnostics, reserveAxisTitles, resolveOptions } from "./chart-options.js";
|
|
14
9
|
|
|
15
10
|
// Ordered series palette written by opf-pptx (`CHART_COLORS`), before the same
|
|
@@ -19,12 +14,34 @@ export const CHART_SERIES_COLORS = Object.freeze([
|
|
|
19
14
|
"#F59E0B", "#EF4444", "#8B5CF6", "#14B8A6", "#0F172A", "#64748B"
|
|
20
15
|
]);
|
|
21
16
|
|
|
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.
|
|
17
|
+
// The series colours for a chart panel: core's `chartPaletteForFill` (contrast against the panel without letting two series merge).
|
|
24
18
|
export function chartSeriesPalette(surface) {
|
|
25
|
-
return
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
return chartPaletteForFill(surface, CHART_SERIES_COLORS);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// FA-14: chart.highlight. Core resolves which series and categories are named (chartHighlightMarks) and the two colours
|
|
23
|
+
// (chartHighlightColors); a highlighted mark takes the accent, every other mark the muted neutral. A chart without a
|
|
24
|
+
// highlight has no `c.highlight`, and every colour below is the palette colour it always was.
|
|
25
|
+
function highlightFor(c, resolved, spec, data) {
|
|
26
|
+
if (!resolved.highlight) return undefined;
|
|
27
|
+
const marks = chartHighlightMarks(resolved.highlight, { columns: data.columns, hasX: spec.kind === "scatter" && data.columns.length > 2, rows: data.rows });
|
|
28
|
+
if (!marks) return undefined;
|
|
29
|
+
return { ...marks, ...chartHighlightColors(c.surface, c.bound.design.colors.primary, c.labelColor) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The colour of series `j` as a whole (a line, an area, a legend key): the accent when highlighted, else muted. */
|
|
33
|
+
function seriesColor(c, j) {
|
|
34
|
+
return c.highlight ? (c.highlight.series[j] ? c.highlight.accent : c.highlight.muted) : c.colors[j % c.colors.length];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The colour of the mark of series `j` at row `i`: highlighted when its series or its category is named. */
|
|
38
|
+
function markColor(c, j, i) {
|
|
39
|
+
return c.highlight ? (c.highlight.series[j] || c.highlight.categories[i] ? c.highlight.accent : c.highlight.muted) : c.colors[j % c.colors.length];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The colour of the pie or doughnut slice of row `i`. */
|
|
43
|
+
function sliceColor(c, i) {
|
|
44
|
+
return c.highlight ? (c.highlight.categories[i] ? c.highlight.accent : c.highlight.muted) : c.colors[i % c.colors.length];
|
|
28
45
|
}
|
|
29
46
|
|
|
30
47
|
const bar = (dir, grouping) => ({ kind: "bar", dir, grouping });
|
|
@@ -34,18 +51,18 @@ const radar = (style) => ({ kind: "radar", style, markers: style === "marker" })
|
|
|
34
51
|
|
|
35
52
|
export const CHART_TYPES = Object.freeze({
|
|
36
53
|
column: bar("col", "clustered"),
|
|
37
|
-
"stacked-column
|
|
38
|
-
"100pct-stacked-column
|
|
54
|
+
"stacked-column": bar("col", "stacked"),
|
|
55
|
+
"100pct-stacked-column": bar("col", "percentStacked"),
|
|
39
56
|
bar: bar("bar", "clustered"),
|
|
40
|
-
"stacked-bar
|
|
41
|
-
"100pct-stacked-bar
|
|
57
|
+
"stacked-bar": bar("bar", "stacked"),
|
|
58
|
+
"100pct-stacked-bar": bar("bar", "percentStacked"),
|
|
42
59
|
line: line("standard", false),
|
|
43
60
|
"line-with-markers": line("standard", true),
|
|
44
|
-
"stacked-line
|
|
45
|
-
"stacked-line-with-markers
|
|
61
|
+
"stacked-line": line("stacked", false),
|
|
62
|
+
"stacked-line-with-markers": line("stacked", true),
|
|
46
63
|
area: area("standard"),
|
|
47
|
-
"stacked-area
|
|
48
|
-
"100pct-stacked-area
|
|
64
|
+
"stacked-area": area("stacked"),
|
|
65
|
+
"100pct-stacked-area": area("percentStacked"),
|
|
49
66
|
pie: { kind: "pie" },
|
|
50
67
|
doughnut: { kind: "doughnut" },
|
|
51
68
|
scatter: { kind: "scatter" },
|
|
@@ -58,54 +75,14 @@ export const CHART_TYPES = Object.freeze({
|
|
|
58
75
|
"box-and-whisker": { kind: "box" },
|
|
59
76
|
waterfall: { kind: "waterfall" },
|
|
60
77
|
funnel: { kind: "funnel" },
|
|
61
|
-
world: { kind: "map" }
|
|
78
|
+
world: { kind: "map" },
|
|
79
|
+
// FA-15: clustered columns with line series (markers), the lines optionally on a secondary value axis (core resolves the plan).
|
|
80
|
+
combo: { kind: "combo", dir: "col", grouping: "clustered", markers: true }
|
|
62
81
|
});
|
|
63
82
|
|
|
64
|
-
|
|
65
|
-
const suffixes = (base, target) => Object.fromEntries(["", "-2x", "-3x", "-4x", "-5x", "-6x"].map((suffix) => [`${base}${suffix}`, target]));
|
|
66
|
-
|
|
67
|
-
// Deprecated core ids -> kept replacement (core spec/catalogs/chart-types `deprecation.replacedBy`).
|
|
68
|
-
export const DEPRECATED_CHART_TYPES = Object.freeze({
|
|
69
|
-
...variants("100pct-bullet-bar", "100pct-stacked-bar-3x"),
|
|
70
|
-
"100pct-progress-bar": "100pct-stacked-bar-3x",
|
|
71
|
-
"100pct-stacked-bar-2x": "100pct-stacked-bar-3x",
|
|
72
|
-
...variants("100pct-bullet-column", "100pct-stacked-column-3x"),
|
|
73
|
-
"100pct-stacked-column-2x": "100pct-stacked-column-3x",
|
|
74
|
-
"100pct-stacked-area-2x": "100pct-stacked-area-3x",
|
|
75
|
-
australia: "world",
|
|
76
|
-
canada: "world",
|
|
77
|
-
"united-kingdom": "world",
|
|
78
|
-
"united-states": "world",
|
|
79
|
-
"box-and-whisker-2x": "box-and-whisker",
|
|
80
|
-
"box-and-whisker-3x": "box-and-whisker",
|
|
81
|
-
...variants("bullet-bar", "bar"),
|
|
82
|
-
"clustered-bar-2x": "bar",
|
|
83
|
-
...variants("bullet-column", "column"),
|
|
84
|
-
"clustered-column": "column",
|
|
85
|
-
...suffixes("dot-plot", "scatter"),
|
|
86
|
-
dumbbell: "scatter",
|
|
87
|
-
"line-2x": "line",
|
|
88
|
-
"line-3x": "line",
|
|
89
|
-
"line-with-high-low": "line",
|
|
90
|
-
...suffixes("sparkline", "line"),
|
|
91
|
-
"line-with-high-low-and-markers": "line-with-markers",
|
|
92
|
-
"line-with-markers-2x": "line-with-markers",
|
|
93
|
-
"line-with-markers-3x": "line-with-markers",
|
|
94
|
-
"stacked-area-2x": "stacked-area-3x",
|
|
95
|
-
"stacked-bar-2x": "stacked-bar-3x",
|
|
96
|
-
"stacked-column-2x": "stacked-column-3x",
|
|
97
|
-
"stacked-line-2x": "stacked-line-3x",
|
|
98
|
-
"stacked-line-with-markers-2x": "stacked-line-with-markers-3x",
|
|
99
|
-
"treemap-2x": "treemap",
|
|
100
|
-
"treemap-3x": "treemap"
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
const ALIASES = Object.freeze({ donut: "doughnut" });
|
|
104
|
-
|
|
105
|
-
/** Resolve a chart type id to its kept catalog id, or null for ids outside the catalog. */
|
|
83
|
+
/** Resolve a chart type (core's CHART_TYPES vocabulary, OPF 0.15) to its preview spec id, or null for any other value. */
|
|
106
84
|
export function resolveChartType(type) {
|
|
107
|
-
const
|
|
108
|
-
const id = ALIASES[raw] ?? DEPRECATED_CHART_TYPES[raw] ?? raw;
|
|
85
|
+
const id = String(type ?? "").trim().toLowerCase();
|
|
109
86
|
return Object.hasOwn(CHART_TYPES, id) ? id : null;
|
|
110
87
|
}
|
|
111
88
|
|
|
@@ -124,7 +101,8 @@ const RENDERERS = {
|
|
|
124
101
|
box: renderBoxWhiskerChart,
|
|
125
102
|
waterfall: renderWaterfallChart,
|
|
126
103
|
funnel: renderFunnelChart,
|
|
127
|
-
map: renderRegionMapChart
|
|
104
|
+
map: renderRegionMapChart,
|
|
105
|
+
combo: renderComboChart
|
|
128
106
|
};
|
|
129
107
|
|
|
130
108
|
// Chartex constructs also accept a lone value column (the exporter plots it
|
|
@@ -132,7 +110,7 @@ const RENDERERS = {
|
|
|
132
110
|
const CHARTEX_KINDS = new Set(["treemap", "histogram", "pareto", "box", "waterfall", "funnel", "map"]);
|
|
133
111
|
|
|
134
112
|
/**
|
|
135
|
-
* Render a
|
|
113
|
+
* Render a chart of a known type. Returns null when the type is outside CHART_TYPES, has
|
|
136
114
|
* no preview renderer, or carries no inline category-major data, so the caller
|
|
137
115
|
* can keep its legacy output ("No chart data" for unresolved sources).
|
|
138
116
|
*/
|
|
@@ -147,6 +125,9 @@ export function renderCatalogChart(item, box, bound, options, svg) {
|
|
|
147
125
|
// RR-35: a chart with axis titles, a legend position or data labels reserves their space before the plot is laid out. A chart
|
|
148
126
|
// with none of them skips this block entirely, so its output is unchanged.
|
|
149
127
|
const resolved = resolveOptions(item.value, spec);
|
|
128
|
+
// FA-14: a highlight changes colours only (no space is reserved), so it is resolved whether or not an option is active.
|
|
129
|
+
c.highlight = highlightFor(c, resolved, spec, data);
|
|
130
|
+
if (!resolved.active) reportOptionDiagnostics(c, resolved);
|
|
150
131
|
if (resolved.active) {
|
|
151
132
|
reportOptionDiagnostics(c, resolved);
|
|
152
133
|
c.dataLabels = resolved.dataLabels;
|
|
@@ -162,20 +143,13 @@ export function renderCatalogChart(item, box, bound, options, svg) {
|
|
|
162
143
|
// RR-54: the chart's data as the renderers plot it, from core's resolveChartData: inline columns (names or DataColumn objects),
|
|
163
144
|
// a dataset reference and chart.mapping all resolve to one positional table, [category, (x,) ...series], with each column's number
|
|
164
145
|
// format. Series cells have already gone through core's strict chartNumber. Returns null when the data does not resolve (an
|
|
165
|
-
// external data source, an unknown dataset, no rows or columns), so the caller keeps its placeholder.
|
|
166
|
-
// reads the inline data as before.
|
|
146
|
+
// external data source, an unknown dataset, no rows or columns), so the caller keeps its placeholder.
|
|
167
147
|
export function chartData(item, bound) {
|
|
168
148
|
const chart = item.value;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return { columns: resolved.columns, rows: resolved.rows, formats, trace: tracePaths(item, bound, resolved) };
|
|
174
|
-
}
|
|
175
|
-
const data = chart?.data;
|
|
176
|
-
const rows = Array.isArray(data?.rows) ? data.rows.map((row) => Array.isArray(row) ? row : [row]) : [];
|
|
177
|
-
const columns = Array.isArray(data?.columns) ? data.columns : [];
|
|
178
|
-
return { columns, rows, formats: [], trace: null };
|
|
149
|
+
const resolved = resolveChartData(chart, bound?.presentation, { path: item.path });
|
|
150
|
+
if (!resolved.ok) return null;
|
|
151
|
+
const formats = resolved.formats.map((format) => typeof format === "string" && format !== "" && !numberFormatError(format) ? format : undefined);
|
|
152
|
+
return { columns: resolved.columns, rows: resolved.rows, formats, trace: tracePaths(item, bound, resolved), ...(resolved.combo ? { combo: resolved.combo } : {}) };
|
|
179
153
|
}
|
|
180
154
|
|
|
181
155
|
// The authored object at a composed path ("slides.0.blocks.1.chart"), or undefined.
|
|
@@ -202,19 +176,19 @@ export function isDatasetTable(item, bound) {
|
|
|
202
176
|
}
|
|
203
177
|
|
|
204
178
|
// Where a mark's trace path points. A dataset-backed chart has no `data.rows` or `data.columns` of its own, so every part reports
|
|
205
|
-
// the chart's authored path; a chart with `mapping`
|
|
206
|
-
// order is category, X, then the mapped series).
|
|
179
|
+
// the chart's authored path; a chart with `mapping` (or a combo chart, whose column series come before its lines) reports the
|
|
180
|
+
// authored column index of each plotted column (the resolved order is category, X, then the mapped series).
|
|
207
181
|
function tracePaths(item, bound, resolved) {
|
|
208
182
|
if (isDatasetChart(item, bound)) return { collapse: true };
|
|
209
183
|
const authored = authoredAt(bound?.presentation, item.path);
|
|
210
184
|
const authoredData = authored && typeof authored === "object" ? authored.data : undefined;
|
|
211
|
-
if (!authored || typeof authored !== "object" || !authored.mapping || !Array.isArray(authoredData?.columns)) return null;
|
|
185
|
+
if (!authored || typeof authored !== "object" || (!authored.mapping && !resolved.combo) || !Array.isArray(authoredData?.columns)) return null;
|
|
212
186
|
const names = authoredData.columns.map((column) => column !== null && typeof column === "object" ? column.name : column);
|
|
213
187
|
const columnMap = resolved.columns.map((name, index) => { const found = names.indexOf(name); return found < 0 ? index : found; });
|
|
214
188
|
return columnMap.every((column, index) => column === index) ? null : { columnMap };
|
|
215
189
|
}
|
|
216
190
|
|
|
217
|
-
function chartContext(item, box, bound, options, svg, { rows, columns, formats, trace }) {
|
|
191
|
+
function chartContext(item, box, bound, options, svg, { rows, columns, formats, trace, combo }) {
|
|
218
192
|
const u = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
|
|
219
193
|
const composition = bound.composition ?? bound.geometry.composition ?? {};
|
|
220
194
|
const requested = 14, fontPx = Math.max(requested, composition.minFontSize ?? 16) * u;
|
|
@@ -232,7 +206,7 @@ function chartContext(item, box, bound, options, svg, { rows, columns, formats,
|
|
|
232
206
|
return path.replace(/^(.*\.data\.(?:columns\.|rows\.\d+\.))(\d+)$/, (whole, head, column) => `${head}${trace.columnMap[Number(column)] ?? column}`);
|
|
233
207
|
};
|
|
234
208
|
const c = {
|
|
235
|
-
item, box, bound, options, svg, rows, columns, formats, children, u, fontPx, surface, labelColor,
|
|
209
|
+
item, box, bound, options, svg, rows, columns, formats, combo, children, u, fontPx, surface, labelColor,
|
|
236
210
|
path: item.path,
|
|
237
211
|
pt: u * 4 / 3,
|
|
238
212
|
pad: 10 * u,
|
|
@@ -276,21 +250,13 @@ function chartContext(item, box, bound, options, svg, { rows, columns, formats,
|
|
|
276
250
|
}
|
|
277
251
|
|
|
278
252
|
// RR-54: core's strict chart number (finite numbers and strict decimal strings; everything else is a gap), so the preview, the PPTX
|
|
279
|
-
// export and the validator agree on which cells are numbers.
|
|
280
|
-
export
|
|
281
|
-
if (typeof opfComposition.chartNumber === "function") return opfComposition.chartNumber(value);
|
|
282
|
-
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
283
|
-
if (typeof value === "string" && value.trim()) {
|
|
284
|
-
const parsed = Number(value);
|
|
285
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
286
|
-
}
|
|
287
|
-
return null;
|
|
288
|
-
}
|
|
253
|
+
// export and the validator agree on which cells are numbers.
|
|
254
|
+
export { chartNumber };
|
|
289
255
|
|
|
290
256
|
/** A number as its column's format shows it (core's formatDataNumber); without a format, the General form the preview always drew. */
|
|
291
257
|
export function formatChartValue(value, format) {
|
|
292
|
-
if (format === undefined
|
|
293
|
-
return
|
|
258
|
+
if (format === undefined) return String(clean(value));
|
|
259
|
+
return formatDataNumber(clean(value), format);
|
|
294
260
|
}
|
|
295
261
|
|
|
296
262
|
// ---------------------------------------------------------------------------
|
|
@@ -435,16 +401,17 @@ function legendEntries(c, entries) {
|
|
|
435
401
|
|
|
436
402
|
// RR-35: the legend entries of a chart whatever the series count (an explicit legend position shows a single series too).
|
|
437
403
|
function legendSource(c, spec) {
|
|
438
|
-
const fromSeries = (series, key, marker = false) => series.map((s) => ({ name: s.name, color: c
|
|
404
|
+
const fromSeries = (series, key, marker = false) => series.map((s) => ({ name: s.name, color: seriesColor(c, s.index), path: `${c.path}.data.columns.${s.column}`, key, marker }));
|
|
439
405
|
switch (spec.kind) {
|
|
440
406
|
case "pie":
|
|
441
|
-
case "doughnut": return c.rows.map((row, i) => ({ name: c.label(row[0]), color: c
|
|
407
|
+
case "doughnut": return c.rows.map((row, i) => ({ name: c.label(row[0]), color: sliceColor(c, i), path: `${c.path}.data.rows.${i}.0` }));
|
|
442
408
|
case "scatter": return fromSeries(scatterSeries(c.rows, c.columns, c.formats).series, "marker");
|
|
443
409
|
case "line": return fromSeries(c.series(), "line", spec.markers);
|
|
444
410
|
case "radar": return fromSeries(c.series(), spec.style === "filled" ? undefined : "line", spec.markers);
|
|
445
411
|
case "box": return fromSeries(c.columns.length === 1 ? [{ name: c.label(c.columns[0]), column: 0, index: 0 }] : c.series());
|
|
446
412
|
case "bar":
|
|
447
413
|
case "area": return fromSeries(c.series());
|
|
414
|
+
case "combo": return comboLegendEntries(c, c.series());
|
|
448
415
|
default: return [];
|
|
449
416
|
}
|
|
450
417
|
}
|
|
@@ -494,7 +461,7 @@ function placeLegend(c, spec, position) {
|
|
|
494
461
|
|
|
495
462
|
function seriesLegend(c, series, key, marker = false) {
|
|
496
463
|
return series.length > 1
|
|
497
|
-
? legendEntries(c, series.map((s) => ({ name: s.name, color: c
|
|
464
|
+
? legendEntries(c, series.map((s) => ({ name: s.name, color: seriesColor(c, s.index), path: `${c.path}.data.columns.${s.column}`, key, marker })))
|
|
498
465
|
: 0;
|
|
499
466
|
}
|
|
500
467
|
|
|
@@ -845,9 +812,9 @@ function drawBars(c, spec, series, stacks, { plot, band, at, horizontal, scale,
|
|
|
845
812
|
const geometry = barGeometry(band, series.length, spec.grouping);
|
|
846
813
|
const labels = [];
|
|
847
814
|
series.forEach((s, j) => {
|
|
848
|
-
const color = c.colors[j % c.colors.length];
|
|
849
815
|
stacks[j].forEach((point, i) => {
|
|
850
816
|
if (!point) return;
|
|
817
|
+
const color = markColor(c, j, i);
|
|
851
818
|
const slot = geometry.clustered ? j * geometry.width : 0;
|
|
852
819
|
const a = at(base(point.from)), b = at(point.to);
|
|
853
820
|
const path = `${c.path}.data.rows.${i}.${s.column}`;
|
|
@@ -871,8 +838,8 @@ function drawBars(c, spec, series, stacks, { plot, band, at, horizontal, scale,
|
|
|
871
838
|
|
|
872
839
|
function drawLines(c, spec, series, stacks, { plot, band, at, categorySlot = (i) => i }) {
|
|
873
840
|
const radius = 3 * c.pt;
|
|
874
|
-
series.
|
|
875
|
-
const
|
|
841
|
+
for (let j = 0; j < series.length; j++) {
|
|
842
|
+
const s = series[j], color = seriesColor(c, j);
|
|
876
843
|
let run = [];
|
|
877
844
|
const flush = () => {
|
|
878
845
|
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}`);
|
|
@@ -883,10 +850,13 @@ function drawLines(c, spec, series, stacks, { plot, band, at, categorySlot = (i)
|
|
|
883
850
|
run.push([plot.x + (categorySlot(i) + 0.5) * band, at(point.to)]);
|
|
884
851
|
});
|
|
885
852
|
flush();
|
|
886
|
-
|
|
887
|
-
|
|
853
|
+
// FA-14: with a highlight each point takes its own colour, and a line without markers marks the highlighted categories' points.
|
|
854
|
+
if (spec.markers || c.highlight?.categories.some(Boolean)) stacks[j].forEach((point, i) => {
|
|
855
|
+
if (!point || (!spec.markers && !c.highlight.categories[i])) return;
|
|
856
|
+
const fill = markColor(c, j, i);
|
|
857
|
+
c.mark("circle", { cx: plot.x + (categorySlot(i) + 0.5) * band, cy: at(point.to), r: radius, fill, stroke: fill, "stroke-width": 0.75 * c.pt }, `${c.path}.data.rows.${i}.${s.column}`);
|
|
888
858
|
});
|
|
889
|
-
}
|
|
859
|
+
}
|
|
890
860
|
// RR-35: data labels sit beside each point, over every line.
|
|
891
861
|
if (c.dataLabels) series.forEach((s, j) => stacks[j].forEach((point, i) => {
|
|
892
862
|
if (point) drawPointLabel(c, labelString(c, { category: c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]), value: s.values[i], format: s.format }), [plot.x + (i + 0.5) * band, at(point.to)], `${c.path}.data.rows.${i}.${s.column}`);
|
|
@@ -894,20 +864,133 @@ function drawLines(c, spec, series, stacks, { plot, band, at, categorySlot = (i)
|
|
|
894
864
|
}
|
|
895
865
|
|
|
896
866
|
function drawAreas(c, series, stacks, { plot, band, at, crossing, categorySlot = (i) => i }) {
|
|
897
|
-
series.
|
|
867
|
+
for (let j = 0; j < series.length; j++) {
|
|
868
|
+
const s = series[j];
|
|
898
869
|
const points = stacks[j].map((point, i) => ({ x: plot.x + (categorySlot(i) + 0.5) * band, from: point.from, to: point.to }));
|
|
899
870
|
const upper = points.map((p) => `${c.num(p.x)} ${c.num(at(p.to))}`);
|
|
900
871
|
const lower = points.slice().reverse().map((p) => `${c.num(p.x)} ${c.num(p.from === 0 ? crossing : at(p.from))}`);
|
|
901
|
-
c.mark("path", { d: `M ${upper.join(" L ")} L ${lower.join(" L ")} Z`, fill: c
|
|
902
|
-
}
|
|
872
|
+
c.mark("path", { d: `M ${upper.join(" L ")} L ${lower.join(" L ")} Z`, fill: seriesColor(c, j) }, `${c.path}.data.columns.${s.column}`);
|
|
873
|
+
}
|
|
903
874
|
// RR-35: an area's label sits in the area at each category, halfway between its lower and upper edge.
|
|
904
875
|
if (c.dataLabels) series.forEach((s, j) => stacks[j].forEach((point, i) => {
|
|
905
876
|
if (!point) return;
|
|
906
877
|
const lowerEdge = point.from === 0 ? crossing : at(point.from);
|
|
907
|
-
drawCenteredLabel(c, labelString(c, { category: c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]), value: s.values[i], format: s.format }), plot.x + (i + 0.5) * band, (lowerEdge + at(point.to)) / 2, `${c.path}.data.rows.${i}.${s.column}`, c
|
|
878
|
+
drawCenteredLabel(c, labelString(c, { category: c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]), value: s.values[i], format: s.format }), plot.x + (i + 0.5) * band, (lowerEdge + at(point.to)) / 2, `${c.path}.data.rows.${i}.${s.column}`, seriesColor(c, j));
|
|
908
879
|
}));
|
|
909
880
|
}
|
|
910
881
|
|
|
882
|
+
// ---------------------------------------------------------------------------
|
|
883
|
+
// Combo (FA-15): clustered columns and line series with markers in one plot. Core's resolveChartData orders the series
|
|
884
|
+
// (column series first, then lines) and says which lines use the secondary value axis. That axis sits at the right (the left,
|
|
885
|
+
// right to left) with its own scale and its tick labels in the first secondary series' number format; the gridlines follow
|
|
886
|
+
// the primary axis, whose ticks take the first column series' format. The PPTX export writes the same plan natively.
|
|
887
|
+
|
|
888
|
+
/** The combo plan of a chart: core's, or (a core without it) every series a column except the last, a line, all on the primary axis. */
|
|
889
|
+
function comboPlan(c, series) {
|
|
890
|
+
if (Array.isArray(c.combo) && c.combo.length === series.length) return c.combo;
|
|
891
|
+
return series.map((_, index) => ({ role: series.length > 1 && index === series.length - 1 ? "line" : "bar", axis: "primary" }));
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Legend entries in series order: a square key for a column series, a line with a marker for a line series. */
|
|
895
|
+
function comboLegendEntries(c, series) {
|
|
896
|
+
const plan = comboPlan(c, series);
|
|
897
|
+
return series.map((s, index) => ({ name: s.name, color: c.colors[s.index % c.colors.length], path: `${c.path}.data.columns.${s.column}`, ...(plan[index].role === "line" ? { key: "line", marker: true } : {}) }));
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function renderComboChart(c, spec) {
|
|
901
|
+
const { box, pad, fontPx } = c;
|
|
902
|
+
const series = c.series();
|
|
903
|
+
const plan = comboPlan(c, series);
|
|
904
|
+
const bars = series.filter((_, index) => plan[index].role === "bar");
|
|
905
|
+
const lines = series.filter((_, index) => plan[index].role === "line");
|
|
906
|
+
const onSecondary = (s) => plan[s.index].axis === "secondary";
|
|
907
|
+
const primaryLines = lines.filter((s) => !onSecondary(s)), secondaryLines = lines.filter(onSecondary);
|
|
908
|
+
const categories = c.rows.map((row) => c.label(row[0]));
|
|
909
|
+
const count = categories.length;
|
|
910
|
+
// Right to left (RR-05): the categories run from the right, the primary value axis moves to the right and the secondary one to the left.
|
|
911
|
+
const rtl = c.bound.geometry?.direction === "rtl";
|
|
912
|
+
const categorySlot = (i) => rtl ? count - 1 - i : i;
|
|
913
|
+
const extent = (list) => {
|
|
914
|
+
const values = list.flatMap((s) => s.values).filter((value) => value !== null);
|
|
915
|
+
return values.length ? [Math.min(...values), Math.max(...values)] : [0, 1];
|
|
916
|
+
};
|
|
917
|
+
const [primaryMin, primaryMax] = extent([...bars, ...primaryLines]);
|
|
918
|
+
const [secondaryMin, secondaryMax] = extent(secondaryLines);
|
|
919
|
+
const primaryFormat = (bars[0] ?? primaryLines[0])?.format, secondaryFormat = secondaryLines[0]?.format;
|
|
920
|
+
// The default legend sits at the far right, outside a right axis title (the secondary axis title, or right to left the primary
|
|
921
|
+
// one), as PowerPoint places it: the legend is laid out against the box before the title band was carved, and the title moves in.
|
|
922
|
+
const rightTitle = c.axisTitleBands?.some((band) => band.side === "right") && !c.legendManaged;
|
|
923
|
+
let legendWidth = 0;
|
|
924
|
+
if (series.length > 1) {
|
|
925
|
+
const inner = c.box;
|
|
926
|
+
if (rightTitle) c.box = { ...inner, width: inner.width + c.lineHeight };
|
|
927
|
+
legendWidth = legendEntries(c, comboLegendEntries(c, series));
|
|
928
|
+
c.box = inner;
|
|
929
|
+
if (rightTitle) c.axisTitleRightInset = legendWidth + c.pad / 2;
|
|
930
|
+
}
|
|
931
|
+
const right = box.x + box.width - pad - legendWidth;
|
|
932
|
+
const top = box.y + pad + c.lineHeight / 2;
|
|
933
|
+
const entries = categories.map((name, i) => ({ name, path: `${c.path}.data.rows.${i}.0` }));
|
|
934
|
+
if (rtl) entries.reverse();
|
|
935
|
+
const tickWidthOf = (scale, format) => Math.max(...scale.ticks.map((tick) => c.width(formatTick(tick, false, format)))) + fontPx * 0.5;
|
|
936
|
+
const axis = planCategoryAxis(c, entries, (reserve) => {
|
|
937
|
+
const height = Math.max(1, box.y + box.height - pad - reserve - top);
|
|
938
|
+
const intervals = maxIntervalsFor(height, c.lineHeight * 1.2);
|
|
939
|
+
const scale = niceScale(primaryMin, primaryMax, intervals);
|
|
940
|
+
const secondary = secondaryLines.length ? niceScale(secondaryMin, secondaryMax, intervals) : null;
|
|
941
|
+
const primaryWidth = tickWidthOf(scale, primaryFormat), secondaryWidth = secondary ? tickWidthOf(secondary, secondaryFormat) : 0;
|
|
942
|
+
const leftWidth = rtl ? secondaryWidth : primaryWidth, rightWidth = rtl ? primaryWidth : secondaryWidth;
|
|
943
|
+
const x = box.x + pad + (leftWidth || fontPx / 2);
|
|
944
|
+
const plotRight = right - (rightWidth || fontPx / 2);
|
|
945
|
+
return { scale, secondary, primaryWidth, secondaryWidth, plot: { x, y: top, width: Math.max(1, plotRight - x), height } };
|
|
946
|
+
});
|
|
947
|
+
const { plot, scale, secondary } = axis;
|
|
948
|
+
drawBottomLabels(c, axis.plan, plot, entries);
|
|
949
|
+
c.plotArea = plot;
|
|
950
|
+
const valueAt = (axisScale) => (value) => plot.y + axisFraction(value, axisScale, true) * plot.height;
|
|
951
|
+
const at = valueAt(scale), atSecondary = secondary ? valueAt(secondary) : at;
|
|
952
|
+
const crossing = at(Math.min(scale.max, Math.max(scale.min, 0)));
|
|
953
|
+
const tickLabel = (label, position, side, width) => side === "left"
|
|
954
|
+
? c.text(label, { x: box.x + pad, y: position - c.lineHeight / 2, width: Math.max(1, plot.x - box.x - pad - fontPx * 0.35), height: c.lineHeight }, c.path, "right")
|
|
955
|
+
: c.text(label, { x: plot.x + plot.width + fontPx * 0.35, y: position - c.lineHeight / 2, width: Math.max(1, width - fontPx * 0.35), height: c.lineHeight }, c.path, "left");
|
|
956
|
+
for (const tick of scale.ticks) {
|
|
957
|
+
const position = at(tick);
|
|
958
|
+
gridline(c, plot.x, position, plot.x + plot.width, position);
|
|
959
|
+
tickLabel(formatTick(tick, false, primaryFormat), position, rtl ? "right" : "left", axis.primaryWidth);
|
|
960
|
+
}
|
|
961
|
+
if (secondary) for (const tick of secondary.ticks) tickLabel(formatTick(tick, false, secondaryFormat), atSecondary(tick), rtl ? "left" : "right", axis.secondaryWidth);
|
|
962
|
+
const band = plot.width / count;
|
|
963
|
+
// Column series come first in core's order, so a column's palette index is its series index.
|
|
964
|
+
if (bars.length) drawBars(c, spec, bars, stackCategoryValues(bars, count, "bar", "clustered", c.path), { plot, band, at, horizontal: false, scale, categorySlot });
|
|
965
|
+
const point = (s, i, value) => [plot.x + (categorySlot(i) + 0.5) * band, (onSecondary(s) ? atSecondary : at)(value)];
|
|
966
|
+
const radius = 3 * c.pt;
|
|
967
|
+
for (const s of lines) {
|
|
968
|
+
const color = c.colors[s.index % c.colors.length];
|
|
969
|
+
let run = [];
|
|
970
|
+
const flush = () => {
|
|
971
|
+
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}`);
|
|
972
|
+
run = [];
|
|
973
|
+
};
|
|
974
|
+
s.values.forEach((value, i) => { if (value === null) flush(); else run.push(point(s, i, value)); });
|
|
975
|
+
flush();
|
|
976
|
+
s.values.forEach((value, i) => {
|
|
977
|
+
if (value === null) return;
|
|
978
|
+
const [cx, cy] = point(s, i, value);
|
|
979
|
+
c.mark("circle", { cx, cy, r: radius, fill: color, stroke: color, "stroke-width": 0.75 * c.pt }, `${c.path}.data.rows.${i}.${s.column}`);
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
// Line labels sit beside each point at the combo's line position (core: above unless a point position was asked for).
|
|
983
|
+
if (c.dataLabels) for (const s of lines) s.values.forEach((value, i) => {
|
|
984
|
+
if (value === null) return;
|
|
985
|
+
const category = c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]);
|
|
986
|
+
drawPointLabel(c, labelString(c, { category, value, format: s.format }), point(s, i, value), `${c.path}.data.rows.${i}.${s.column}`, c.dataLabels.linePosition ?? "above");
|
|
987
|
+
});
|
|
988
|
+
const primaryX = rtl ? plot.x + plot.width : plot.x, secondaryX = rtl ? plot.x : plot.x + plot.width;
|
|
989
|
+
axisLine(c, primaryX, plot.y, primaryX, plot.y + plot.height);
|
|
990
|
+
if (secondary) axisLine(c, secondaryX, plot.y, secondaryX, plot.y + plot.height);
|
|
991
|
+
axisLine(c, plot.x, crossing, plot.x + plot.width, crossing);
|
|
992
|
+
}
|
|
993
|
+
|
|
911
994
|
// ---------------------------------------------------------------------------
|
|
912
995
|
// Pie and doughnut: first series only, clockwise from 12 o'clock, one colour per category.
|
|
913
996
|
|
|
@@ -916,7 +999,7 @@ function renderCircularChart(c, spec) {
|
|
|
916
999
|
const categories = c.rows.map((row) => c.label(row[0]));
|
|
917
1000
|
const values = c.rows.map((row) => Math.abs(chartNumber(row[1]) ?? 0));
|
|
918
1001
|
const legendWidth = legendEntries(c, categories.map((name, i) => ({
|
|
919
|
-
name, color: c
|
|
1002
|
+
name, color: sliceColor(c, i), path: `${c.path}.data.rows.${i}.0`
|
|
920
1003
|
})));
|
|
921
1004
|
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) };
|
|
922
1005
|
const cx = area.x + area.width / 2, cy = area.y + area.height / 2;
|
|
@@ -936,7 +1019,7 @@ function renderCircularChart(c, spec) {
|
|
|
936
1019
|
let angle = -Math.PI / 2;
|
|
937
1020
|
values.forEach((value, i) => {
|
|
938
1021
|
const delta = value / total * Math.PI * 2, end = angle + delta;
|
|
939
|
-
const fill = c
|
|
1022
|
+
const fill = sliceColor(c, i), path = `${c.path}.data.rows.${i}.1`;
|
|
940
1023
|
if (delta >= Math.PI * 2 - 1e-9) {
|
|
941
1024
|
if (inner) c.mark("path", { d: `${circlePath(c, cx, cy, r)} ${circlePath(c, cx, cy, inner)}`, fill, "fill-rule": "evenodd", ...border }, path);
|
|
942
1025
|
else c.mark("circle", { cx, cy, r, fill, ...border }, path);
|
|
@@ -949,7 +1032,7 @@ function renderCircularChart(c, spec) {
|
|
|
949
1032
|
let start = -Math.PI / 2;
|
|
950
1033
|
values.forEach((value, i) => {
|
|
951
1034
|
const delta = value / total * Math.PI * 2;
|
|
952
|
-
if (delta > 0) drawSliceLabel(c, sliceTexts[i], { cx, cy, r, inner, mid: start + delta / 2 }, `${c.path}.data.rows.${i}.1`, c
|
|
1035
|
+
if (delta > 0) drawSliceLabel(c, sliceTexts[i], { cx, cy, r, inner, mid: start + delta / 2 }, `${c.path}.data.rows.${i}.1`, sliceColor(c, i));
|
|
953
1036
|
start += delta;
|
|
954
1037
|
});
|
|
955
1038
|
}
|
|
@@ -1020,10 +1103,10 @@ function renderScatterChart(c) {
|
|
|
1020
1103
|
const yCross = yAt(Math.min(yScale.max, Math.max(yScale.min, 0))), xCross = xAt(Math.min(xScale.max, Math.max(xScale.min, 0)));
|
|
1021
1104
|
axisLine(c, xCross, plot.y, xCross, plot.y + plot.height);
|
|
1022
1105
|
axisLine(c, plot.x, yCross, plot.x + plot.width, yCross);
|
|
1023
|
-
series.
|
|
1024
|
-
const
|
|
1106
|
+
for (let j = 0; j < series.length; j++) {
|
|
1107
|
+
const s = series[j], color = seriesColor(c, j);
|
|
1025
1108
|
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}`);
|
|
1026
|
-
}
|
|
1109
|
+
}
|
|
1027
1110
|
// RR-35: a scatter label shows the Y value, and the X value as its category.
|
|
1028
1111
|
if (c.dataLabels) series.forEach((s) => {
|
|
1029
1112
|
for (const point of s.points) drawPointLabel(c, labelString(c, { category: formatChartValue(point.x, xFormat), value: point.y, format: s.format }), [xAt(point.x), yAt(point.y)], `${c.path}.data.rows.${point.row}.${s.column}`);
|
|
@@ -1092,12 +1175,12 @@ function renderRadarChart(c, spec) {
|
|
|
1092
1175
|
c.text(text, rect, label.path, align);
|
|
1093
1176
|
});
|
|
1094
1177
|
reportTruncatedLabels(c, truncated);
|
|
1095
|
-
series.
|
|
1096
|
-
const
|
|
1178
|
+
for (let j = 0; j < series.length; j++) {
|
|
1179
|
+
const s = series[j], color = seriesColor(c, j), path = `${c.path}.data.columns.${s.column}`;
|
|
1097
1180
|
if (spec.style === "filled") {
|
|
1098
1181
|
const outline = s.values.map((v, i) => point(i, v ?? scale.min).map(n).join(" "));
|
|
1099
1182
|
c.mark("path", { d: `M ${outline.join(" L ")} Z`, fill: color }, path);
|
|
1100
|
-
|
|
1183
|
+
continue;
|
|
1101
1184
|
}
|
|
1102
1185
|
const at = (i) => point(i, s.values[i]).map(n).join(" ");
|
|
1103
1186
|
let d = "";
|
|
@@ -1112,7 +1195,7 @@ function renderRadarChart(c, spec) {
|
|
|
1112
1195
|
const [x, y] = point(i, v);
|
|
1113
1196
|
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}`);
|
|
1114
1197
|
});
|
|
1115
|
-
}
|
|
1198
|
+
}
|
|
1116
1199
|
// Value axis labels sit on top of filled series, as in PowerPoint.
|
|
1117
1200
|
for (const tick of scale.ticks) {
|
|
1118
1201
|
const label = formatTick(tick, false, valueFormat), width = c.width(label) + fontPx * 0.5;
|
package/dist/deck-runtime.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// RR-28: the part of the slideshow player and the <opf-deck> element that has no DOM of its own. It reads an OPF document,
|
|
2
|
-
// decides which slides play (hidden ones do not), renders each slide once with the renderer's own SVG output (
|
|
2
|
+
// decides which slides play (hidden ones do not), renders each slide once with the renderer's own SVG output (renderSlideSvg:
|
|
3
3
|
// there is no second layout engine) behind a face-level font gate, and synchronizes windows over a BroadcastChannel.
|
|
4
4
|
// Nothing here fetches anything but the font files the host's font root serves.
|
|
5
|
-
import {
|
|
5
|
+
import { renderSlideSvg } from "./svg.js";
|
|
6
6
|
|
|
7
7
|
/** Error raised for a deck that cannot be read, rendered or loaded. */
|
|
8
8
|
export class DeckError extends Error {
|
|
@@ -173,38 +173,24 @@ export function localIsoDate(now = new Date()) {
|
|
|
173
173
|
|
|
174
174
|
// --- font gate -----------------------------------------------------------------------------------------------------
|
|
175
175
|
|
|
176
|
-
const MAX_FONT_ROUNDS = 4;
|
|
177
|
-
|
|
178
176
|
/**
|
|
179
|
-
* The gate every render goes through: nothing draws a document before the faces it needs are loaded. `
|
|
180
|
-
*
|
|
181
|
-
* script faces the document draws are fetched, each hash-verified by the registry.
|
|
177
|
+
* The gate every render goes through: nothing draws a document before the faces it needs are loaded. `fonts` is a browser
|
|
178
|
+
* fonts handle (`loadFonts` from `/fonts-browser`); one without `pending` and `ensure` gates nothing. Face level: only the
|
|
179
|
+
* vendored and script faces the document draws are fetched, each hash-verified by the registry behind the handle.
|
|
182
180
|
*/
|
|
183
|
-
export function createFontGate(
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
try { scripts = registry?.pendingScripts?.(document, options) ?? []; } catch { /* the renderer reports the document itself */ }
|
|
187
|
-
try { lazy = (registry?.pendingLazyFonts?.(document, options) ?? []).map((face) => face?.file ?? String(face)); } catch { /* likewise */ }
|
|
188
|
-
return { lazy, scripts };
|
|
189
|
-
};
|
|
181
|
+
export function createFontGate(fonts) {
|
|
182
|
+
// A document that does not resolve has nothing to load here: the renderer reports it when it draws.
|
|
183
|
+
const pending = (document, options) => { try { return fonts?.pending?.(document, options) ?? []; } catch { return []; } };
|
|
190
184
|
return {
|
|
191
|
-
pending
|
|
185
|
+
pending,
|
|
192
186
|
async ensure(document, { signal, renderOptions } = {}) {
|
|
193
|
-
|
|
194
|
-
try {
|
|
195
|
-
for (let round = 0; round < MAX_FONT_ROUNDS; round++) {
|
|
196
|
-
const { lazy, scripts } = sources(document, renderOptions);
|
|
197
|
-
if (!lazy.length && !scripts.length) return;
|
|
198
|
-
signal?.throwIfAborted?.();
|
|
199
|
-
if (lazy.length) await registry.ensureLazyFonts?.(document, call);
|
|
200
|
-
if (scripts.length) await registry.ensureScripts?.(document, call);
|
|
201
|
-
}
|
|
202
|
-
} catch (cause) {
|
|
187
|
+
if (!pending(document, renderOptions).length) return;
|
|
188
|
+
try { await fonts.ensure(document, { ...renderOptions, signal }); } catch (cause) {
|
|
203
189
|
if (signal?.aborted) throw cause;
|
|
204
190
|
throw new DeckError("fonts-unavailable", `Fonts for this deck could not be loaded: ${cause?.message ?? cause}`, { cause });
|
|
205
191
|
}
|
|
206
|
-
const left =
|
|
207
|
-
if (left.
|
|
192
|
+
const left = pending(document, renderOptions);
|
|
193
|
+
if (left.length) throw new DeckError("fonts-unavailable", `Fonts for this deck did not finish loading: ${left.join(", ")}.`);
|
|
208
194
|
},
|
|
209
195
|
};
|
|
210
196
|
}
|
|
@@ -213,9 +199,9 @@ export function createFontGate(registry) {
|
|
|
213
199
|
|
|
214
200
|
/**
|
|
215
201
|
* One deck's rendering, shared by the element, the full-screen player and the speaker view: font loading once, each slide
|
|
216
|
-
* rendered once by `
|
|
202
|
+
* rendered once by `renderSlideSvg` and kept, so moving between slides never draws twice and every surface shows the same markup.
|
|
217
203
|
*
|
|
218
|
-
* `fonts` is a browser
|
|
204
|
+
* `fonts` is a browser fonts handle (or nothing: layout then uses estimated widths and the visitor's system sans-serif).
|
|
219
205
|
*/
|
|
220
206
|
export function createDeckStore({ document, fonts, renderOptions = {}, date } = {}) {
|
|
221
207
|
const deck = parseDeckDocument(document);
|
|
@@ -224,7 +210,8 @@ export function createDeckStore({ document, fonts, renderOptions = {}, date } =
|
|
|
224
210
|
const diagnostics = [];
|
|
225
211
|
const options = () => ({
|
|
226
212
|
...renderOptions,
|
|
227
|
-
|
|
213
|
+
// Inline slides use the page's own faces (the registry added them to the document), so only the measurement is passed: nothing is embedded.
|
|
214
|
+
...(fonts?.textMeasurement ? { fonts: { textMeasurement: fonts.textMeasurement } } : {}),
|
|
228
215
|
...(renderOptions.date === undefined && date !== undefined ? { date } : {}),
|
|
229
216
|
onDiagnostic(diagnostic) { diagnostics.push(diagnostic); renderOptions.onDiagnostic?.(diagnostic); },
|
|
230
217
|
});
|
|
@@ -243,12 +230,12 @@ export function createDeckStore({ document, fonts, renderOptions = {}, date } =
|
|
|
243
230
|
let svg = cache.get(index);
|
|
244
231
|
if (svg === undefined) {
|
|
245
232
|
try {
|
|
246
|
-
svg =
|
|
233
|
+
svg = renderSlideSvg(deck, index, { ...options(), validate: !validated });
|
|
247
234
|
} catch (cause) {
|
|
248
235
|
if (cause instanceof DeckError) throw cause;
|
|
249
|
-
// A document the schema rejects says where: the first
|
|
250
|
-
const
|
|
251
|
-
const where =
|
|
236
|
+
// A document the schema rejects says where: the first finding's path and message.
|
|
237
|
+
const finding = cause?.code === "invalid-opf" ? cause.findings?.[0] : undefined;
|
|
238
|
+
const where = finding ? ` (${finding.path || "/"}: ${finding.message})` : "";
|
|
252
239
|
throw new DeckError(cause?.code === "invalid-opf" ? "invalid-document" : "render-failed", `${cause?.message ?? "The slide could not be drawn."}${where}`, { cause });
|
|
253
240
|
}
|
|
254
241
|
validated = true;
|