@openpresentation/opf-render 0.11.1 → 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.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
 
@@ -176,7 +176,7 @@ const png = await svgToPng(slides[0], options);
176
176
 
177
177
  ### Load only the script faces a document uses (`scripts: 'auto'`)
178
178
 
179
- Hosts do not need to detect scripts themselves. `scripts: 'auto'` reads the presentation's drawn text and loads only the faces for the scripts it uses: a Latin, Greek or Cyrillic deck loads none, a Japanese deck loads Noto Sans JP, an Arabic deck the three Arabic packages. Text decides, so a deck whose `language` is `ja` but whose text is Latin still loads nothing. The document language only tells Han text apart (through core `resolveScriptFonts`, the same profile the renderer plans with; a core without it treats Han as Simplified Chinese, as the renderer does). Only drawn text counts (titles, text, list items, runs, table cells, chart labels, code, quotes, metrics, furniture); ids, image alt text and sources, URLs, assets, catalogs, speaker notes, `extensions`, a slide's `beat` and the deck's own name, description, filename, author, speaker, audience, purpose, tone, takeaway, duration, tags and narrative are ignored. The organization and a slide's `section` count only when a design turns on that generated header or footer field. Detection itemizes text exactly as drawing does, so with an East Asian language the curly quotes, dashes and ellipsis count as East Asian. `detectPresentationScripts(presentation)` and `autoScriptSelection(presentation)` (from `/fonts-node` and `/fonts-browser`) return the decision.
179
+ Hosts do not need to detect scripts themselves. `scripts: 'auto'` reads the presentation's drawn text and loads only the faces for the scripts it uses: a Latin, Greek or Cyrillic deck loads none, a Japanese deck loads Noto Sans JP, an Arabic deck the three Arabic packages. Text decides, so a deck whose `language` is `ja` but whose text is Latin still loads nothing. The one exception is a font scheme that names a script font (Yu Gothic, Meiryo, Malgun Gothic, Microsoft YaHei, Arabic Typesetting, Mangal or a pinned Noto family, on the deck or on a slide's `design`): its Latin text previews with that font's open replacement, so its face loads whatever the text, and Han-only text in such a deck draws with the scheme's CJK face. `detected` still lists only what the text draws; `scripts` is what to load. The document language only tells Han text apart (through core `resolveScriptFonts`, the same profile the renderer plans with; a core without it treats Han as Simplified Chinese, as the renderer does). Only drawn text counts (titles, text, list items, runs, table cells, chart labels, code, quotes, metrics, furniture); ids, image alt text and sources, URLs, assets, catalogs, speaker notes, `extensions`, a slide's `beat` and the deck's own name, description, filename, author, speaker, audience, purpose, tone, takeaway, duration, tags and narrative are ignored. The organization and a slide's `section` count only when a design turns on that generated header or footer field. Detection itemizes text exactly as drawing does, so with an East Asian language the curly quotes, dashes and ellipsis count as East Asian. `detectPresentationScripts(presentation)` and `autoScriptSelection(presentation)` (from `/fonts-node` and `/fonts-browser`) return the decision.
180
180
 
