@openpresentation/opf-render 0.11.5 → 0.11.7

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
@@ -1,8 +1,8 @@
1
1
  # OPF Render
2
2
 
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 faces the document draws, by family, weight and style (a plain Aptos deck: Intos Display Bold and Intos Regular, 2 files, 1.5 MB; an italic run adds Intos Italic, a bold run Intos Bold; the code face only when a slide has code), then adds them to the document and the registry together so measurement and painting never disagree. `registry.pendingLazyFonts(presentation)` is the synchronous check hosts gate rendering on, and after an edit it reports just the newly needed faces. The faces are those the renderer itself measures and draws (`presentationFaces(presentation, options, registry)`, one layout-and-paint run with a recording measurement), resolved by the registry's own face selection over the faces it holds plus the vendored ones, so the browser loads exactly what Node draws with every vendored face loaded. A document resolves with the `renderSvg` options the host renders with: pass them as `loadBrowserFontRegistry(entries, { renderOptions: { catalogs } })`, or per call (`pendingLazyFonts(presentation, { catalogs })`, `ensureLazyFonts(presentation, { signal, catalogs })`), so a layout or font scheme id that only the host's catalogs know resolves here too; a document that does not resolve throws (rejects) what `renderSvg` throws for it instead of reporting nothing. Node's `prepareNodeFonts` and `loadOfficeFontRegistry` keep loading the whole open pack and Intos eagerly, so nothing there is face level; with `scripts: 'auto'` they accept `renderOptions: { catalogs }` too. Hosts copy the vendored directories next to the page.
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 faces the document draws, by family, weight and style (a plain Aptos deck: Intos Display Bold and Intos Regular, 2 files, 1.5 MB; an italic run adds Intos Italic, a bold run Intos Bold; the code face only when a slide has code), then adds them to the document and the registry together so measurement and painting never disagree. Faces a host serves itself load the same way: `loadBrowserFontRegistry(startup, { extraLazyFonts: [{ family, weight, italic, url, sha256 }] })` adds them to the lazy list (`registry.lazyFonts`, `package: "host"`), so the registry can start with Roboto Regular only (`splitStartupFaces(faces)` splits a manifest into the startup face and the rest) and a document draws, fetches and verifies just the faces it needs, vendored and host faces in one `ensureLazyFonts` call. `sha256` is mandatory, loads are all or nothing, `dispose()` removes the faces, and, like the vendored ones, host faces are `embed: "used"`: they are not in `registry.embeddedFonts` (only the startup faces are), and a standalone SVG embeds one only when passed explicitly through `registry.selectEmbeddedFonts` and the slide's text uses its family. `registry.pendingLazyFonts(presentation)` is the synchronous check hosts gate rendering on, and after an edit it reports just the newly needed faces. The faces are those the renderer itself measures and draws (`presentationFaces(presentation, options, registry)`, one layout-and-paint run with a recording measurement), resolved by the registry's own face selection over the faces it holds plus the vendored ones, so the browser loads exactly what Node draws with every vendored face loaded. A document resolves with the `renderSvg` options the host renders with: pass them as `loadBrowserFontRegistry(entries, { renderOptions: { catalogs } })`, or per call (`pendingLazyFonts(presentation, { catalogs })`, `ensureLazyFonts(presentation, { signal, catalogs })`), so a layout or font scheme id that only the host's catalogs know resolves here too; a document that does not resolve throws (rejects) what `renderSvg` throws for it instead of reporting nothing. Node's `prepareNodeFonts` and `loadOfficeFontRegistry` keep loading the whole open pack and Intos eagerly, so nothing there is face level; with `scripts: 'auto'` they accept `renderOptions: { catalogs }` too. Hosts copy the vendored directories next to the page.
4
4
 
5
- Version 0.11.5 loads vendored preview fonts by face, not by family, and lets the font loaders resolve documents with the host's render options (`presentationFaces`, `lazyFacesNeeded`; `pendingLazyFonts`, `ensureLazyFonts`, `pendingScripts` and `ensureScripts` take `catalogs` and throw or reject with the `renderSvg` error for a document that does not resolve; `registry.lazyFonts` still lists 94 faces). Version 0.11.4 previews the seven chartex chart types natively (the world map as a non-geographic tile grid), keeps the Latin Noto Sans replacement for script schemes under `scripts: 'auto'` in Node, shapes Noto Sans Mongolian again and bundles Raleway and Playfair Display (`registry.lazyFonts` lists 94 faces; no API change). 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.
5
+ Version 0.11.7 lets a browser registry load the host's own faces on demand (`extraLazyFonts`, `splitStartupFaces`: a registry can start with Roboto Regular only; additive, `registry.lazyFonts` still lists the 94 vendored faces plus the host's). Version 0.11.6 makes the category-axis labels of chart previews rotate and skip instead of wrapping mid-word (no API change; the preview of dense-axis chart slides changes) and requires `@openpresentation/opf` ^0.11.3 (the 70 legacy gallery layout ids and their geometry); use it with PPTX 0.11.4 and editor 0.10.4 so preview and export resolve the same core. Version 0.11.5 loads vendored preview fonts by face, not by family, and lets the font loaders resolve documents with the host's render options (`presentationFaces`, `lazyFacesNeeded`; `pendingLazyFonts`, `ensureLazyFonts`, `pendingScripts` and `ensureScripts` take `catalogs` and throw or reject with the `renderSvg` error for a document that does not resolve; `registry.lazyFonts` still lists 94 faces). Version 0.11.4 previews the seven chartex chart types natively (the world map as a non-geographic tile grid), keeps the Latin Noto Sans replacement for script schemes under `scripts: 'auto'` in Node, shapes Noto Sans Mongolian again and bundles Raleway and Playfair Display (`registry.lazyFonts` lists 94 faces; no API change). 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
 
@@ -250,6 +250,8 @@ Chartex chart previews (FF-22b) draw the constructs opf-pptx exports as Office 2
250
250
 
251
251
  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.
252
252
 
