@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 +6 -2
- package/dist/charts.js +260 -30
- package/dist/fonts-browser.d.ts +56 -15
- package/dist/fonts-browser.js +24 -16
- package/dist/fonts-node.d.ts +6 -2
- package/dist/fonts-node.js +13 -12
- package/dist/fonts.js +57 -45
- package/dist/lazy-fonts.js +149 -21
- package/dist/script-font-pack.js +19 -7
- package/dist/svg.js +1 -1
- package/package.json +4 -4
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
|
|
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
|
-
|
|
172
|
-
if (!(rect.width > 0 && rect.height > 0)) return;
|
|
173
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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 -
|
|
626
|
-
width: labelWidth, height
|
|
846
|
+
y: Math.abs(sin) < 0.2 ? ly - height / 2 : sin < 0 ? ly - height : ly,
|
|
847
|
+
width: labelWidth, height
|
|
627
848
|
};
|
|
628
|
-
|
|
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
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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);
|
package/dist/fonts-browser.d.ts
CHANGED
|
@@ -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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
|
|
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
|
|
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(
|
|
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[];
|
package/dist/fonts-browser.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { createFontRegistry, OPFFontError } from "./fonts.js";
|
|
2
2
|
import { lazyFontEntries, lazyFontList } from "./lazy-font-list.js";
|
|
3
|
-
import {
|
|
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).
|
|
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
|
-
|
|
185
|
-
|
|
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
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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
|
|
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 });
|
package/dist/fonts-node.d.ts
CHANGED
|
@@ -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
|
-
|
|
19
|
-
export declare function
|
|
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
|
}
|
package/dist/fonts-node.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/lazy-fonts.js
CHANGED
|
@@ -1,52 +1,180 @@
|
|
|
1
1
|
import { fontPolicyFor } from "./font-policy.js";
|
|
2
|
-
import {
|
|
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
|
|
8
|
-
*
|
|
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
|
-
/**
|
|
14
|
-
|
|
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
|
-
|
|
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.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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(
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 && (
|
|
40
|
-
if (policy === "none")
|
|
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 (
|
|
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
|
-
|
|
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
|
}
|
package/dist/script-font-pack.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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",
|