181
181
  ```js
182
182
  // Node: pass the presentation with scripts: 'auto'. Auto never fails on a package that is not installed
@@ -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/fonts.js CHANGED
@@ -187,7 +187,12 @@ export function createFontRegistry(entries, options = {}) {
187
187
  if (value===undefined) {
188
188
  if (options.strictGlyphs!==false) for(const character of text) {
189
189
  if (/\p{Default_Ignorable_Code_Point}/u.test(character)) continue;
190
- if (!face.font.hasGlyphForCodePoint(character.codePointAt(0))) throw new OPFFontError("missing-glyph", `Font '${face.family}' cannot display U+${character.codePointAt(0).toString(16).toUpperCase()}.`, {path:style.path,fontFamily:face.family,character});
190
+ const code=character.codePointAt(0);
191
+ if (!face.font.hasGlyphForCodePoint(code)) {
192
+ // No loaded face has it: the script faces for this text are not loaded (glyph fallback needs a face to fall back to).
193
+ const loadedElsewhere=faces.some(other=>other!==face&&other.font.hasGlyphForCodePoint(code));
194
+ throw new OPFFontError("missing-glyph", `Font '${face.family}' cannot display U+${code.toString(16).toUpperCase()}.${loadedElsewhere?"":" No loaded font has it, so glyph fallback has no face to fall back to; if it is a script character, load that script's font (prepareNodeFonts scripts: 'auto' with the presentation, or registry.ensureScripts(presentation) in a browser)."}`, {path:style.path,fontFamily:face.family,character,...(loadedElsewhere?{}:{loadedFaceHasGlyph:false})});
195
+ }
191
196
  }
192
197
  }
193
198
  if(value===undefined||includeOutline&&!Object.hasOwn(value,'outline')) {
@@ -106,18 +106,58 @@ const cjkCharacters = new Set(["Hani", "Hira", "Kana", "Hang", "Bopo"]);
106
106
  /** True when the renderer has per-character glyph fallback (FF-19 glyphFallbackFamilies). */
107
107
  const hasGlyphFallback = () => typeof scriptFontModule.glyphFallbackFamilies === "function";
108
108
 
109
+ /** ISO 15924 script whose pinned face a family resolves to: a proprietary script font (Yu Gothic: Jpan) or a pinned face itself (Noto Sans JP: Jpan). */
110
+ function scriptOfFamily(family) {
111
+ const name = String(family ?? "").toLowerCase();
112
+ if (!name) return undefined;
113
+ const rule = scriptFontModule.SCRIPT_FONT_REPLACEMENTS.find(item => item.requestedFamily.toLowerCase() === name);
114
+ if (rule) return rule.script;
115
+ return BUNDLED_FONT_MANIFEST.packages.find(item => item.pack === "scripts" && item.faces.some(face => face.family.toLowerCase() === name))?.scripts[0];
116
+ }
117
+
118
+ /**
119
+ * The scripts whose faces the deck's font schemes need in order to resolve at all. A scheme that names a script font
120
+ * (Yu Gothic, Malgun Gothic, Arabic Typesetting or a pinned Noto family) previews its Latin text with that font's
121
+ * open replacement, so the face is needed even when no drawn character has the script. A slide may override the
122
+ * scheme (`slide.design`), so each such slide is resolved too. `han` is the CJK script the scheme names, which tells
123
+ * Han-only text apart when the language does not (a Yu Gothic deck whose language is English).
124
+ */
125
+ function designScripts(presentation, profile) {
126
+ const scripts = new Set();
127
+ let han;
128
+ const note = resolved => {
129
+ for (const family of [resolved?.heading?.latin, resolved?.body?.latin]) {
130
+ const script = scriptOfFamily(family);
131
+ if (!script) continue;
132
+ scripts.add(script);
133
+ if (!han && cjkKeys.includes(script)) han = script;
134
+ }
135
+ };
136
+ note(profile);
137
+ if (typeof opfCore.resolveScriptFonts === "function" && Array.isArray(presentation?.slides)) {
138
+ presentation.slides.forEach((slide, slideIndex) => {
139
+ if (!slide || typeof slide !== "object" || !slide.design || typeof slide.design !== "object") return;
140
+ try { note(opfCore.resolveScriptFonts(presentation, { slideIndex })); } catch { /* the renderer reports it when it draws the slide */ }
141
+ });
142
+ }
143
+ return { scripts: [...scripts].sort(), han };
144
+ }
145
+
109
146
  /**
110
147
  * What a preview of the presentation draws: its script profile, the scripts of its text (the renderer's own
111
- * itemization, so language-dependent punctuation counts), and the CJK characters that a glyph fallback might
112
- * have to draw with another CJK face.
148
+ * itemization, so language-dependent punctuation counts), the scripts its font schemes need (`design`, for example
149
+ * Jpan for a Yu Gothic scheme) and the CJK characters that a glyph fallback might have to draw with another CJK face.
113
150
  */
114
151
  export function analyzePresentationScripts(presentation, profile = presentationScriptProfile(presentation)) {
115
152
  const scripts = new Set(), cjk = new Set();
153
+ const design = designScripts(presentation, profile);
154
+ // Han-only text in a deck whose language is not CJK draws with the scheme's own CJK face when the scheme names one.
155
+ const itemizing = design.han && !cjkKeys.includes(profile?.script) ? { ...profile, script: design.han } : profile;
116
156
  for (const text of drawnStrings(presentation)) {
117
- for (const script of scriptsOfText(text, profile)) scripts.add(script);
157
+ for (const script of scriptsOfText(text, itemizing)) scripts.add(script);
118
158
  for (const character of text) if (character.codePointAt(0) > 0x2E7F && cjkCharacters.has(scriptOfCharacter(character))) cjk.add(character);
119
159
  }
120
- return { profile, detected: [...scripts].sort(), cjk };
160
+ return { profile, detected: [...scripts].sort(), design: design.scripts, cjk };
121
161
  }
122
162
 
123
163
  /**
@@ -135,7 +175,9 @@ export function detectPresentationScripts(presentation, { profile } = {}) {
135
175
  /** The scripts of an analysis that a pinned pack serves (`scripts`, the input for `scriptFontPackages`) and those it does not. */
136
176
  export function scriptSelectionOf(analysis) {
137
177
  const served = servedScripts();
138
- return { detected: analysis.detected, scripts: analysis.detected.filter(script => served.has(script)), unavailable: analysis.detected.filter(script => !served.has(script)) };
178
+ // `scripts` are the faces to load: the text's scripts and the ones its font schemes need; `detected` stays what the text draws.
179
+ const needed = [...new Set([...analysis.detected, ...(analysis.design ?? [])])].sort();
180
+ return { detected: analysis.detected, scripts: needed.filter(script => served.has(script)), unavailable: analysis.detected.filter(script => !served.has(script)) };
139
181
  }
140
182
 
141
183
  /**
@@ -160,7 +202,7 @@ export function autoScriptSelection(presentation, options) {
160
202
  export function nextFallbackPackage(analysis, { covers, loaded }) {
161
203
  if (!hasGlyphFallback()) return undefined;
162
204
  const packages = BUNDLED_FONT_MANIFEST.packages.filter(item => item.pack === "scripts" && item.scripts.some(script => cjkKeys.includes(script)));
163
- const primary = analysis.detected.filter(script => cjkKeys.includes(script)).length;
205
+ const primary = new Set([...analysis.detected, ...(analysis.design ?? [])].filter(script => cjkKeys.includes(script))).size;
164
206
  if (packages.filter(item => loaded.has(item.name)).length >= primary + 1) return undefined;
165
207
  for (const character of analysis.cjk) {
166
208
  if (covers(character)) continue;
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.1",
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 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/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",
@@ -77,6 +77,7 @@
77
77
  "test:font-variants": "node test/font-variants.mjs",
78
78
  "test:font-variants-browser": "node test/font-variants-browser.mjs",
79
79
  "test:aptos-preview-browser": "node test/aptos-preview-browser.mjs artifacts/aptos-preview-browser.json",
80
+ "test:lazy-face-fallback-browser": "node test/lazy-face-fallback-browser.mjs artifacts/lazy-face-fallback",
80
81
  "test:lazy-fonts-browser": "node test/lazy-fonts-browser.mjs artifacts/lazy-fonts",
81
82
  "test:script-fonts-browser": "node test/script-fonts-browser.mjs artifacts/script-fonts-browser.json",
82
83
  "test:script-fonts-auto-browser": "node test/script-fonts-auto-browser.mjs artifacts/script-fonts-auto",