253
+ Category-axis labels follow PowerPoint's automatic labelling instead of wrapping inside a band (column, bar, line and area charts, including stacked and 100%, and the chartex histogram, pareto, waterfall, funnel and box-and-whisker previews). A label is never broken inside a word: it stays on one line, or splits at its spaces onto two lines when a band is too narrow, so `Category 1` cannot become `Ca` over `t 1`. When the labels do not fit horizontally they turn to -45 degrees, then -90 degrees (the plot area shrinks to make room, by at most 40% of the chart height, and a slanted first label must stay inside the chart), and when even that collides the axis draws every n-th label (Office's automatic `tickLblSkip`): n is the smallest interval that leaves no two drawn labels overlapping, the first label is always drawn, and ties go to the less rotated arrangement. In horizontal bar charts and funnels, where the category axis is vertical, rows shorter than a line of text are skipped the same way, and the label gutter grows with the longest label up to 30% of the chart width. Only a label wider than that gutter (or wider than the whole chart on a horizontal axis) is ellipsized, and then the renderer reports one `chart-label-truncated` diagnostic per chart with the affected label paths in `labels`; nothing is shortened silently, and no arrangement raises `text-overflow` for an axis label. Radar spoke labels use the same one-line, two-line or ellipsis rule and leave out a spoke label that would collide with one already drawn. Rotated labels are wrapped in a `<g transform="rotate(...)">` around their usual text group, so the traced `data-opf-box-*` attributes describe the unrotated box. PowerPoint applies its own automatic rotation and skip to the exported chart: opf-pptx writes an automatic axis text body (`<a:bodyPr/>`, no `rot`) and no `tickLblSkip` for these axes, so nothing forces a wrap, and the preview and the export make the same kind of choice, not identical ones (the export uses 9 pt labels, the preview the readability floor).
254
+
253
255
  ## Development
254
256
 
