@openpresentation/opf-render 0.11.4 → 0.11.6

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 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.
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.
4
4
 
5
- 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.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
 
@@ -189,6 +189,8 @@ registry.scriptSelection; // { detected, scripts, unavailable, packages, notInst
189
189
  const fonts = await loadBrowserFontRegistry(baseEntries, { substitutionPolicy: 'visual', fallbackFamily: 'Roboto', scriptBaseUrl: '/script-fonts/' });
190
190
  if (fonts.pendingScripts(presentation).length) await fonts.ensureScripts(presentation); // resolves { detected, scripts, unavailable, loaded }
191
191
  // pendingScripts is synchronous: empty means nothing is missing, so render at once.
192
+ // Text decides, so the analysis does not resolve layouts or catalogs. Pass the render options (renderOptions: { catalogs } to
193
+ // loadBrowserFontRegistry, or per call) so a font scheme only the host's catalogs have and that names a script font (Yu Gothic) counts.
192
194
  ```
193
195
 
194
196
  Where the renderer has per-character glyph fallback (`glyphFallbackFamilies`), auto also loads Noto Sans when the text has Greek or Cyrillic (the chosen Latin face may lack it), and the next CJK face of the fallback chain when a drawn Han, kana or Hangul character is missing from the loaded ones (a Simplified-only hanzi in Japanese text, hanja). That is capped at one fallback package beyond the packages the text's own scripts need: a character no CJK face covers never pulls in all four (10 to 20 MiB each); it is listed in `ensureScripts().uncovered` (Node: `script-glyph-uncovered`, `registry.scriptSelection.uncovered`) and the renderer reports `missing-glyph`. Browser loads are all or nothing per package: a failed fetch, hash check or `FontFace.load()` leaves the registry, the document and `loadedScriptPackages` unchanged and the package pending. One failing package does not lose the others: `ensureScripts` loads every package it can, then rejects with an `OPFFontError` whose `details` are `{ loaded, failed: [{package, code, message}] }`, and the next call retries only the failures. `ensureScripts(presentation, { signal })` and `loadScripts(scripts, { signal })` take a per-call `AbortSignal` (the creation signal is not reused), and `dispose()` during a load leaves no face behind and rejects with `font-registry-disposed`. Script faces carry `embed: "used"`, like the open families: the eager `registry.embeddedFonts` leaves them out, and when you pass them explicitly (`registry.selectEmbeddedFonts(face => face.scripts)`) an SVG embeds one only when the slide's text uses its family, so a Latin slide never carries a CJK face.
@@ -248,6 +250,8 @@ Chartex chart previews (FF-22b) draw the constructs opf-pptx exports as Office 2
248
250
 
249
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.
250
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
+
251
255
  ## Development
252
256
 
253
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);
@@ -4,6 +4,7 @@ import type {
4
4
  FontRegistryOptions,
5
5
  } from "./fonts.js";
6
6
  import type { AutoScriptSelection, BundledFontPackage, LazyFont, ScriptSelection } from "./fonts-node.js";
7
+ import type { RenderSvgOptions } from "./svg.js";
7
8
  export type { LazyFont } from "./fonts-node.js";
8
9
  export type { AutoScriptSelection } from "./fonts-node.js";
9
10
  export { detectPresentationScripts, autoScriptSelection } from "./fonts-node.js";
@@ -18,15 +19,22 @@ export interface BrowserFontRegistry extends FontRegistry {
18
19
  /** Every vendored face a host can load on demand: family, style, package-relative file and pinned sha256. */
19
20
  readonly lazyFonts: readonly LazyFont[];
20
21
  /**
21
- * Load the vendored faces (for example Intos for the default Aptos scheme) the presentation's font families resolve to
22
- * under the registry's `substitutionPolicy` (nothing is downloaded for a family the policy would not resolve), once
23
- * each, from `lazyFontsBaseUrl`. Fetches and verifies every file, then adds the faces to the document and the registry
24
- * together; on failure nothing changes and the call can be retried. Call it before measuring a document. After
25
- * `dispose()` it rejects with `font-registry-disposed`.
22
+ * Load the vendored faces (for example Intos Display 700 and Intos 400 for a plain deck in the default Aptos scheme) the
23
+ * presentation draws, resolved as the registry resolves them under its `substitutionPolicy` (nothing is downloaded for a
24
+ * family the policy would not resolve, nor for a weight or style nothing draws), once each, from `lazyFontsBaseUrl`.
25
+ * Fetches and verifies every file, then adds the faces to the document and the registry together; on failure nothing
26
+ * changes and the call can be retried. Call it before measuring a document, and again after edits: an edit that adds a
27
+ * bold or italic run loads just that face. Besides `signal`, the call takes the `renderSvg` options the document resolves
28
+ * with (`catalogs`, ...) over the loader's `renderOptions`. A document that does not resolve rejects with the error
29
+ * `renderSvg` throws for it. After `dispose()` it rejects with `font-registry-disposed`.
30
+ */
31
+ ensureLazyFonts(presentation: unknown, options?: { signal?: AbortSignal } & Partial<RenderSvgOptions>): Promise<LazyFont[]>;
32
+ /**
33
+ * Synchronous: the vendored faces the presentation draws under the registry's policy that are not loaded yet. Empty means
34
+ * `ensureLazyFonts` fetches nothing. `renderOptions` are the `renderSvg` options the document resolves with (`catalogs`, ...)
35
+ * over the loader's `renderOptions`. Throws what `renderSvg` throws for a document that does not resolve.
26
36
  */
27
- ensureLazyFonts(presentation: unknown, options?: { signal?: AbortSignal }): Promise<LazyFont[]>;
28
- /** Synchronous: the vendored faces the presentation needs under the registry's policy that are not loaded yet. Empty means `ensureLazyFonts` fetches nothing. */
29
- pendingLazyFonts(presentation: unknown): LazyFont[];
37
+ pendingLazyFonts(presentation: unknown, renderOptions?: Partial<RenderSvgOptions>): LazyFont[];
30
38
 
31
39
  /** Load script faces for ISO 15924 codes (or "all") from `scriptBaseUrl`. Resolves with the newly loaded package names. */
32
40
  loadScripts(scripts: ScriptSelection, options?: { signal?: AbortSignal }): Promise<string[]>;
@@ -39,9 +47,9 @@ export interface BrowserFontRegistry extends FontRegistry {
39
47
  * OPFFontError whose `details` are `{ loaded: string[]; failed: {package, code, message}[] }`. A failed call (fetch, hash, FontFace load) leaves nothing loaded and can be retried; after
40
48
  * `dispose()` it rejects with `font-registry-disposed`.
41
49
  */
42
- ensureScripts(presentation: unknown, options?: { signal?: AbortSignal }): Promise<AutoScriptSelection & { loaded: string[]; uncovered: string[] }>;
43
- /** Synchronous: package names the presentation needs that are not loaded yet. Empty means `ensureScripts` fetches nothing, so a host can render immediately. */
44
- pendingScripts(presentation: unknown): string[];
50
+ ensureScripts(presentation: unknown, options?: { signal?: AbortSignal } & Partial<RenderSvgOptions>): Promise<AutoScriptSelection & { loaded: string[]; uncovered: string[] }>;
51
+ /** Synchronous: package names the presentation needs that are not loaded yet. Empty means `ensureScripts` fetches nothing, so a host can render immediately. Text decides, so a document is analyzed without resolving its layouts; `renderOptions.catalogs` matter for font schemes only the host's catalogs have (FF-41). */
52
+ pendingScripts(presentation: unknown, renderOptions?: Partial<RenderSvgOptions>): string[];
45
53
  /** Names of the script-pack packages loaded so far. */
46
54
  readonly loadedScriptPackages: string[];
47
55
  }
@@ -60,6 +68,12 @@ export declare function loadBrowserFontRegistry(
60
68
  scriptBaseUrl?: string;
61
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>/...). */
62
70
  lazyFontsBaseUrl?: string;
71
+ /**
72
+ * FF-41: the `renderSvg` options the documents resolve with (`catalogs` above all), the default for `pendingLazyFonts`,
73
+ * `ensureLazyFonts`, `pendingScripts` and `ensureScripts`. A layout or font scheme id that only the host's catalogs have
74
+ * resolves the same way here as it does when the host renders. A call's own options win.
75
+ */
76
+ renderOptions?: Partial<RenderSvgOptions>;
63
77
  },
64
78
  ): Promise<BrowserFontRegistry>;
65
79
  export declare function scriptFontPackages(scripts: ScriptSelection): BundledFontPackage[];
@@ -72,10 +86,37 @@ export declare function scriptFontEntries(
72
86
  export declare function lazyFontList(): readonly LazyFont[];
73
87
  /** Hash-pinned browser entries for the vendored faces, served by the host from `baseUrl` at their package-relative paths. */
74
88
  export declare function lazyFontEntries(options: { baseUrl: string }, list?: readonly LazyFont[]): (BrowserFontInput & { url: string; family: string; weight: number; italic: boolean; sha256: string; package: string })[];
75
- /** The font families a presentation's slides resolve (heading, body and code roles); empty for a document that does not resolve. */
76
- export declare function presentationFamilies(presentation: unknown): Set<string>;
77
89
  /**
78
- * The vendored faces a set of resolved families needs that the registry does not hold, following the registry's own order and
90
+ * The font families a presentation's slides resolve (heading, body and code roles of every slide). `options` are the
91
+ * `renderSvg` options the document resolves with (`catalogs`, ...). A document that does not resolve throws what `renderSvg`
92
+ * throws for it (FF-41: it no longer returns an empty set). This is the role families, not what is drawn: see `presentationFaces`.
93
+ */
94
+ export declare function presentationFamilies(presentation: unknown, options?: Partial<RenderSvgOptions>): Set<string>;
95
+ /** A face request or a drawn face: a family, a weight and a style. */
96
+ export interface FaceRequest { family: string; weight: number; italic: boolean }
97
+ /**
98
+ * The faces a preview of the presentation draws (FF-41), sorted by family, style and weight: the renderer's own layout and
99
+ * painting with a recording measurement, so heading and body roles, bold and italic runs, tables, furniture, chart labels and
100
+ * the code role (only where code is drawn), per-slide overrides and script slots are all followed. Without `registry` the
101
+ * faces are the styles as the document names them. With `registry` (`{faces, policy, aliases, fallbackFamily}`: the faces a
102
+ * registry holds or could hold and its substitution options) every style resolves as that registry resolves it, in the two
103
+ * steps drawing takes (role family, then weight within it), and the result is the faces the registry paints.
104
+ * `options` are the `renderSvg` options the document resolves with; a document that does not resolve throws what `renderSvg`
105
+ * throws for it.
106
+ */
107
+ export declare function presentationFaces(presentation: unknown, options?: Partial<RenderSvgOptions>, registry?: { faces: Iterable<FaceRequest>; policy?: "none" | "metric" | "visual"; aliases?: ReadonlyMap<string, string>; fallbackFamily?: string }): FaceRequest[];
108
+ /**
109
+ * The vendored faces (of `lazy`) a presentation draws that the registry does not hold. `held` is `registry.describeFaces()`,
110
+ * `loaded` the lazy files already loaded; `policy`, `aliases` and `fallbackFamily` are the registry's. Face level: only the
111
+ * drawn faces, so an edit adding a bold run adds one face.
112
+ */
113
+ export declare function lazyFacesNeeded(presentation: unknown, renderOptions: Partial<RenderSvgOptions> | undefined, context: { lazy: readonly LazyFont[]; held: Iterable<FaceRequest>; loaded?: ReadonlySet<string>; policy?: "none" | "metric" | "visual"; aliases?: ReadonlyMap<string, string>; fallbackFamily?: string }): LazyFont[];
114
+ /**
115
+ * The vendored faces a list of requests needs that the registry does not hold, following the registry's own order and
79
116
  * `policy` (default "visual"): the family itself, an alias target, the declared replacement, then its alternates.
117
+ * Entries are face requests (`presentationFaces`), resolved as the registry resolves them with every vendored face loaded
118
+ * over `held` (`registry.describeFaces()`), so only the faces asked for are returned and a partially loaded family still
119
+ * loads its missing faces; or family names, which mean every face of the family. Without `held` a face request falls back to
120
+ * family level with `hasFamily`.
80
121
  */
81
- export declare function lazyFontsFor(families: Iterable<string>, context: { lazy: readonly LazyFont[]; hasFamily(family: string): boolean; loaded?: ReadonlySet<string>; policy?: "none" | "metric" | "visual"; aliases?: ReadonlyMap<string, string> }): LazyFont[];
122
+ export declare function lazyFontsFor(entries: Iterable<string | { family: string; weight?: number; italic?: boolean }>, context: { lazy: readonly LazyFont[]; held?: Iterable<FaceRequest>; hasFamily?: (family: string) => boolean; loaded?: ReadonlySet<string>; policy?: "none" | "metric" | "visual"; aliases?: ReadonlyMap<string, string>; fallbackFamily?: string }): LazyFont[];
@@ -1,10 +1,10 @@
1
1
  import { createFontRegistry, OPFFontError } from "./fonts.js";
2
2
  import { lazyFontEntries, lazyFontList } from "./lazy-font-list.js";
3
- import { lazyFontsFor, presentationFamilies } from "./lazy-fonts.js";
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
6
  export { lazyFontEntries, lazyFontList } from "./lazy-font-list.js";
7
- export { lazyFontsFor, presentationFamilies } from "./lazy-fonts.js";
7
+ export { lazyFacesNeeded, lazyFontsFor, presentationFaces, presentationFamilies } from "./lazy-fonts.js";
8
8
 
9
9
  async function verifyDigest(entry, data, subtle) {
10
10
  if (entry.sha256 === undefined) return;
@@ -91,6 +91,9 @@ export async function loadBrowserFontRegistry(entries, options = {}) {
91
91
  // Script faces (FF-19) are loaded lazily, once a document needs them, and only for the scripts it uses.
92
92
  const scriptPackages = new Set();
93
93
  let queue = Promise.resolve(), disposed = false;
94
+ // FF-41: the `renderSvg` options a document resolves with (`catalogs`, ...). `options.renderOptions` are the defaults; a call's own
95
+ // options (everything but `signal`) are added over them, so a host that passes its canvas options gets the same resolution here.
96
+ const renderOptionsFor = (callOptions) => { const { signal: _signal, ...own } = callOptions ?? {}; return { ...options.renderOptions, ...own }; };
94
97
  const gone = () => new OPFFontError("font-registry-disposed", "The font registry was disposed.");
95
98
  const baseUrl = () => {
96
99
  if (typeof options.scriptBaseUrl !== "string" || !options.scriptBaseUrl)
@@ -142,14 +145,15 @@ export async function loadBrowserFontRegistry(entries, options = {}) {
142
145
  * fallback needs for characters the loaded faces lack. Cheap when nothing new is needed; call it after edits and
143
146
  * render again afterwards. Resolves with the detected scripts, the packages loaded by this call, the scripts no
144
147
  * pinned font serves and `uncovered`, drawn CJK characters no loaded face covers (the renderer reports
145
- * `missing-glyph` for them). Each package loads all or nothing. If some package fails, the others still load: the
148
+ * `missing-glyph` for them). Besides `signal`, the call takes the `renderSvg` options the document resolves with
149
+ * (`catalogs`, ...) over the loader's `renderOptions`. Each package loads all or nothing. If some package fails, the others still load: the
146
150
  * call then rejects with an OPFFontError (the first failure's code) whose `details` hold `loaded` (packages this
147
151
  * call did load) and `failed` (`{package, code, message}` per failed package); nothing is lost and a later call
148
152
  * retries only the failures. `signal` aborts this call's fetches.
149
153
  */
150
154
  async ensureScripts(presentation, callOptions) {
151
155
  if (disposed) throw gone();
152
- const analysis = analyzePresentationScripts(presentation), selection = scriptSelectionOf(analysis);
156
+ const analysis = analyzePresentationScripts(presentation, undefined, renderOptionsFor(callOptions)), selection = scriptSelectionOf(analysis);
153
157
  const loadedNow = [], failed = [];
154
158
  const attempt = async (item) => {
155
159
  try { loadedNow.push(...(await loadPackageList([item], callOptions))); }
@@ -166,9 +170,9 @@ export async function loadBrowserFontRegistry(entries, options = {}) {
166
170
  }
167
171
  return { ...selection, loaded: loadedNow, uncovered: uncoveredCjkCharacters(analysis, { covers }) };
168
172
  },
169
- /** Synchronous: script-pack packages the presentation needs that are not loaded yet. Empty means `ensureScripts` would fetch nothing. */
170
- pendingScripts(presentation) {
171
- const analysis = analyzePresentationScripts(presentation);
173
+ /** Synchronous: script-pack packages the presentation needs that are not loaded yet. Empty means `ensureScripts` would fetch nothing. `renderOptions` are the `renderSvg` options the document resolves with (`catalogs`, ...). */
174
+ pendingScripts(presentation, renderOptions) {
175
+ const analysis = analyzePresentationScripts(presentation, undefined, renderOptionsFor(renderOptions));
172
176
  const primary = scriptFontPackages(scriptSelectionOf(analysis).scripts).map((item) => item.name).filter((name) => !scriptPackages.has(name));
173
177
  if (primary.length) return primary;
174
178
  const next = nextFallbackPackage(analysis, { covers, loaded: scriptPackages });
@@ -181,8 +185,9 @@ export async function loadBrowserFontRegistry(entries, options = {}) {
181
185
  const lazy = lazyFontList(), lazyLoaded = new Set();
182
186
  const policy = options.substitutionPolicy ?? "none";
183
187
  const aliasTargets = new Map(Object.entries(options.aliases ?? {}).map(([from, to]) => [from.toLowerCase(), to]));
184
- const hasFamily = (family) => registry.describeFaces().some((face) => face.family.toLowerCase() === String(family).toLowerCase());
185
- const neededLazy = (presentation) => lazyFontsFor(presentationFamilies(presentation), { lazy, hasFamily, loaded: lazyLoaded, policy, aliases: aliasTargets });
188
+ // Face level (FF-41): the faces the document draws, resolved as the registry resolves them with every vendored face loaded.
189
+ // A document that does not resolve throws what `renderSvg` throws for it.
190
+ const neededLazy = (presentation, callOptions) => lazyFacesNeeded(presentation, renderOptionsFor(callOptions), { lazy, held: registry.describeFaces(), loaded: lazyLoaded, policy, aliases: aliasTargets, fallbackFamily: options.fallbackFamily });
186
191
  const lazyBaseUrl = () => {
187
192
  if (typeof options.lazyFontsBaseUrl !== "string" || !options.lazyFontsBaseUrl)
188
193
  throw new OPFFontError("invalid-font-source", "Loading vendored fonts requires lazyFontsBaseUrl, where the host serves the package's fonts directory.");
@@ -192,16 +197,19 @@ export async function loadBrowserFontRegistry(entries, options = {}) {
192
197
  /** Every vendored face a host can load on demand: family, style, package-relative file and pinned sha256. */
193
198
  lazyFonts: lazy,
194
199
  /**
195
- * Load the vendored faces the presentation's font families resolve to under the registry's substitution policy (nothing
196
- * is downloaded for a family the policy would not resolve), once each. All or nothing: on any failure (fetch, hash,
197
- * FontFace.load, registry) the document and registry are unchanged and the call can be retried. Cheap when nothing is
198
- * needed. Call it before measuring a document, and again after edits that change fonts. Resolves with the faces added.
200
+ * Load the vendored faces the presentation draws (FF-41: only the faces, by family, weight and style, that the renderer
201
+ * draws; a bold or italic run added by an edit adds just that face) as the registry resolves them under its substitution
202
+ * policy (nothing is downloaded for a family the policy would not resolve), once each. All or nothing: on any failure
203
+ * (fetch, hash, FontFace.load, registry) the document and registry are unchanged and the call can be retried. Cheap when
204
+ * nothing is needed. Call it before measuring a document, and again after edits that change fonts. Resolves with the faces
205
+ * added. Besides `signal`, the call takes the `renderSvg` options the document resolves with (`catalogs`, ...) over the
206
+ * loader's `renderOptions`; a document that does not resolve rejects with what `renderSvg` throws for it.
199
207
  */
200
208
  ensureLazyFonts(presentation, callOptions = {}) {
201
209
  const result = queue.then(async () => {
202
210
  if (disposed) throw gone();
203
211
  callOptions.signal?.throwIfAborted?.();
204
- const pending = neededLazy(presentation);
212
+ const pending = neededLazy(presentation, callOptions);
205
213
  if (!pending.length) return [];
206
214
  const fetched = (await fetchFaces(lazyFontEntries({ baseUrl: lazyBaseUrl() }, pending), callOptions.signal)).map((entry) => ({ ...entry, embed: "used" }));
207
215
  let browserFaces;
@@ -229,8 +237,8 @@ export async function loadBrowserFontRegistry(entries, options = {}) {
229
237
  queue = result.catch(() => {});
230
238
  return result;
231
239
  },
232
- /** Synchronous: the vendored faces the presentation needs under the registry's policy that are not loaded yet. Empty means `ensureLazyFonts` fetches nothing. */
233
- pendingLazyFonts: (presentation) => neededLazy(presentation),
240
+ /** Synchronous: the vendored faces the presentation draws under the registry's policy that are not loaded yet. Empty means `ensureLazyFonts` fetches nothing. `renderOptions` are the `renderSvg` options the document resolves with (`catalogs`, ...). */
241
+ pendingLazyFonts: (presentation, renderOptions) => neededLazy(presentation, renderOptions),
234
242
  });
235
243
  /** Names of the script-pack packages loaded so far. */
236
244
  Object.defineProperty(registry, "loadedScriptPackages", { get: () => [...scriptPackages], enumerable: true });
@@ -1,4 +1,5 @@
1
1
  import type {FontRegistry,FontRegistryOptions,EmbeddedFont,FontFaceInput,ScriptFontProfile} from "./fonts.js";
2
+ import type {RenderSvgOptions} from "./svg.js";
2
3
  /** A caller-supplied face (FF-31), for example a licensed copy of the real font. Node callers may pass a file path. */
3
4
  export type CallerFontFace = FontFaceInput | (Omit<FontFaceInput, "data"> & {path: string});
4
5
  /** "all" or ISO 15924 codes (`Jpan`, `Hans`, `Hant`, `Kore`, `Arab`, `Hebr`, `Deva`, `Thai`, ...). */
@@ -15,8 +16,9 @@ export interface AutoScriptSelection {
15
16
  /** Script-pack packages that `scripts: "auto"` loaded or found not installed. */
16
17
  export interface AppliedScriptSelection extends AutoScriptSelection { packages: string[]; notInstalled: string[]; /** Drawn CJK characters no loaded face covers (glyph fallback stopped at its cap). */ uncovered?: string[] }
17
18
  /** Script keys whose faces a preview of the presentation needs: text decides, the document language only tells Han scripts apart. */
18
- export declare function detectPresentationScripts(presentation: unknown, options?: { profile?: ScriptFontProfile }): string[];
19
- export declare function autoScriptSelection(presentation: unknown, options?: { profile?: ScriptFontProfile }): AutoScriptSelection;
19
+ /** `options` may also carry the `renderSvg` options the document resolves with (`catalogs`): a font scheme that exists only in the host's catalogs and names a script font selects that font's script (FF-41). */
20
+ export declare function detectPresentationScripts(presentation: unknown, options?: { profile?: ScriptFontProfile } & Partial<RenderSvgOptions>): string[];
21
+ export declare function autoScriptSelection(presentation: unknown, options?: { profile?: ScriptFontProfile } & Partial<RenderSvgOptions>): AutoScriptSelection;
20
22
  export interface ScriptPackOptions {
21
23
  /** Caller-supplied faces, loaded first so a licensed real font resolves as an exact face (FF-31). */
22
24
  faces?: readonly CallerFontFace[];
@@ -28,6 +30,8 @@ export interface ScriptPackOptions {
28
30
  scripts?: ScriptSelection | "auto";
29
31
  /** The presentation whose text decides `scripts: "auto"`. */
30
32
  presentation?: unknown;
33
+ /** The `renderSvg` options the presentation resolves with (`catalogs`, ...), for the font schemes `scripts: "auto"` reads (FF-41). */
34
+ renderOptions?: Partial<RenderSvgOptions>;
31
35
  /** Receives `script-font-unavailable` and `script-font-not-installed` for `scripts: "auto"`. */
32
36
  onDiagnostic?: (diagnostic: {code: string; message: string; script?: string; package?: string; scripts?: string[]}) => void;
33
37
  }
@@ -78,9 +78,10 @@ const loadPack = pack => loadPackages(BUNDLED_FONT_MANIFEST.packages.filter(item
78
78
  /**
79
79
  * Adds the script pack. `scripts` is 'all', ISO 15924 codes, or 'auto' (FF-19): detect the scripts the
80
80
  * `presentation` draws and load only their faces. Auto never fails on a missing optional package or a
81
- * script no pinned font serves; it reports them through `onDiagnostic` and `selection`.
81
+ * script no pinned font serves; it reports them through `onDiagnostic` and `selection`. `renderOptions` are the host's `renderSvg`
82
+ * options (see `analyzePresentationScripts`).
82
83
  */
83
- async function withScripts(loaded, scripts, {presentation, onDiagnostic} = {}) {
84
+ async function withScripts(loaded, scripts, {presentation, onDiagnostic, renderOptions} = {}) {
84
85
  if (scripts === undefined || (Array.isArray(scripts) && !scripts.length)) return loaded;
85
86
  // A package the registry already loaded (the default Noto Sans fallback) is never loaded twice.
86
87
  const notLoaded = list => list.filter(pkg => !loaded.packages?.includes(pkg.name));
@@ -89,7 +90,7 @@ async function withScripts(loaded, scripts, {presentation, onDiagnostic} = {}) {
89
90
  return {...loaded, entries:[...loaded.entries, ...embedUsed(extra.entries)], fontFiles:[...loaded.fontFiles, ...extra.fontFiles], packages:[...(loaded.packages ?? []), ...extra.packages]};
90
91
  }
91
92
  if (presentation === null || typeof presentation !== "object") throw new OPFFontError("invalid-font-scripts", "scripts: 'auto' needs the presentation whose text decides the scripts.", {scripts});
92
- const selection = scriptSelectionOf(analyzePresentationScripts(presentation)), skipped = [];
93
+ const selection = scriptSelectionOf(analyzePresentationScripts(presentation, undefined, renderOptions)), skipped = [];
93
94
  const extra = await loadPackages(notLoaded(scriptFontPackages(selection.scripts)), skipped);
94
95
  for (const script of selection.unavailable) onDiagnostic?.({code: "script-font-unavailable", script, message: `No pinned open font serves script '${script}'; that text uses the design font.`});
95
96
  for (const item of skipped) onDiagnostic?.({code: "script-font-not-installed", package: item.package, scripts: item.scripts, message: `Install ${item.package}@${item.version} to preview ${item.scripts.join(", ")} text with its designated open font.`});
@@ -102,12 +103,12 @@ async function withScripts(loaded, scripts, {presentation, onDiagnostic} = {}) {
102
103
  * has (Japanese-only kanji in a Simplified Chinese run, hanja) needs the next CJK face along the fallback chain.
103
104
  * Adds those packages to the registry and its fontFiles until every such character is covered or the chain ends.
104
105
  */
105
- async function completeFallback(registry, presentation, loaded, onDiagnostic) {
106
+ async function completeFallback(registry, presentation, loaded, onDiagnostic, renderOptions) {
106
107
  const selection = loaded.selection;
107
108
  if (!selection) return;
108
109
  const seen = new Set([...selection.packages, ...selection.notInstalled]);
109
110
  for (;;) {
110
- const analysis = analyzePresentationScripts(presentation), covers = character => registry.scriptFacesCover(character);
111
+ const analysis = analyzePresentationScripts(presentation, undefined, renderOptions), covers = character => registry.scriptFacesCover(character);
111
112
  const next = nextFallbackPackage(analysis, {covers, loaded: seen});
112
113
  if (!next) {
113
114
  const uncovered = uncoveredCjkCharacters(analysis, {covers, limit: 8});
@@ -152,17 +153,17 @@ async function withFaces(loaded, faces) {
152
153
  }
153
154
 
154
155
  /** Bundled, openly licensed faces. No system font discovery or network requests. */
155
- export async function loadBundledFontRegistry({scripts, faces, presentation, onDiagnostic, ...options} = {}) {
156
- const {entries, fontFiles, selection} = await withFaces(await withScripts(await loadPack("base"), scripts, {presentation, onDiagnostic}), faces);
156
+ export async function loadBundledFontRegistry({scripts, faces, presentation, onDiagnostic, renderOptions, ...options} = {}) {
157
+ const {entries, fontFiles, selection} = await withFaces(await withScripts(await loadPack("base"), scripts, {presentation, onDiagnostic, renderOptions}), faces);
157
158
  const registry = Object.assign(createFontRegistry(entries,options),{fontFiles, ...(selection ? {scriptSelection: selection} : {})});
158
159
  registry.lazyFonts = lazyOf(registry);
159
- await completeFallback(registry, presentation, {fontFiles, selection}, onDiagnostic);
160
+ await completeFallback(registry, presentation, {fontFiles, selection}, onDiagnostic, renderOptions);
160
161
  return registry;
161
162
  }
162
163
 
163
164
  /** Office substitutes plus the open families that font schemes select (FF-31),
164
165
  * optionally alongside the base Roboto pack. `includeOpenFonts: false` leaves the open families out. */
165
- export async function loadOfficeFontRegistry({scripts, faces, presentation, onDiagnostic, ...options} = {}) {
166
+ export async function loadOfficeFontRegistry({scripts, faces, presentation, onDiagnostic, renderOptions, ...options} = {}) {
166
167
  const {entries, fontFiles, packages} = await loadPack("office");
167
168
  for (const [include, pack] of [[options.includeOpenFonts, "open"], [options.includeBaseFonts, "base"]]) {
168
169
  if (include === false) continue;
@@ -177,17 +178,17 @@ export async function loadOfficeFontRegistry({scripts, faces, presentation, onDi
177
178
  // caller asked for it with scripts it is fallback-only, so no other family's preview changes. "auto" counts as asking for it when the
178
179
  // presentation itself selects Latn (a Sylfaen scheme: Noto Sans is its designated replacement, which a fallback-only face never is), as
179
180
  // an explicit list does; a presentation that does not select Latn keeps it fallback-only.
180
- const requested = scripts === "auto" && presentation !== null && typeof presentation === "object" ? scriptFontPackages(autoScriptSelection(presentation).scripts).map(pkg => pkg.name)
181
+ const requested = scripts === "auto" && presentation !== null && typeof presentation === "object" ? scriptFontPackages(autoScriptSelection(presentation, renderOptions).scripts).map(pkg => pkg.name)
181
182
  : scripts === undefined || scripts === "auto" || (Array.isArray(scripts) && !scripts.length) ? [] : scriptFontPackages(scripts).map(pkg => pkg.name);
182
183
  const fallback = await loadPackages(scriptFontPackages(["Latn"]).filter(pkg => !requested.includes(pkg.name)), undefined, {fallbackOnly:true});
183
184
  fontFiles.push(...fallback.fontFiles);
184
185
  entries.push(...fallback.entries);
185
186
  packages.push(...fallback.packages);
186
- const loaded = await withFaces(await withScripts({entries, fontFiles, packages}, scripts, {presentation, onDiagnostic}), faces);
187
+ const loaded = await withFaces(await withScripts({entries, fontFiles, packages}, scripts, {presentation, onDiagnostic, renderOptions}), faces);
187
188
  const aliases = options.includeOpenFonts === false ? options.aliases : {...renamedAliases(), ...options.aliases};
188
189
  const registry = Object.assign(createFontRegistry(loaded.entries,{substitutionPolicy:"metric",...options,...(aliases?{aliases}:{})}),{fontFiles:loaded.fontFiles, ...(loaded.selection ? {scriptSelection: loaded.selection} : {})});
189
190
  registry.lazyFonts = lazyOf(registry);
190
- await completeFallback(registry, presentation, loaded, onDiagnostic);
191
+ await completeFallback(registry, presentation, loaded, onDiagnostic, renderOptions);
191
192
  return registry;
192
193
  }
193
194
 
package/dist/fonts.js CHANGED
@@ -66,6 +66,62 @@ function skipUndecodableLookups(font) {
66
66
  return guarded;
67
67
  }
68
68
 
69
+ /**
70
+ * The face a registry draws for `family` (a validated concrete family: theme tokens are already resolved) in `style`, chosen
71
+ * from `faces` ({family, familyGroup, weight, italic, fallbackOnly?, scripts?}) under the substitution policy, aliases and
72
+ * fallback family, or an OPFFontError when none applies. Shared by the registry's resolution and by the lazy loader's face
73
+ * planning (lazy-fonts.js), so the browser loads exactly the faces Node would draw with everything loaded.
74
+ */
75
+ export function pickFace(faces, family, style, {policy = "none", aliases = new Map(), fallbackFamily} = {}) {
76
+ const findFamily = name => faces.filter(face=>face.family.toLowerCase()===name.toLowerCase() || face.familyGroup.toLowerCase()===name.toLowerCase());
77
+ const weight = style.fontWeight ?? 400;
78
+ let matching = findFamily(family), compatibility = "exact", rule, targetWeight = weight, styleFallback = false, via = "family";
79
+ const encodedSymbol = /^(wingdings(?: [23])?|webdings|symbol)$/i.test(family);
80
+ if (!matching.length && encodedSymbol) throw new OPFFontError("font-encoding-required", `Font '${family}' requires character mapping before substitution.`, {path:style.path,fontFamily:family});
81
+ if (!matching.length && aliases.has(family.toLowerCase())) {
82
+ matching = findFamily(aliases.get(family.toLowerCase())); compatibility = "visual"; via = "alias";
83
+ }
84
+ if (!matching.length && policy!=="none") {
85
+ const candidate = FONT_COMPATIBILITY.find(entry=>entry.requestedFamily.toLowerCase()===family.toLowerCase());
86
+ const tier = candidate?.compatibility==="metric" && !candidate.weights.includes(weight) ? "visual" : candidate?.compatibility;
87
+ // A declared visual replacement is used in visual mode, and in metric mode only when the
88
+ // policy keeps it as a metric-mode fallback (Cambria -> Caladea); it is still reported visual.
89
+ // In metric mode the fallback applies only where the pre-FF-31 metric rule did: the declared
90
+ // replacement at weights 400 and 700, normal or italic, with an exact face weight.
91
+ const metricFallback = policy!=="visual" && candidate?.metricModeFallback===true && [400,700].includes(weight);
92
+ const allowVisual = policy==="visual" || metricFallback;
93
+ if (candidate && (allowVisual || tier==="metric")) {
94
+ // A family that names its weight (Segoe UI Semibold, Arial Black) selects that weight in
95
+ // the replacement; its bold style link still selects bold (FF-31).
96
+ const wanted = candidate.weight ? (weight>=600 ? Math.max(candidate.weight,700) : candidate.weight) : weight;
97
+ for (const [index, substitute] of candidate.substitutes.entries()) {
98
+ // Only the declared replacement can carry the row's metric claim; an alternate is visual.
99
+ const substituteTier = index===0 ? tier : "visual";
100
+ if (substituteTier==="visual" && !(policy==="visual" || metricFallback && index===0)) continue;
101
+ const exactWeight = substituteTier==="metric" || policy!=="visual";
102
+ const pool = findFamily(substitute).filter(face=>!face.fallbackOnly && (!exactWeight || face.weight===weight));
103
+ let available = pool.filter(face=>face.italic===!!style.italic);
104
+ // Visual replacements without the requested style draw the other one and say so.
105
+ if (!available.length && substituteTier==="visual" && policy==="visual" && pool.length) { available = pool.filter(face=>!face.italic); styleFallback = available.length>0; }
106
+ if (available.length) { matching=available; compatibility=substituteTier; rule={...candidate, substituteIndex:index}; targetWeight=wanted; via="replacement"; break; }
107
+ }
108
+ }
109
+ }
110
+ // Equations require an explicit math-aware choice; never fall through to body text.
111
+ if (!matching.length && /^(cambria math|stix two math|noto sans math)$/i.test(family)) throw new OPFFontError("math-font-required", `Supply '${family}' or an explicit math-font alias.`, {path:style.path});
112
+ if (!matching.length && fallbackFamily) { matching=findFamily(fallbackFamily); compatibility="generic"; via="fallback"; }
113
+ if (!matching.length) throw unavailableFontError(family, style, policy);
114
+ const styled = styleFallback ? matching : matching.filter(face=>face.italic===!!style.italic);
115
+ // Script replacement faces have no italics; use upright glyphs and advances.
116
+ if (!styled.length && style.italic && matching.length && matching.every(face=>face.scripts)) { if (compatibility!=="generic") compatibility="visual"; }
117
+ else matching = styled;
118
+ if (!matching.length) throw new OPFFontError("font-style-unavailable", `No ${style.italic ? "italic" : "upright"} face for '${family}'.`, {path:style.path});
119
+ matching.sort((a,b)=>Math.abs(a.weight-targetWeight)-Math.abs(b.weight-targetWeight) || a.weight-b.weight);
120
+ const face = matching[0];
121
+ if ((face.weight!==targetWeight || styleFallback) && compatibility!=="generic") compatibility="visual";
122
+ return {face, compatibility, rule, styleFallback, via};
123
+ }
124
+
69
125
  export function createFontRegistry(entries, options = {}) {
70
126
  if (!Array.isArray(entries) || !entries.length) throw new OPFFontError("empty-font-registry", "Supply at least one font file.");
71
127
  const makeFace = entry => {
@@ -127,7 +183,6 @@ export function createFontRegistry(entries, options = {}) {
127
183
  const policy = options.substitutionPolicy ?? "none";
128
184
  if (!["none","metric","visual"].includes(policy)) throw new OPFFontError("invalid-font-policy", "substitutionPolicy must be none, metric, or visual.");
129
185
  if (options.fallbackFamily) validFamily(options.fallbackFamily);
130
- const findFamily = family => faces.filter(face=>face.family.toLowerCase()===family.toLowerCase() || face.familyGroup.toLowerCase()===family.toLowerCase());
131
186
  const resolve = style => {
132
187
  const requested = validFamily(style?.fontFamily);
133
188
  const weight = style.fontWeight ?? 400;
@@ -138,50 +193,7 @@ export function createFontRegistry(entries, options = {}) {
138
193
  const family = themeKey ? options.themeFonts?.[themeKey] : requested;
139
194
  if (!family || family.startsWith("+")) throw new OPFFontError("unresolved-theme-font", `Supply a concrete theme family for '${requested}'.`, {path:style.path});
140
195
  validFamily(family);
141
- let matching = findFamily(family), compatibility = "exact", rule, targetWeight = weight, styleFallback = false, via = "family";
142
- const encodedSymbol = /^(wingdings(?: [23])?|webdings|symbol)$/i.test(family);
143
- if (!matching.length && encodedSymbol) throw new OPFFontError("font-encoding-required", `Font '${family}' requires character mapping before substitution.`, {path:style.path,fontFamily:family});
144
- if (!matching.length && aliases.has(family.toLowerCase())) {
145
- matching = findFamily(aliases.get(family.toLowerCase())); compatibility = "visual"; via = "alias";
146
- }
147
- if (!matching.length && policy!=="none") {
148
- const candidate = FONT_COMPATIBILITY.find(entry=>entry.requestedFamily.toLowerCase()===family.toLowerCase());
149
- const tier = candidate?.compatibility==="metric" && !candidate.weights.includes(weight) ? "visual" : candidate?.compatibility;
150
- // A declared visual replacement is used in visual mode, and in metric mode only when the
151
- // policy keeps it as a metric-mode fallback (Cambria -> Caladea); it is still reported visual.
152
- // In metric mode the fallback applies only where the pre-FF-31 metric rule did: the declared
153
- // replacement at weights 400 and 700, normal or italic, with an exact face weight.
154
- const metricFallback = policy!=="visual" && candidate?.metricModeFallback===true && [400,700].includes(weight);
155
- const allowVisual = policy==="visual" || metricFallback;
156
- if (candidate && (allowVisual || tier==="metric")) {
157
- // A family that names its weight (Segoe UI Semibold, Arial Black) selects that weight in
158
- // the replacement; its bold style link still selects bold (FF-31).
159
- const wanted = candidate.weight ? (weight>=600 ? Math.max(candidate.weight,700) : candidate.weight) : weight;
160
- for (const [index, substitute] of candidate.substitutes.entries()) {
161
- // Only the declared replacement can carry the row's metric claim; an alternate is visual.
162
- const substituteTier = index===0 ? tier : "visual";
163
- if (substituteTier==="visual" && !(policy==="visual" || metricFallback && index===0)) continue;
164
- const exactWeight = substituteTier==="metric" || policy!=="visual";
165
- const faces = findFamily(substitute).filter(face=>!face.fallbackOnly && (!exactWeight || face.weight===weight));
166
- let available = faces.filter(face=>face.italic===!!style.italic);
167
- // Visual replacements without the requested style draw the other one and say so.
168
- if (!available.length && substituteTier==="visual" && policy==="visual" && faces.length) { available = faces.filter(face=>!face.italic); styleFallback = available.length>0; }
169
- if (available.length) { matching=available; compatibility=substituteTier; rule={...candidate, substituteIndex:index}; targetWeight=wanted; via="replacement"; break; }
170
- }
171
- }
172
- }
173
- // Equations require an explicit math-aware choice; never fall through to body text.
174
- if (!matching.length && /^(cambria math|stix two math|noto sans math)$/i.test(family)) throw new OPFFontError("math-font-required", `Supply '${family}' or an explicit math-font alias.`, {path:style.path});
175
- if (!matching.length && options.fallbackFamily) { matching=findFamily(options.fallbackFamily); compatibility="generic"; via="fallback"; }
176
- if (!matching.length) throw unavailableFontError(family, style, policy);
177
- const styled = styleFallback ? matching : matching.filter(face=>face.italic===!!style.italic);
178
- // Script replacement faces have no italics; use upright glyphs and advances.
179
- if (!styled.length && style.italic && matching.length && matching.every(face=>face.scripts)) { if (compatibility!=="generic") compatibility="visual"; }
180
- else matching = styled;
181
- if (!matching.length) throw new OPFFontError("font-style-unavailable", `No ${style.italic ? "italic" : "upright"} face for '${family}'.`, {path:style.path});
182
- matching.sort((a,b)=>Math.abs(a.weight-targetWeight)-Math.abs(b.weight-targetWeight) || a.weight-b.weight);
183
- const face = matching[0];
184
- if ((face.weight!==targetWeight || styleFallback) && compatibility!=="generic") compatibility="visual";
196
+ const {face, compatibility, rule, styleFallback, via} = pickFace(faces, family, style, {policy, aliases, fallbackFamily:options.fallbackFamily});
185
197
  // FF-31: `substitute` is true whenever the face is not the chosen family itself (policy
186
198
  // replacement, alias, script replacement or fallback). Exporters keep writing sourceFamily.
187
199
  const substitute = via!=="family", policyRow = substitute ? fontPolicyFor(family) : undefined;
@@ -1,52 +1,180 @@
1
1
  import { fontPolicyFor } from "./font-policy.js";
2
- import { resolvePresentation } from "./svg.js";
2
+ import { pickFace } from "./fonts.js";
3
+ import { renderSvgDeck, resolvePresentation } from "./svg.js";
3
4
  export { lazyFontEntries, lazyFontList } from "./lazy-font-list.js";
4
5
 
5
6
  /**
6
7
  * FF-31: the vendored faces (the open families and Intos) are not in a registry's eager list. Browser hosts load them
7
- * on demand, from separate hash-pinned files, when a document's font families resolve to them. This module holds the
8
- * pure parts (which faces exist, which a presentation needs); fonts-browser.js does the fetching.
8
+ * on demand, from separate hash-pinned files, when a document draws them. This module holds the pure parts (which faces
9
+ * exist, which a presentation needs); fonts-browser.js does the fetching.
10
+ *
11
+ * FF-41: the analysis is face level. `presentationFaces` lists the {family, weight, italic} the renderer draws (resolved as a
12
+ * registry resolves them, on request), `lazyFacesNeeded` picks the vendored faces among them a registry lacks, and
13
+ * `lazyFontsFor` selects the vendored faces a list of face requests resolves to; none of them loads whole families.
9
14
  */
10
15
 
11
16
  const lc = value => String(value).trim().toLowerCase();
12
17
 
13
- /** The font families a presentation's slides resolve (heading, body and code roles), or an empty set for a document that does not resolve. */
14
- export function presentationFamilies(presentation) {
18
+ /**
19
+ * Render options that decide how a document resolves (catalogs, validate, date, ...) are forwarded from the host's
20
+ * `renderSvg` options. The analysis supplies its own measurement and reports nothing: callbacks, the host's registry
21
+ * measurement, embedded fonts, asset resolution and tracing are dropped.
22
+ */
23
+ const NOT_RESOLVE_OPTIONS = new Set(["textMeasurement", "embeddedFonts", "fontFiles", "useBundledFonts", "loadSystemFonts", "onDiagnostic", "trace", "glyphFallback", "imageResolver", "strictAssets", "slideIndex", "signal"]);
24
+ function resolveOptionsOf(options) {
25
+ return Object.fromEntries(Object.entries(options ?? {}).filter(([key]) => !NOT_RESOLVE_OPTIONS.has(key)));
26
+ }
27
+
28
+ /**
29
+ * The font families a presentation's slides resolve (heading, body and code roles of every slide, including per-slide
30
+ * design overrides), as `renderSvg` resolves them with the same `options` (`catalogs`, ...). This is the role families,
31
+ * not what is drawn: use `presentationFaces` for that.
32
+ *
33
+ * A document that does not resolve throws the error `renderSvg` throws for it (an OPFRenderError such as
34
+ * `catalog-resolution-failed` for a layout id no catalog has, or `invalid-opf`), never an empty set: a document whose layout comes only from
35
+ * `options.catalogs` needs those catalogs here too.
36
+ */
37
+ export function presentationFamilies(presentation, options = {}) {
15
38
  const families = new Set();
16
- let resolved;
17
- try { resolved = resolvePresentation(presentation, {}); } catch { return families; }
39
+ const resolved = resolvePresentation(presentation, resolveOptionsOf(options));
18
40
  for (const slide of resolved.slides) for (const family of Object.values(slide.design?.fonts ?? {})) if (typeof family === "string" && family) families.add(family);
19
41
  return families;
20
42
  }
21
43
 
44
+ // A measurement that costs nothing to overflow: text is measured as very narrow, so layout never fails on text that
45
+ // only the real faces would fit. Only the styles it is asked about matter.
46
+ const RECORDED_EM = 0.01;
47
+
48
+ /** The registry's view with faces added: what a registry holding `faces` resolves families to (`pickFace`, its own selection). */
49
+ function registryView({ faces, policy = "none", aliases = new Map(), fallbackFamily }) {
50
+ const pool = [...faces].map(face => ({ ...face, familyGroup: face.familyGroup ?? face.family }));
51
+ return style => {
52
+ const family = style?.fontFamily;
53
+ const { face } = pickFace(pool, family, style, { policy, aliases, fallbackFamily });
54
+ return { ...style, fontFamily: face.family, fontWeight: face.weight, italic: face.italic };
55
+ };
56
+ }
57
+
58
+ /**
59
+ * The faces a preview of the presentation draws, as `[{family, weight, italic}]`, sorted by family, style and weight. It is
60
+ * the renderer's own layout and painting run with a recording measurement, so it follows exactly what `renderSvg` measures
61
+ * and draws: the heading and body roles of every slide that draws them, bold and italic runs of rich text, table and cell
62
+ * styles, header and footer furniture, chart labels, metrics, quotes and timelines, the code role only where code is
63
+ * drawn, per-slide design overrides, font scheme and theme resolution, and script slots (a script run is requested in its
64
+ * slot's family). A role the document does not draw is not requested.
65
+ *
66
+ * Without `registry` the faces are the styles as the document names them: the family a role resolves to, with the weight
67
+ * a use asks for (700 for headings and bold runs, 400 for body, 500 to 800 where a design asks).
68
+ *
69
+ * With `registry` (`{faces, policy, aliases, fallbackFamily}`: the faces a registry holds or could hold, `{family, weight,
70
+ * italic}`, and its substitution options) every style resolves as that registry resolves it (`pickFace`, the registry's own
71
+ * selection: the family or its alias or policy replacement, italic before weight, the nearest weight, ties to the
72
+ * lighter), in the two steps drawing takes: a role's family resolves first, then each use's weight resolves within the
73
+ * family found. The result is the faces the registry paints, for example `Intos 700` for a 600-weight label of an Aptos
74
+ * deck. A style the registry cannot resolve is left as named (drawing reports it).
75
+ *
76
+ * `options` are the `renderSvg` options the document resolves with (`catalogs`, `date`, `validate`, ...). A document that
77
+ * does not resolve throws what `renderSvg` throws for it.
78
+ * @param {unknown} presentation
79
+ * @param {object} [options]
80
+ * @param {{faces: Iterable<{family: string, weight: number, italic: boolean}>, policy?: "none"|"metric"|"visual", aliases?: ReadonlyMap<string, string>, fallbackFamily?: string}} [registry]
81
+ * @returns {{family: string, weight: number, italic: boolean}[]}
82
+ */
83
+ export function presentationFaces(presentation, options = {}, registry) {
84
+ const seen = new Map();
85
+ const resolve = registry ? registryView(registry) : undefined;
86
+ const settle = style => {
87
+ if (!resolve) return { ...style };
88
+ try { return resolve(style); } catch { return { ...style }; }
89
+ };
90
+ const record = style => {
91
+ const family = style?.fontFamily;
92
+ if (typeof family !== "string" || !family.trim() || family.startsWith("+")) return;
93
+ const weight = Number.isFinite(style.fontWeight) ? style.fontWeight : 400, italic = style.italic === true;
94
+ const key = `${lc(family)}\u0000${weight}\u0000${italic}`;
95
+ if (!seen.has(key)) seen.set(key, { family, weight, italic });
96
+ };
97
+ const textMeasurement = {
98
+ measure(text, size, style) { if (text !== "") record(settle(style)); return String(text).length * size * RECORDED_EM; },
99
+ resolveStyle: settle,
100
+ };
101
+ renderSvgDeck(presentation, { ...resolveOptionsOf(options), textMeasurement });
102
+ return [...seen.values()].sort((a, b) => a.family < b.family ? -1 : a.family > b.family ? 1 : Number(a.italic) - Number(b.italic) || a.weight - b.weight);
103
+ }
104
+
105
+ /**
106
+ * The vendored faces (of `lazy`) a presentation draws that the registry does not hold: what a registry loads before it can
107
+ * paint the document as Node paints it with every vendored face loaded. `held` lists the registry's faces
108
+ * (`registry.describeFaces()`), `loaded` the lazy files already loaded; `policy`, `aliases` and `fallbackFamily` are the
109
+ * registry's. `renderOptions` are the `renderSvg` options the document resolves with (`catalogs`, ...), and a document that
110
+ * does not resolve throws what `renderSvg` throws for it. Face level: a bold or italic run added by an edit adds just that face.
111
+ */
112
+ export function lazyFacesNeeded(presentation, renderOptions, { lazy, held, loaded = new Set(), policy = "none", aliases = new Map(), fallbackFamily }) {
113
+ const heldFaces = [...held], heldKeys = new Set(heldFaces.map(faceKeyOf));
114
+ const missing = lazy.filter(face => !heldKeys.has(faceKeyOf(face)) && !loaded.has(face.file));
115
+ const drawn = new Set(presentationFaces(presentation, renderOptions, { faces: [...heldFaces, ...missing], policy, aliases, fallbackFamily }).map(faceKeyOf));
116
+ return missing.filter(face => drawn.has(faceKeyOf(face)));
117
+ }
118
+ function faceKeyOf(face) { return `${lc(face.family)}\u0000${face.weight}\u0000${!!face.italic}`; }
119
+
22
120
  /**
23
121
  * The vendored faces a document needs that the registry does not hold, following the registry's own order and substitution
24
122
  * policy: the family itself, an alias target, then (unless the policy is "none") the declared replacement and, under
25
- * "visual", its alternates. The first candidate the registry already has ends the search (it will resolve to that); the
26
- * first vendored candidate is needed, whole family, so the browser resolves exactly what Node resolves with everything
27
- * loaded, and nothing is downloaded for a family the policy would not resolve.
28
- * @param {Iterable<string>} families families the document resolves
29
- * @param {{lazy: readonly object[], hasFamily: (family: string) => boolean, loaded?: ReadonlySet<string>, policy?: "none"|"metric"|"visual", aliases?: ReadonlyMap<string, string>}} context
123
+ * "visual", its alternates.
124
+ *
125
+ * Entries are face requests `{family, weight, italic}` (from `presentationFaces`) or family names. A face request is
126
+ * resolved exactly as the registry resolves it with every vendored face loaded (`pickFace`, the registry's own selection,
127
+ * over the faces it holds plus the vendored ones): the vendored face it lands on is needed if the registry does not hold
128
+ * it, so a partially loaded family still loads its missing faces, and a face is never fetched for a weight or style the
129
+ * document does not draw. A family name means every face of the family the route resolves to (the whole family, as
130
+ * before FF-41). Nothing is downloaded for a family the policy would not resolve.
131
+ * @param {Iterable<string | {family: string, weight?: number, italic?: boolean}>} entries
132
+ * @param {{lazy: readonly object[], held?: Iterable<{family: string, weight: number, italic: boolean}>, hasFamily?: (family: string) => boolean, loaded?: ReadonlySet<string>, policy?: "none"|"metric"|"visual", aliases?: ReadonlyMap<string, string>, fallbackFamily?: string}} context
133
+ * `held` lists the registry's faces (`registry.describeFaces()`); it is what makes the check face level. Without it a face
134
+ * request falls back to family level with `hasFamily` (a held family needs nothing).
30
135
  */
31
- export function lazyFontsFor(families, { lazy, hasFamily, loaded = new Set(), policy = "visual", aliases = new Map() }) {
136
+ export function lazyFontsFor(entries, { lazy, held, hasFamily, loaded = new Set(), policy = "visual", aliases = new Map(), fallbackFamily }) {
32
137
  const byFamily = new Map();
33
138
  for (const face of lazy) { const list = byFamily.get(lc(face.family)) ?? []; list.push(face); byFamily.set(lc(face.family), list); }
34
- const needed = new Set();
35
- for (const family of families) {
36
- if (hasFamily(family)) continue;
37
- if (byFamily.has(lc(family))) { needed.add(lc(family)); continue; }
139
+ const heldFaces = held === undefined ? undefined : [...held];
140
+ const faceKey = face => `${lc(face.family)}\u0000${face.weight}\u0000${!!face.italic}`;
141
+ const heldKeys = new Set((heldFaces ?? []).map(faceKey));
142
+ const has = hasFamily ?? (family => (heldFaces ?? []).some(face => lc(face.family) === lc(family)));
143
+ const needed = new Set(), neededFiles = new Set();
144
+ // Whole families (a family name, or a face request without a face-level registry view).
145
+ const addFamily = family => {
146
+ // A family the registry holds only in part still needs its other vendored faces (face level, when the registry's faces are known).
147
+ if (has(family)) { if (heldFaces && byFamily.has(lc(family))) needed.add(lc(family)); return; }
148
+ if (byFamily.has(lc(family))) { needed.add(lc(family)); return; }
38
149
  const target = aliases.get(lc(family));
39
- if (target && (hasFamily(target) || byFamily.has(lc(target)))) { if (!hasFamily(target)) needed.add(lc(target)); continue; }
40
- if (policy === "none") continue;
150
+ if (target && (has(target) || byFamily.has(lc(target)))) { if (byFamily.has(lc(target)) && (!has(target) || heldFaces)) needed.add(lc(target)); return; }
151
+ if (policy === "none") return;
41
152
  const row = fontPolicyFor(family), replacement = row?.replacement;
42
153
  const route = [];
43
154
  // Metric mode uses a metric replacement, or a visual one its policy row keeps as a metric-mode fallback; alternates are visual only.
44
155
  if (replacement && (policy === "visual" || replacement.compatibility === "metric" || replacement.metricModeFallback)) route.push(replacement.family);
45
156
  if (policy === "visual") route.push(...(row?.alternates ?? []));
46
157
  for (const candidate of route) {
47
- if (hasFamily(candidate)) break;
158
+ if (has(candidate)) { if (heldFaces && byFamily.has(lc(candidate))) needed.add(lc(candidate)); break; }
48
159
  if (byFamily.has(lc(candidate))) { needed.add(lc(candidate)); break; }
49
160
  }
161
+ };
162
+ // The registry's view with every vendored face added: what Node draws with everything loaded.
163
+ let pool;
164
+ const universe = () => pool ??= [
165
+ ...heldFaces.map(face => ({ ...face, familyGroup: face.family })),
166
+ ...lazy.filter(face => !heldKeys.has(faceKey(face)) && !loaded.has(face.file)).map(face => ({ family: face.family, familyGroup: face.family, weight: face.weight, italic: !!face.italic, lazy: face })),
167
+ ];
168
+ for (const entry of entries) {
169
+ if (typeof entry === "string") { addFamily(entry); continue; }
170
+ if (heldFaces === undefined) { addFamily(entry.family); continue; }
171
+ let picked;
172
+ try { picked = pickFace(universe(), entry.family, { fontFamily: entry.family, fontWeight: entry.weight ?? 400, italic: entry.italic === true }, { policy, aliases, fallbackFamily }); }
173
+ // A face the registry cannot resolve even with every vendored face (unknown family, symbol font, ...) needs nothing here: drawing reports it.
174
+ catch { continue; }
175
+ if (picked.face.lazy) neededFiles.add(picked.face.lazy.file);
50
176
  }
51
- return [...needed].flatMap(family => byFamily.get(family)).filter(face => !loaded.has(face.file));
177
+ const all = [...needed].flatMap(family => byFamily.get(family) ?? []);
178
+ for (const face of lazy) if (neededFiles.has(face.file)) all.push(face);
179
+ return [...new Set(all)].filter(face => !loaded.has(face.file) && !heldKeys.has(faceKey(face)));
52
180
  }
@@ -7,6 +7,7 @@ import * as scriptFontModule from "./script-fonts.js";
7
7
  import { scriptOfCharacter, scriptsOfText } from "./script-fonts.js";
8
8
  // Optional core exports are read from the namespace so an older published core still loads.
9
9
  import * as opfCore from "@openpresentation/opf";
10
+ import { presentationFamilies } from "./lazy-fonts.js";
10
11
 
11
12
  const SCRIPT_ALIASES = Object.freeze({Hira: "Jpan", Kana: "Jpan", Hrkt: "Jpan", Hang: "Kore", Hani: "Hans", Zyyy: "Latn"});
12
13
 
@@ -121,8 +122,12 @@ function scriptOfFamily(family) {
121
122
  * open replacement, so the face is needed even when no drawn character has the script. A slide may override the
122
123
  * scheme (`slide.design`), so each such slide is resolved too. `han` is the CJK script the scheme names, which tells
123
124
  * Han-only text apart when the language does not (a Yu Gothic deck whose language is English).
125
+ *
126
+ * `renderOptions` are the host's `renderSvg` options. Core resolves a font scheme id from the document and its bundled catalogs
127
+ * only, while the renderer also reads `renderOptions.catalogs`; when a host passes catalogs the families the renderer resolves
128
+ * per slide count too, so a scheme that exists only in the host's catalogs and names a script font still loads that font.
124
129
  */
125
- function designScripts(presentation, profile) {
130
+ function designScripts(presentation, profile, renderOptions) {
126
131
  const scripts = new Set();
127
132
  let han;
128
133
  const note = resolved => {
@@ -140,6 +145,9 @@ function designScripts(presentation, profile) {
140
145
  try { note(opfCore.resolveScriptFonts(presentation, { slideIndex })); } catch { /* the renderer reports it when it draws the slide */ }
141
146
  });
142
147
  }
148
+ if (renderOptions?.catalogs) {
149
+ for (const family of presentationFamilies(presentation, renderOptions)) note({ heading: { latin: family }, body: { latin: family } });
150
+ }
143
151
  return { scripts: [...scripts].sort(), han };
144
152
  }
145
153
 
@@ -147,10 +155,13 @@ function designScripts(presentation, profile) {
147
155
  * What a preview of the presentation draws: its script profile, the scripts of its text (the renderer's own
148
156
  * itemization, so language-dependent punctuation counts), the scripts its font schemes need (`design`, for example
149
157
  * Jpan for a Yu Gothic scheme) and the CJK characters that a glyph fallback might have to draw with another CJK face.
158
+ * The text decides, so a document is analyzed without resolving its layouts and catalogs; `renderOptions` (the host's
159
+ * `renderSvg` options) matter only for font schemes the host supplies in `renderOptions.catalogs`, which then resolve
160
+ * like the renderer resolves them; a document that then does not resolve throws what `renderSvg` throws for it.
150
161
  */
151
- export function analyzePresentationScripts(presentation, profile = presentationScriptProfile(presentation)) {
162
+ export function analyzePresentationScripts(presentation, profile = presentationScriptProfile(presentation), renderOptions) {
152
163
  const scripts = new Set(), cjk = new Set();
153
- const design = designScripts(presentation, profile);
164
+ const design = designScripts(presentation, profile, renderOptions);
154
165
  // 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
166
  const itemizing = design.han && !cjkKeys.includes(profile?.script) ? { ...profile, script: design.han } : profile;
156
167
  for (const text of drawnStrings(presentation)) {
@@ -166,10 +177,10 @@ export function analyzePresentationScripts(presentation, profile = presentationS
166
177
  * (Japanese, Korean, Simplified or Traditional Chinese) and makes curly quotes, dashes and the ellipsis
167
178
  * East Asian. Text the preview never draws (ids, alt text, sources, assets, catalogs, notes) is ignored.
168
179
  * Greek and Cyrillic add nothing: the office registry always carries Noto Sans (the glyph-fallback face) and Roboto covers them.
169
- * Pass `profile` to reuse a resolved profile.
180
+ * Pass `profile` to reuse a resolved profile. Other options are the host's `renderSvg` options (`catalogs`, see `analyzePresentationScripts`).
170
181
  */
171
- export function detectPresentationScripts(presentation, { profile } = {}) {
172
- return analyzePresentationScripts(presentation, profile).detected;
182
+ export function detectPresentationScripts(presentation, { profile, ...renderOptions } = {}) {
183
+ return analyzePresentationScripts(presentation, profile, renderOptions).detected;
173
184
  }
174
185
 
175
186
  /** The scripts of an analysis that a pinned pack serves (`scripts`, the input for `scriptFontPackages`) and those it does not. */
@@ -186,7 +197,8 @@ export function scriptSelectionOf(analysis) {
186
197
  * Cherokee or Tifinagh, which draw with whatever the design font covers).
187
198
  */
188
199
  export function autoScriptSelection(presentation, options) {
189
- return scriptSelectionOf(analyzePresentationScripts(presentation, options?.profile));
200
+ const { profile, ...renderOptions } = options ?? {};
201
+ return scriptSelectionOf(analyzePresentationScripts(presentation, profile, renderOptions));
190
202
  }
191
203
 
192
204
  /**
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.4",
3
+ "version": "0.11.6",
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-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",
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/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-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/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/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",
@@ -96,7 +96,7 @@
96
96
  "@expo-google-fonts/roboto": "0.4.3",
97
97
  "@expo-google-fonts/roboto-mono": "0.4.2",
98
98
  "@expo-google-fonts/tinos": "0.4.2",
99
- "@openpresentation/opf": "^0.11.2",
99
+ "@openpresentation/opf": "^0.11.3",
100
100
  "@resvg/resvg-js": "^2.6.2",
101
101
  "fontkit": "^2.0.4",
102
102
  "pdf-lib": "^1.17.1",