255
257
  ```sh
package/dist/charts.js CHANGED
@@ -156,6 +156,10 @@ function chartContext(item, box, bound, options, svg, rows, columns) {
156
156
  pt: u * 4 / 3,
157
157
  pad: 10 * u,
158
158
  lineHeight: fontPx * 1.5,
159
+ // One text line as renderTextBox lays it out (fitText line height), and the
160
+ // clear space kept between neighbouring category labels.
161
+ textHeight: fontPx * 1.22,
162
+ labelGap: fontPx * 0.1,
159
163
  colors: CHART_SERIES_COLORS.map((color) => chartColorForFill(surface, color)),
160
164
  gridColor: bound.design.colors.border,
161
165
  axisColor: "#888888",
@@ -168,12 +172,16 @@ function chartContext(item, box, bound, options, svg, rows, columns) {
168
172
  for (const [key, value] of Object.entries(attrs)) out[key] = numericAttrs.has(key) && typeof value === "number" ? n(value) : value;
169
173
  children.push(svg.tag(name, { ...out, ...(path ? svg.traceAttrs(options, path) : {}) }));
170
174
  },
171
- text(value, rect, path, align = "center") {
172
- if (!(rect.width > 0 && rect.height > 0)) return;
173
- children.push(svg.renderTextBox(String(value), rect, bound, {
175
+ textElement(value, rect, path, align = "center") {
176
+ if (!(rect.width > 0 && rect.height > 0)) return "";
177
+ return svg.renderTextBox(String(value), rect, bound, {
174
178
  path, fontSize: requested, fontFamily: bound.design.fonts.body, fontWeight: 400,
175
179
  fill: labelColor, options, align, verticalAlign: "middle"
176
- }));
180
+ });
181
+ },
182
+ text(value, rect, path, align = "center") {
183
+ const element = c.textElement(value, rect, path, align);
184
+ if (element) children.push(element);
177
185
  },
178
186
  series(first = 1) {
179
187
  return columns.slice(first).map((name, offset) => ({
@@ -324,6 +332,202 @@ function maxIntervalsFor(length, labelExtent) {
324
332
  return Math.max(1, Math.min(10, Math.floor(length / Math.max(1, labelExtent))));
325
333
  }
326
334
 
335
+ // ---------------------------------------------------------------------------
336
+ // Category-axis labels. PowerPoint labels a category axis automatically: a label
337
+ // stays on one line unless it has spaces and fits two lines in its category
338
+ // band; when the labels do not fit horizontally it rotates them (-45 degrees,
339
+ // then vertical) and shrinks the plot area to make room; when even that does not
340
+ // fit it draws every n-th label (tickLblSkip auto, first label always kept).
341
+ // The helpers below reproduce that policy deterministically:
342
+ //
343
+ // 1. A label is never broken inside a word. It is one line, or (when it has
344
+ // whitespace) the best two-line split, or an ellipsized single line.
345
+ // 2. Horizontal labels (single, or two-line where a band is too narrow) win
346
+ // when no two labels collide. Otherwise -45 degree, then -90 degree labels
347
+ // are tried while their footprint fits `maxReserve` (at most 40% of the
348
+ // chart height), each drawn with the fewest labels that avoid overlap.
349
+ // 3. When no arrangement shows every label, the arrangement with the smallest
350
+ // skip interval n wins (ties: horizontal, then -45, then -90); labels
351
+ // 0, n, 2n, ... are drawn, so the first label is always drawn.
352
+ // 4. Only a label wider than the whole chart area (or wider than a horizontal
353
+ // bar chart's label gutter, which grows to 30% of the chart width) is
354
+ // ellipsized; that is reported once per chart as `chart-label-truncated`.
355
+ // No arrangement raises `text-overflow`.
356
+
357
+ const ELLIPSIS = "…";
358
+ // Whitespace that may split a label; no-break spaces stay inside their word.
359
+ const BREAKABLE = /[^\S  ]+/u;
360
+
361
+ // Labels lay out as one line of text; authored line breaks read as spaces.
362
+ const flatLabel = (name) => String(name ?? "").replace(/[\r\n]+/g, " ");
363
+
364
+ function labelCandidates(c, entries) {
365
+ return entries.map((entry) => {
366
+ const text = flatLabel(entry.name);
367
+ return { ...entry, text, width: text ? c.width(text) : 0 };
368
+ });
369
+ }
370
+
371
+ // The two-line split with the narrowest widest line, or null for one word.
372
+ function twoLines(c, label) {
373
+ if (label.two !== undefined) return label.two;
374
+ const words = label.text.split(BREAKABLE).filter(Boolean);
375
+ let best = null;
376
+ for (let split = 1; split < words.length; split++) {
377
+ const first = words.slice(0, split).join(" "), second = words.slice(split).join(" ");
378
+ const width = Math.max(c.width(first), c.width(second));
379
+ if (!best || width < best.width - 1e-9) best = { text: `${first}\n${second}`, width };
380
+ }
381
+ return label.two = best;
382
+ }
383
+
384
+ // Longest prefix that fits `limit` once the ellipsis is appended.
385
+ function ellipsize(c, text, limit) {
386
+ const chars = Array.from(text);
387
+ const fits = (length) => c.width(chars.slice(0, length).join("").trimEnd() + ELLIPSIS) <= limit;
388
+ let low = 0, high = chars.length;
389
+ while (low < high) {
390
+ const middle = Math.ceil((low + high) / 2);
391
+ if (fits(middle)) low = middle; else high = middle - 1;
392
+ }
393
+ const out = chars.slice(0, low).join("").trimEnd() + ELLIPSIS;
394
+ return { text: out, width: c.width(out) };
395
+ }
396
+
397
+ function reportTruncatedLabels(c, paths) {
398
+ if (!paths.length) return;
399
+ const many = paths.length !== 1;
400
+ c.svg.reportDiagnostic?.({
401
+ code: "chart-label-truncated", reason: "category-axis", path: c.path, labels: paths,
402
+ message: `${paths.length} category label${many ? "s are" : " is"} wider than the space the axis can give ${many ? "them" : "it"} and ${many ? "were" : "was"} shortened with an ellipsis; shorten the label or enlarge the chart.`
403
+ }, c.options);
404
+ }
405
+
406
+ // A horizontal category axis (labels below the plot). Returns the arrangement
407
+ // with the space it needs under the plot: { angle, n, reserve, labels }.
408
+ function planBottomLabels(c, entries, plot, maxReserve, { minX, maxX, rotatedMinX }) {
409
+ const labels = labelCandidates(c, entries);
410
+ const count = labels.length;
411
+ if (!count) return { angle: 0, n: 1, reserve: c.lineHeight, labels: [] };
412
+ const band = plot.width / count, gap = c.labelGap, lineH = c.textHeight;
413
+ const limit = Math.max(1, maxX - minX);
414
+ const allowTwo = maxReserve >= c.lineHeight + lineH - 0.01;
415
+
416
+ const horizontal = (n) => {
417
+ const slot = n * band - gap, placed = [];
418
+ let prevRight = -Infinity, rows = 1;
419
+ for (let i = 0; i < count; i += n) {
420
+ const label = labels[i];
421
+ let { text, width } = label, lines = 1, truncated = false;
422
+ if (width > slot && allowTwo) {
423
+ const two = twoLines(c, label);
424
+ if (two && two.width <= slot) ({ text, width } = two), lines = 2;
425
+ }
426
+ if (width > limit) ({ text, width } = ellipsize(c, label.text, limit)), lines = 1, truncated = true;
427
+ const cx = plot.x + (i + 0.5) * band;
428
+ const left = Math.min(Math.max(cx - width / 2, minX), maxX - width);
429
+ if (left < prevRight + gap - 1e-6) return null;
430
+ prevRight = left + width;
431
+ rows = Math.max(rows, lines);
432
+ placed.push({ i, text, width, lines, left, cx, truncated });
433
+ }
434
+ return { angle: 0, n, reserve: c.lineHeight + (rows - 1) * lineH, labels: placed };
435
+ };
436
+ let plain = null;
437
+ for (let n = 1; n <= count && !plain; n++) plain = horizontal(n);
438
+ // One label always fits by itself (it is clamped and ellipsized to the chart).
439
+ plain ??= { angle: 0, n: count, reserve: c.lineHeight, labels: [] };
440
+
441
+ const rotated = (angle) => {
442
+ const sin = angle === 90 ? 1 : Math.SQRT1_2, cos = angle === 90 ? 0 : Math.SQRT1_2;
443
+ const n = Math.max(1, Math.ceil(lineH / sin / band - 1e-9)), topGap = c.fontPx * 0.25;
444
+ let extent = 0;
445
+ const placed = [];
446
+ for (let i = 0; i < count; i += n) {
447
+ const { text, width } = labels[i], cx = plot.x + (i + 0.5) * band;
448
+ if (cx - width * cos - lineH * sin / 2 < rotatedMinX - 1e-6) return null;
449
+ extent = Math.max(extent, width * sin + lineH * cos);
450
+ placed.push({ i, text, width, lines: 1, cx, truncated: false });
451
+ }
452
+ const reserve = Math.max(c.lineHeight, topGap + extent);
453
+ return reserve > maxReserve + 1e-6 ? null : { angle, n, reserve, labels: placed, topGap };
454
+ };
455
+ const options = [plain, rotated(45), rotated(90)].filter(Boolean);
456
+ return options.find((option) => option.n === 1) ?? options.reduce((best, option) => option.n < best.n ? option : best);
457
+ }
458
+
459
+ // Lay out the plot for a horizontal category axis. `geometry(reserve)` returns
460
+ // { plot, ...whatever else depends on the height }, the plot leaving `reserve`
461
+ // under it for the labels; rotated labels shrink the plot as Office does.
462
+ function planCategoryAxis(c, entries, geometry) {
463
+ const cap = Math.max(c.lineHeight + c.textHeight, c.box.height * 0.4);
464
+ const bounds = (plot) => ({ minX: plot.x - c.fontPx * 0.5, maxX: plot.x + plot.width + c.fontPx * 0.5, rotatedMinX: c.box.x + c.pad });
465
+ // The space under the plot only grows (a smaller plot can change the value-tick
466
+ // width and so the band, and the arrangement with it); it stops once the plan
467
+ // fits the space that shaped the plot.
468
+ let reserve = c.lineHeight, axis = geometry(reserve), plan = planBottomLabels(c, entries, axis.plot, cap, bounds(axis.plot));
469
+ for (let pass = 0; pass < 3 && plan.reserve > reserve + 0.5; pass++) {
470
+ reserve = plan.reserve;
471
+ axis = geometry(reserve);
472
+ plan = planBottomLabels(c, entries, axis.plot, cap, bounds(axis.plot));
473
+ }
474
+ if (plan.reserve > reserve + 0.5) plan = planBottomLabels(c, entries, axis.plot, reserve, bounds(axis.plot));
475
+ return { ...axis, plan };
476
+ }
477
+
478
+ function drawBottomLabels(c, plan, plot, entries) {
479
+ const y = plot.y + plot.height, band = plot.width / Math.max(1, entries.length), lineH = c.textHeight;
480
+ for (const label of plan.labels) {
481
+ const path = entries[label.i].path;
482
+ if (plan.angle) {
483
+ // Rotated labels end at their tick (Office -45 / -90 degree text).
484
+ const cos = plan.angle === 90 ? 0 : Math.SQRT1_2;
485
+ const px = label.cx, py = y + plan.topGap + lineH * cos / 2, width = label.width + 2;
486
+ const text = c.textElement(label.text, { x: px - width, y: py - lineH * 0.55, width, height: lineH * 1.1 }, path, "right");
487
+ if (text) c.children.push(c.svg.tag("g", { transform: `rotate(${-plan.angle} ${c.num(px)} ${c.num(py)})` }, text));
488
+ continue;
489
+ }
490
+ const centred = Math.abs(label.left - (label.cx - label.width / 2)) < 1e-6;
491
+ const rect = plan.n === 1 && centred && label.width <= band
492
+ ? { x: plot.x + label.i * band, y, width: band, height: plan.reserve }
493
+ : { x: label.left - 1, y, width: label.width + 2, height: plan.reserve };
494
+ c.text(label.text, rect, path);
495
+ }
496
+ reportTruncatedLabels(c, plan.labels.filter((label) => label.truncated).map((label) => entries[label.i].path));
497
+ }
498
+
499
+ // A vertical category axis (labels to the left of horizontal bars and funnels).
500
+ // Labels stay on one line inside the gutter, wrap at spaces onto two lines when
501
+ // their rows are tall enough, else are ellipsized; every n-th row is labelled
502
+ // when the rows are shorter than a line of text.
503
+ function planRowLabels(c, entries, band, gutter) {
504
+ const labels = labelCandidates(c, entries);
505
+ const n = Math.max(1, Math.ceil(c.textHeight / band - 1e-9));
506
+ const canWrap = n * band >= 2 * c.textHeight - 0.01;
507
+ const placed = [];
508
+ for (let i = 0; i < labels.length; i += n) {
509
+ const label = labels[i];
510
+ let text = label.text, truncated = false;
511
+ if (label.width > gutter + 0.01) {
512
+ const two = canWrap ? twoLines(c, label) : null;
513
+ if (two && two.width <= gutter + 0.01) text = two.text;
514
+ else ({ text } = ellipsize(c, label.text, gutter)), truncated = true;
515
+ }
516
+ placed.push({ i, text, truncated });
517
+ }
518
+ return { n, labels: placed };
519
+ }
520
+
521
+ // `rowTop(i)` is the top of category i's row; rows are `band` tall.
522
+ function drawRowLabels(c, plan, entries, band, gutter, x, rowTop) {
523
+ const span = plan.n * band;
524
+ for (const label of plan.labels) {
525
+ const middle = rowTop(label.i) + band / 2;
526
+ c.text(label.text, { x, y: middle - span / 2, width: gutter, height: span }, entries[label.i].path, "right");
527
+ }
528
+ reportTruncatedLabels(c, plan.labels.filter((label) => label.truncated).map((label) => entries[label.i].path));
529
+ }
530
+
327
531
  // ---------------------------------------------------------------------------
328
532
  // Category charts: column, bar, line and area (standard, stacked, 100%).
329
533
 
@@ -380,26 +584,28 @@ function renderCategoryChart(c, spec) {
380
584
  const top = box.y + pad + c.lineHeight / 2;
381
585
  let plot, scale;
382
586
  if (horizontal) {
383
- const categoryWidth = Math.min(box.width * 0.3, Math.max(0, ...categories.map(c.width)) + fontPx * 0.5);
587
+ const categoryWidth = Math.min(box.width * 0.3, Math.max(0, ...categories.map((name) => c.width(flatLabel(name)))) + fontPx * 0.5);
384
588
  const x = box.x + pad + categoryWidth + c.pad / 2;
385
589
  const bottom = box.y + box.height - pad - c.lineHeight;
386
590
  const width = Math.max(1, right - x - fontPx);
387
591
  const tickWidth = Math.max(c.width(formatTick(dataMax, percent)), c.width(formatTick(dataMin, percent)), c.width("100%")) + fontPx;
388
592
  scale = niceScale(dataMin, dataMax, maxIntervalsFor(width, tickWidth), { percent });
389
593
  plot = { x, y: top, width, height: Math.max(1, bottom - top) };
390
- categories.forEach((name, i) => {
391
- const band = plot.height / count;
392
- 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");
393
- });
594
+ // Office bar charts draw the first category nearest the origin (bottom).
595
+ const entries = categories.map((name, i) => ({ name, path: `${c.path}.data.rows.${i}.0` }));
596
+ const rowBand = plot.height / count;
597
+ drawRowLabels(c, planRowLabels(c, entries, rowBand, categoryWidth), entries, rowBand, categoryWidth, box.x + pad, (i) => plot.y + plot.height - (i + 1) * rowBand);
394
598
  } else {
395
- const bottom = box.y + box.height - pad - c.lineHeight;
396
- const height = Math.max(1, bottom - top);
397
- scale = niceScale(dataMin, dataMax, maxIntervalsFor(height, c.lineHeight * 1.2), { percent });
398
- const tickWidth = Math.max(...scale.ticks.map((tick) => c.width(formatTick(tick, percent)))) + fontPx * 0.5;
399
- const x = box.x + pad + tickWidth;
400
- plot = { x, y: top, width: Math.max(1, right - x - fontPx / 2), height };
401
- const band = plot.width / count;
402
- 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`));
599
+ const entries = categories.map((name, i) => ({ name, path: `${c.path}.data.rows.${i}.0` }));
600
+ const axis = planCategoryAxis(c, entries, (reserve) => {
601
+ const height = Math.max(1, box.y + box.height - pad - reserve - top);
602
+ const fitted = niceScale(dataMin, dataMax, maxIntervalsFor(height, c.lineHeight * 1.2), { percent });
603
+ const tickWidth = Math.max(...fitted.ticks.map((tick) => c.width(formatTick(tick, percent)))) + fontPx * 0.5;
604
+ const x = box.x + pad + tickWidth;
605
+ return { scale: fitted, plot: { x, y: top, width: Math.max(1, right - x - fontPx / 2), height } };
606
+ });
607
+ ({ plot, scale } = axis);
608
+ drawBottomLabels(c, axis.plan, plot, entries);
403
609
  }
404
610
  const at = (value) => horizontal ? plot.x + axisFraction(value, scale) * plot.width : plot.y + axisFraction(value, scale, true) * plot.height;
405
611
  // Category axis crosses at zero when zero is on the axis (autoZero).
@@ -600,10 +806,15 @@ function renderRadarChart(c, spec) {
600
806
  const count = categories.length;
601
807
  const values = series.flatMap((s) => s.values).filter((v) => v !== null);
602
808
  const legendWidth = seriesLegend(c, series, spec.style === "filled" ? undefined : "line", spec.markers);
603
- const labelWidth = Math.min(box.width * 0.2, Math.max(0, ...categories.map(c.width)) + fontPx * 0.5);
809
+ const labelWidth = Math.min(box.width * 0.2, Math.max(0, ...categories.map((name) => c.width(flatLabel(name)))) + fontPx * 0.5);
810
+ // Spoke labels follow the same rules as other category labels (never broken
811
+ // inside a word; one line, two lines at spaces, or an ellipsis). A chart with
812
+ // two-line spoke labels keeps room for the second line above and below.
813
+ const spokeLabels = labelCandidates(c, categories.map((name, i) => ({ name, path: `${c.path}.data.rows.${i}.0` })));
814
+ const wraps = spokeLabels.some((label) => label.width > labelWidth + 0.01 && (twoLines(c, label)?.width ?? Infinity) <= labelWidth + 0.01);
604
815
  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) };
605
816
  const cx = area.x + area.width / 2, cy = area.y + area.height / 2;
606
- const radius = Math.max(1, Math.min(area.width / 2 - labelWidth, area.height / 2 - c.lineHeight));
817
+ const radius = Math.max(1, Math.min(area.width / 2 - labelWidth, area.height / 2 - c.lineHeight - (wraps ? c.textHeight : 0)));
607
818
  const scale = niceScale(values.length ? Math.min(...values) : 0, values.length ? Math.max(...values) : 1, maxIntervalsFor(radius, c.lineHeight));
608
819
  const angle = (i) => -Math.PI / 2 + i / count * Math.PI * 2;
609
820
  const rAt = (v) => axisFraction(Math.min(scale.max, Math.max(scale.min, v)), scale) * radius;
@@ -614,19 +825,38 @@ function renderRadarChart(c, spec) {
614
825
  const ring = categories.map((_, i) => point(i, tick).map(n).join(" "));
615
826
  c.mark("path", { d: `M ${ring.join(" L ")} Z`, fill: "none", stroke: c.gridColor, "stroke-opacity": 0.7, "stroke-width": c.pt });
616
827
  }
828
+ // A spoke label that would collide with one already drawn is left out; the first is always kept.
829
+ const drawn = [], truncated = [];
617
830
  categories.forEach((name, i) => {
618
831
  const [x, y] = polar(cx, cy, radius, angle(i));
619
832
  axisLine(c, cx, cy, x, y);
620
833
  const [lx, ly] = polar(cx, cy, radius + fontPx * 0.4, angle(i));
621
834
  const cos = Math.cos(angle(i)), sin = Math.sin(angle(i));
622
835
  const align = Math.abs(cos) < 0.2 ? "center" : cos > 0 ? "left" : "right";
836
+ const label = spokeLabels[i];
837
+ let { text, width } = label, lines = 1, shortened = false;
838
+ if (width > labelWidth + 0.01) {
839
+ const two = twoLines(c, label);
840
+ if (two && two.width <= labelWidth + 0.01) ({ text, width } = two), lines = 2;
841
+ else ({ text, width } = ellipsize(c, label.text, labelWidth)), lines = 1, shortened = true;
842
+ }
843
+ const height = c.lineHeight + (lines - 1) * c.textHeight;
623
844
  const rect = {
624
845
  x: align === "center" ? lx - labelWidth / 2 : align === "left" ? lx : lx - labelWidth,
625
- y: Math.abs(sin) < 0.2 ? ly - c.lineHeight / 2 : sin < 0 ? ly - c.lineHeight : ly,
626
- width: labelWidth, height: c.lineHeight
846
+ y: Math.abs(sin) < 0.2 ? ly - height / 2 : sin < 0 ? ly - height : ly,
847
+ width: labelWidth, height
627
848
  };
628
- c.text(name, rect, `${c.path}.data.rows.${i}.0`, align);
849
+ const left = align === "center" ? rect.x + (labelWidth - width) / 2 : align === "left" ? rect.x : rect.x + labelWidth - width;
850
+ const ink = { left, right: left + width, top: rect.y + (height - lines * c.textHeight) / 2, bottom: rect.y + (height + lines * c.textHeight) / 2 };
851
+ const gap = c.labelGap;
852
+ if (drawn.some((other) => ink.left < other.right + gap && other.left < ink.right + gap && ink.top < other.bottom && other.top < ink.bottom)) {
853
+ return;
854
+ }
855
+ if (shortened) truncated.push(label.path);
856
+ drawn.push(ink);
857
+ c.text(text, rect, label.path, align);
629
858
  });
859
+ reportTruncatedLabels(c, truncated);
630
860
  series.forEach((s, j) => {
631
861
  const color = c.colors[j % c.colors.length], path = `${c.path}.data.columns.${s.column}`;
632
862
  if (spec.style === "filled") {
@@ -721,15 +951,14 @@ export function histogramBins(values) {
721
951
  function chartexPlot(c, { categories, legendWidth = 0, tickLabels = [], percentAxis = false }) {
722
952
  const { box, pad, fontPx } = c;
723
953
  const top = box.y + pad + c.lineHeight / 2;
724
- const bottom = box.y + box.height - pad - c.lineHeight;
725
- const height = Math.max(1, bottom - top);
726
954
  const tickWidth = Math.max(0, ...tickLabels.map((label) => c.width(label))) + fontPx * 0.5;
727
955
  const x = box.x + pad + tickWidth;
728
956
  const right = box.x + box.width - pad - legendWidth - (percentAxis ? c.width("100%") + fontPx : fontPx / 2);
729
- const plot = { x, y: top, width: Math.max(1, right - x), height };
730
- const band = plot.width / Math.max(1, categories.length);
731
- categories.forEach((entry, i) => c.text(entry.name, { x: plot.x + i * band, y: plot.y + plot.height, width: band, height: c.lineHeight }, entry.path));
732
- return { plot, band };
957
+ const { plot, plan } = planCategoryAxis(c, categories, (reserve) => ({
958
+ plot: { x, y: top, width: Math.max(1, right - x), height: Math.max(1, box.y + box.height - pad - reserve - top) }
959
+ }));
960
+ drawBottomLabels(c, plan, plot, categories);
961
+ return { plot, band: plot.width / Math.max(1, categories.length) };
733
962
  }
734
963
 
735
964
  function valueAxis(c, plot, scale) {
@@ -820,12 +1049,13 @@ function renderFunnelChart(c) {
820
1049
  const { column, values, categories } = chartexValues(c.rows, c.columns);
821
1050
  const count = categories.length;
822
1051
  const max = Math.max(0, ...values.filter((value) => value !== null));
823
- const labelWidth = Math.min(box.width * 0.3, Math.max(0, ...categories.map(c.width)) + fontPx * 0.5);
1052
+ const labelWidth = Math.min(box.width * 0.3, Math.max(0, ...categories.map((name) => c.width(flatLabel(name)))) + fontPx * 0.5);
824
1053
  const plot = { x: box.x + pad + labelWidth + pad / 2, y: box.y + pad, width: Math.max(1, box.width - 2 * pad - labelWidth - pad / 2), height: Math.max(1, box.height - 2 * pad) };
825
1054
  const band = plot.height / count, height = band / 1.06, offset = (band - height) / 2;
1055
+ const entries = categories.map((name, i) => ({ name, path: `${c.path}.data.rows.${i}.0` }));
1056
+ drawRowLabels(c, planRowLabels(c, entries, band, labelWidth), entries, band, labelWidth, box.x + pad, (i) => plot.y + i * band);
826
1057
  categories.forEach((name, i) => {
827
1058
  const y = plot.y + i * band;
828
- c.text(name, { x: box.x + pad, y, width: labelWidth, height: band }, `${c.path}.data.rows.${i}.0`, "right");
829
1059
  const value = values[i];
830
1060
  if (value === null || !(max > 0) || value <= 0) return;
831
1061
  const width = plot.width * Math.min(1, value / max);
@@ -68,6 +68,15 @@ export declare function loadBrowserFontRegistry(
68
68
  scriptBaseUrl?: string;
69
69
  /** Where the host serves this package's `fonts` directory, so vendored faces (Intos, the open families) load on demand from `<base>/<package-relative file>` (fonts/intos/..., fonts/<family>/...). */
70
70
  lazyFontsBaseUrl?: string;
71
+ /**
72
+ * FF-41: faces the host serves itself, loaded on demand like the vendored ones: hash-verified, all or nothing, in the same
73
+ * `lazyFacesNeeded` pass (a document that draws a vendored and an extra face fetches both in one `ensureLazyFonts`), removed
74
+ * by `dispose()`. They appear in `registry.lazyFonts` with `package: "host"`. Typically the faces of a manifest that
75
+ * `splitStartupFaces` left out of the startup set. Like the vendored faces they are `embed: "used"`: not in `registry.embeddedFonts` (only the startup faces are);
76
+ * pass `registry.selectEmbeddedFonts(...)` to embed them in a standalone SVG, and then only when a slide's text uses their family.
77
+ * A missing or malformed `sha256`, a duplicate `url` or an invalid family throws `invalid-font-source`.
78
+ */
79
+ extraLazyFonts?: readonly ExtraLazyFont[];
71
80
  /**
72
81
  * FF-41: the `renderSvg` options the documents resolve with (`catalogs` above all), the default for `pendingLazyFonts`,
73
82
  * `ensureLazyFonts`, `pendingScripts` and `ensureScripts`. A layout or font scheme id that only the host's catalogs have
@@ -85,7 +94,15 @@ export declare function scriptFontEntries(
85
94
  /** Every vendored face of this package: family, style, package-relative file, sha256. */
86
95
  export declare function lazyFontList(): readonly LazyFont[];
87
96
  /** Hash-pinned browser entries for the vendored faces, served by the host from `baseUrl` at their package-relative paths. */
88
- export declare function lazyFontEntries(options: { baseUrl: string }, list?: readonly LazyFont[]): (BrowserFontInput & { url: string; family: string; weight: number; italic: boolean; sha256: string; package: string })[];
97
+ export declare function lazyFontEntries(options: { baseUrl?: string }, list?: readonly LazyFont[]): (BrowserFontInput & { url: string; family: string; weight: number; italic: boolean; sha256: string; package: string })[];
98
+ /** A face the host serves itself and loads on demand (FF-41): `sha256` is mandatory, the registry verifies the bytes before use. */
99
+ export interface ExtraLazyFont { family: string; weight: number; italic?: boolean; url: string; sha256: string; license?: string }
100
+ /**
101
+ * Split faces (a host's manifest, `registry.embeddedFonts`) into the ones a registry starts with and the rest, to pass as
102
+ * `extraLazyFonts`. The default startup set is Roboto Regular (the renderer's fallback family); `startup` chooses other faces, for
103
+ * example the ones `presentationFaces` says the first deck draws. Order is kept.
104
+ */
105
+ export declare function splitStartupFaces<T extends { family: string; weight: number; italic?: boolean }>(faces: readonly T[], options?: { startup?: (face: T) => boolean }): { startup: T[]; rest: T[] };
89
106
  /**
90
107
  * The font families a presentation's slides resolve (heading, body and code roles of every slide). `options` are the
91
108
  * `renderSvg` options the document resolves with (`catalogs`, ...). A document that does not resolve throws what `renderSvg`
@@ -1,9 +1,9 @@
1
1
  import { createFontRegistry, OPFFontError } from "./fonts.js";
2
- import { lazyFontEntries, lazyFontList } from "./lazy-font-list.js";
2
+ import { lazyFontEntries, lazyFontList, normalizeExtraLazyFonts } from "./lazy-font-list.js";
3
3
  import { lazyFacesNeeded } from "./lazy-fonts.js";
4
4
  import { analyzePresentationScripts, nextFallbackPackage, scriptFontPackages, scriptPackageEntries, scriptSelectionOf, uncoveredCjkCharacters } from "./script-font-pack.js";
5
5
  export { autoScriptSelection, detectPresentationScripts, scriptFontEntries, scriptFontPackages } from "./script-font-pack.js";
6
- export { lazyFontEntries, lazyFontList } from "./lazy-font-list.js";
6
+ export { lazyFontEntries, lazyFontList, splitStartupFaces } from "./lazy-font-list.js";
7
7
  export { lazyFacesNeeded, lazyFontsFor, presentationFaces, presentationFamilies } from "./lazy-fonts.js";
8
8
 
9
9
  async function verifyDigest(entry, data, subtle) {
@@ -22,6 +22,8 @@ async function verifyDigest(entry, data, subtle) {
22
22
 
23
23
  /** Fetch only host-selected font files, then use those exact bytes for shaping and CSS. */
24
24
  export async function loadBrowserFontRegistry(entries, options = {}) {
25
+ // Validate the host's extra faces first: a bad list must not leave startup faces registered in the document with no registry to dispose.
26
+ const extraLazy = normalizeExtraLazyFonts(options.extraLazyFonts);
25
27
  const documentRef = options.document ?? globalThis.document;
26
28
  const FontFaceRef = documentRef?.defaultView?.FontFace ?? globalThis.FontFace;
27
29
  if (!documentRef?.fonts || !FontFaceRef)
@@ -182,7 +184,9 @@ export async function loadBrowserFontRegistry(entries, options = {}) {
182
184
  // FF-31: vendored faces (the open families and Intos) load on demand, once a document needs them. They share the
183
185
  // script loader's queue and disposed state, and the same all-or-nothing order: fetch and verify, load every FontFace,
184
186
  // then add them to the document and the registry together.
185
- const lazy = lazyFontList(), lazyLoaded = new Set();
187
+ // FF-41: the host's extra lazy faces (`options.extraLazyFonts`, validated above) load like the vendored ones, from their own urls,
188
+ // hash-verified, in the same `lazyFacesNeeded` pass, so a document that draws both fetches both in one call.
189
+ const lazy = Object.freeze([...lazyFontList(), ...extraLazy]), lazyLoaded = new Set();
186
190
  const policy = options.substitutionPolicy ?? "none";
187
191
  const aliasTargets = new Map(Object.entries(options.aliases ?? {}).map(([from, to]) => [from.toLowerCase(), to]));
188
192
  // Face level (FF-41): the faces the document draws, resolved as the registry resolves them with every vendored face loaded.
@@ -194,7 +198,7 @@ export async function loadBrowserFontRegistry(entries, options = {}) {
194
198
  return options.lazyFontsBaseUrl;
195
199
  };
196
200
  Object.assign(registry, {
197
- /** Every vendored face a host can load on demand: family, style, package-relative file and pinned sha256. */
201
+ /** Every face a host can load on demand: the vendored ones (family, style, package-relative file, pinned sha256) and the host's `extraLazyFonts` (`package: "host"`, `file` and `url` the url it serves them from). */
198
202
  lazyFonts: lazy,
199
203
  /**
200
204
  * Load the vendored faces the presentation draws (FF-41: only the faces, by family, weight and style, that the renderer
@@ -211,7 +215,9 @@ export async function loadBrowserFontRegistry(entries, options = {}) {
211
215
  callOptions.signal?.throwIfAborted?.();
212
216
  const pending = neededLazy(presentation, callOptions);
213
217
  if (!pending.length) return [];
214
- const fetched = (await fetchFaces(lazyFontEntries({ baseUrl: lazyBaseUrl() }, pending), callOptions.signal)).map((entry) => ({ ...entry, embed: "used" }));
218
+ // The vendored faces need lazyFontsBaseUrl; the host's own faces carry their url. Both are embed "used": not in registry.embeddedFonts.
219
+ const entries = lazyFontEntries({ baseUrl: pending.some((face) => face.package !== "host") ? lazyBaseUrl() : undefined }, pending);
220
+ const fetched = (await fetchFaces(entries, callOptions.signal)).map((entry) => ({ ...entry, embed: "used" }));
215
221
  let browserFaces;
216
222
  try {
217
223
  browserFaces = await Promise.all(fetched.map(async (entry) => {
@@ -36,7 +36,7 @@ export interface ScriptPackOptions {
36
36
  onDiagnostic?: (diagnostic: {code: string; message: string; script?: string; package?: string; scripts?: string[]}) => void;
37
37
  }
38
38
  /** A vendored face that an SVG embeds only when its text names the family (the open families and Intos), and that browser hosts load on demand from a separate hash-pinned file. */
39
- export interface LazyFont { readonly package: string; readonly family: string; readonly weight: number; readonly italic: boolean; /** Path relative to the package root, for example fonts/intos/Intos-Regular.ttf. */ readonly file: string; readonly sha256: string; readonly license: string; readonly renamedFrom?: string }
39
+ export interface LazyFont { readonly package: string; readonly family: string; readonly weight: number; readonly italic: boolean; /** Path relative to the package root, for example fonts/intos/Intos-Regular.ttf. */ readonly file: string; readonly sha256: string; readonly license: string; readonly renamedFrom?: string; /** Set on a host's extra lazy face (`extraLazyFonts`, `package: "host"`): where the host serves the file from. */ readonly url?: string }
40
40
  export type ScriptFontRegistry = FontRegistry & {fontFiles:string[]; /** Set for `scripts: "auto"`. */ scriptSelection?: AppliedScriptSelection};
41
41
  export type NodeFontRegistry = ScriptFontRegistry & {/** The embed "used" faces this registry holds, with the files a host copies to serve them itself. */ lazyFonts: readonly LazyFont[]};
42
42
  export declare function loadBundledFontRegistry(options?: FontRegistryOptions & ScriptPackOptions): Promise<NodeFontRegistry>;
@@ -13,11 +13,55 @@ export function lazyFontList(manifest = BUNDLED_FONT_MANIFEST) {
13
13
  }))));
14
14
  }
15
15
 
16
- /** Hash-pinned browser entries for the vendored faces, served by the host from `baseUrl` at their package-relative paths. */
16
+ /**
17
+ * Hash-pinned browser entries for the vendored faces, served by the host from `baseUrl` at their package-relative paths. A face
18
+ * that carries its own `url` (a host's extra lazy face, FF-41) keeps it; `baseUrl` is then needed only for the faces without one.
19
+ */
17
20
  export function lazyFontEntries(options, list = lazyFontList()) {
18
21
  const baseUrl = options?.baseUrl;
19
- if (typeof baseUrl !== "string" || !baseUrl) throw new OPFFontError("invalid-font-source", "lazyFontEntries needs a baseUrl, where the host serves the package's fonts directory.");
20
- const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
21
- return list.map(face => ({ url: `${base}${face.file}`, family: face.family, weight: face.weight, italic: face.italic, sha256: face.sha256, package: face.package }));
22
+ const needsBase = list.some(face => typeof face.url !== "string");
23
+ if (needsBase && (typeof baseUrl !== "string" || !baseUrl)) throw new OPFFontError("invalid-font-source", "lazyFontEntries needs a baseUrl, where the host serves the package's fonts directory.");
24
+ const base = needsBase ? (baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`) : "";
25
+ return list.map(face => ({ url: typeof face.url === "string" ? face.url : `${base}${face.file}`, family: face.family, weight: face.weight, italic: face.italic, sha256: face.sha256, package: face.package, ...(face.license ? { license: face.license } : {}) }));
26
+ }
27
+
28
+ /** Roboto Regular, the renderer's fallback family: the face a browser registry starts with. */
29
+ const isRobotoRegular = face => face?.family === "Roboto" && face.weight === 400 && !face.italic;
30
+
31
+ /**
32
+ * Split a list of faces (a host's manifest, `registry.embeddedFonts`) into the ones a registry starts with and the rest, which a
33
+ * host passes as `extraLazyFonts` so they load on demand. By default the startup set is Roboto Regular, the fallback family; pass
34
+ * `startup` to choose other faces (for example the ones `presentationFaces` says the first deck draws). Order is kept.
35
+ * @template {{family: string, weight: number, italic?: boolean}} T
36
+ * @param {readonly T[]} faces
37
+ * @param {{startup?: (face: T) => boolean}} [options]
38
+ * @returns {{startup: T[], rest: T[]}}
39
+ */
40
+ export function splitStartupFaces(faces, options = {}) {
41
+ const test = options.startup ?? isRobotoRegular;
42
+ if (typeof test !== "function") throw new OPFFontError("invalid-font-source", "splitStartupFaces: startup must be a function.");
43
+ const startup = [], rest = [];
44
+ for (const face of faces) (test(face) ? startup : rest).push(face);
45
+ return { startup, rest };
46
+ }
47
+
48
+ /**
49
+ * Validate a host's extra lazy faces (`{family, weight, italic, url, sha256, license?}`) and shape them like the vendored ones
50
+ * (`package: "host"`, `file` = `url`). The SHA-256 is mandatory: the registry verifies every byte it adds.
51
+ */
52
+ export function normalizeExtraLazyFonts(faces) {
53
+ if (faces === undefined) return [];
54
+ if (!Array.isArray(faces)) throw new OPFFontError("invalid-font-source", "extraLazyFonts must be an array of {family, weight, italic, url, sha256} entries.");
55
+ const seen = new Set();
56
+ return faces.map((face, index) => {
57
+ const label = `extraLazyFonts[${index}]`;
58
+ if (typeof face?.family !== "string" || !face.family.trim() || /[\u0000-\u001f"'\\<>;]/.test(face.family)) throw new OPFFontError("invalid-font-source", `${label} needs a plain family name.`);
59
+ if (!Number.isInteger(face.weight) || face.weight < 1 || face.weight > 1000) throw new OPFFontError("invalid-font-source", `${label} needs a weight between 1 and 1000.`);
60
+ if (typeof face.url !== "string" || !face.url) throw new OPFFontError("invalid-font-source", `${label} needs the url the host serves the font file from.`);
61
+ if (typeof face.sha256 !== "string" || !/^[0-9a-f]{64}$/i.test(face.sha256)) throw new OPFFontError("invalid-font-source", `${label} needs the font file's SHA-256 (64 hex digits): extra faces are verified before use.`);
62
+ if (seen.has(face.url)) throw new OPFFontError("invalid-font-source", `${label} repeats url ${face.url}.`);
63
+ seen.add(face.url);
64
+ return Object.freeze({ package: "host", family: face.family, weight: face.weight, italic: face.italic === true, file: face.url, url: face.url, sha256: face.sha256.toLowerCase(), ...(typeof face.license === "string" ? { license: face.license } : {}) });
65
+ });
22
66
  }
23
67
 
package/dist/svg.js CHANGED
@@ -1221,7 +1221,7 @@ function renderTableBorders(defaultEdges, explicitEdges, scale, bound, options)
1221
1221
  function renderChart(item, box, bound, options) {
1222
1222
  // Catalog chart types (kept, deprecated and aliased ids) preview the native
1223
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 });
1224
+ const rendered = renderCatalogChart(item, box, bound, options, { tag, traceAttrs, stableNumber, renderTextBox, reportDiagnostic });
1225
1225
  if (rendered) return rendered;
1226
1226
  const chart = item.value ?? {};
1227
1227
  const chartType = chart.type ?? engineDefaults.chartTypes[0];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-render",
3
- "version": "0.11.5",
3
+ "version": "0.11.7",
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/mongolian-shaping.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-faces.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 && node --check test/gallery-layout-alignment.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/theme-slots.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/mongolian-shaping.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/extra-lazy-fonts.mjs && node --check test/extra-lazy-fonts-browser.mjs && node --check test/lazy-faces.mjs && node --check test/lazy-fonts-browser.mjs && node --check test/lazy-face-fallback.mjs && node --check test/lazy-face-fallback-browser.mjs && node --check src/font-policy.js && node --check scripts/update-font-policy.mjs && node --check src/fonts-browser.js && node --check test/fonts-browser.mjs && node --check src/svg.js && node --check src/raster.js && node --check src/raster-images.js && node --check test/webp.mjs && node --check test/jpeg-orientation.mjs && node --check scripts/build-browser-check.mjs && node --check test/jpeg-browser.js && node --check test/rich-table.mjs && node --check test/rich-tab.mjs && node --check test/rich-tab-browser.mjs && node --check test/rich-tab-estimated-browser.mjs && node --check test/styled-table.mjs && node --check test/color-references.mjs && node --check test/default-font-scheme.mjs && node --check test/chart-colors.mjs && node --check test/chart-axis.mjs && node --check test/chart-category-labels.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 && node --check test/gallery-layout-alignment.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/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-faces.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/mongolian-shaping.mjs && node test/design-preview.mjs && node test/item-alignment.mjs && node test/gallery-layout-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-category-labels.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/extra-lazy-fonts.mjs && node test/lazy-faces.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/theme-slots.mjs && node test/glyph-fallback.mjs && node test/script-fonts-auto.mjs && node test/script-fonts-auto-robust.mjs && node test/mongolian-shaping.mjs && node test/design-preview.mjs && node test/item-alignment.mjs && node test/gallery-layout-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",
@@ -79,6 +79,7 @@
79
79
  "test:aptos-preview-browser": "node test/aptos-preview-browser.mjs artifacts/aptos-preview-browser.json",
80
80
  "test:lazy-face-fallback-browser": "node test/lazy-face-fallback-browser.mjs artifacts/lazy-face-fallback",
81
81
  "test:lazy-fonts-browser": "node test/lazy-fonts-browser.mjs artifacts/lazy-fonts",
82
+ "test:extra-lazy-fonts-browser": "node test/extra-lazy-fonts-browser.mjs artifacts/extra-lazy-fonts",
82
83
  "test:script-fonts-browser": "node test/script-fonts-browser.mjs artifacts/script-fonts-browser.json",
83
84
  "test:script-fonts-auto-browser": "node test/script-fonts-auto-browser.mjs artifacts/script-fonts-auto",
84
85
  "test:text": "node test/font-outlines.mjs && node test/accepted-text.mjs",
@@ -96,7 +97,7 @@
96
97
  "@expo-google-fonts/roboto": "0.4.3",
97
98
  "@expo-google-fonts/roboto-mono": "0.4.2",
98
99
  "@expo-google-fonts/tinos": "0.4.2",
99
- "@openpresentation/opf": "^0.11.2",
100
+ "@openpresentation/opf": "^0.11.3",
100
101
  "@resvg/resvg-js": "^2.6.2",
101
102
  "fontkit": "^2.0.4",
102
103
  "pdf-lib": "^1.17.1",