@openpresentation/opf-render 0.17.1 → 0.18.1

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.
@@ -1,9 +1,9 @@
1
1
  // RR-28: the part of the slideshow player and the <opf-deck> element that has no DOM of its own. It reads an OPF document,
2
- // decides which slides play (hidden ones do not), renders each slide once with the renderer's own SVG output (renderSlideSvg:
2
+ // decides which slides play (hidden ones do not), renders each slide once with the renderer's own SVG output (toSvg:
3
3
  // there is no second layout engine) behind a face-level font gate, and synchronizes windows over a BroadcastChannel.
4
4
  // Nothing here fetches anything but the font files the host's font root serves.
5
5
  import { resolveSlideVariables } from "@openpresentation/opf";
6
- import { renderSlideSvg } from "./svg.js";
6
+ import { toSvg } from "./svg.js";
7
7
 
8
8
  /** Error raised for a deck that cannot be read, rendered or loaded. */
9
9
  export class DeckError extends Error {
@@ -206,7 +206,7 @@ export function createFontGate(fonts) {
206
206
 
207
207
  /**
208
208
  * One deck's rendering, shared by the element, the full-screen player and the speaker view: font loading once, each slide
209
- * rendered once by `renderSlideSvg` and kept, so moving between slides never draws twice and every surface shows the same markup.
209
+ * rendered once by `toSvg` and kept, so moving between slides never draws twice and every surface shows the same markup.
210
210
  *
211
211
  * `fonts` is a browser fonts handle (or nothing: layout then uses estimated widths and the visitor's system sans-serif).
212
212
  */
@@ -237,7 +237,7 @@ export function createDeckStore({ document, fonts, renderOptions = {}, date } =
237
237
  let svg = cache.get(index);
238
238
  if (svg === undefined) {
239
239
  try {
240
- svg = renderSlideSvg(deck, index, { ...options(), validate: !validated });
240
+ svg = toSvg(deck, index + 1, { ...options(), validate: !validated });
241
241
  } catch (cause) {
242
242
  if (cause instanceof DeckError) throw cause;
243
243
  // A document the schema rejects says where: the first finding's path and message.
package/dist/element.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { BrowserFontsHandle } from "./fonts-browser.js";
2
2
  import type { RenderFonts } from "./fonts.js";
3
- import type { RenderSvgOptions } from "./svg.js";
3
+ import type { SlideSelection, ToSvgOptions } from "./svg.js";
4
4
  import type { PlayerSession, PresentOptions, PlayerSlideChange } from "./player.js";
5
5
  export { DeckError } from "./player.js";
6
6
  export { loadPreviewFonts, previewBaseFaces, previewFontLayout } from "./preview-fonts.js";
@@ -43,8 +43,8 @@ export interface OpfDeckElement extends HTMLElement {
43
43
  * attribute's URL (an empty string without either).
44
44
  */
45
45
  fonts: string | BrowserFontsHandle;
46
- /** Extra `renderSlideSvg` options (catalogs, imageResolver, date, ...). Set before the deck loads, or call `reload()`. */
47
- renderOptions: Partial<RenderSvgOptions>;
46
+ /** Extra `toSvg` options (catalogs, imageResolver, date, ...). Set before the deck loads, or call `reload()`. */
47
+ renderOptions: Partial<ToSvgOptions>;
48
48
  includeHidden: boolean;
49
49
  thumbnails: boolean;
50
50
  readonly ready: Promise<void>;
@@ -66,10 +66,8 @@ export declare function getOpfDeckElement(): { new (): OpfDeckElement; readonly
66
66
  /** Register `<opf-deck>` (or another tag name). Safe to call twice and on a server, where it does nothing. */
67
67
  export declare function defineOpfDeck(tagName?: string): CustomElementConstructor | undefined;
68
68
 
69
- export interface RenderDeckHtmlOptions {
69
+ export interface ToHtmlOptions {
70
70
  tagName?: string;
71
- /** `"first"` (default: the first slide and a list of titles), `"all"`, or 1-based slide numbers. */
72
- slides?: "first" | "all" | readonly number[];
73
71
  includeHidden?: boolean;
74
72
  src?: string;
75
73
  slide?: number;
@@ -86,11 +84,16 @@ export interface RenderDeckHtmlOptions {
86
84
  embed?: boolean;
87
85
  /** Today's date (ISO) for `date: true` furniture. */
88
86
  date?: string;
89
- renderOptions?: Partial<RenderSvgOptions>;
87
+ renderOptions?: Partial<ToSvgOptions>;
90
88
  }
91
89
 
92
- /** Server-side markup: the deck's slides as inline SVG inside the tag, shown as is without JavaScript and replaced when the element upgrades. */
93
- export declare function renderDeckHtml(document: unknown, options?: RenderDeckHtmlOptions): string;
90
+ /**
91
+ * Server-side markup (RR-73): the deck's slides as inline SVG inside the tag, shown as is without JavaScript and replaced when the
92
+ * element upgrades. `toHtml(deck)` draws the first slide and a list of titles; `toHtml(deck, 3)`, `toHtml(deck, "1-3")` (`"1-"` for
93
+ * every slide) or `toHtml(deck, [1, 3])` draw those slides of the presented sequence, counting from 1.
94
+ */
95
+ export declare function toHtml(document: unknown, options?: ToHtmlOptions): string;
96
+ export declare function toHtml(document: unknown, slides: SlideSelection, options?: ToHtmlOptions): string;
94
97
 
95
98
  declare global {
96
99
  interface HTMLElementTagNameMap { "opf-deck": OpfDeckElement }
package/dist/element.js CHANGED
@@ -1,4 +1,4 @@
1
- // RR-28: <opf-deck>, a framework-free web component that embeds an OPF deck. It draws the renderer's own SVG (renderSvg), so
1
+ // RR-28: <opf-deck>, a framework-free web component that embeds an OPF deck. It draws the renderer's own SVG (toSvg), so
2
2
  // the slide a page shows is the slide the preview, the editor and the PDF show. Importing this module touches no DOM, so it
3
3
  // is safe on a server (Next.js); `defineOpfDeck()` registers the tag in a browser.
4
4
  import {
@@ -6,8 +6,9 @@ import {
6
6
  prepareSlideSvg, sanitizeSvgTree, slideInfo, slideLabel, svgDimensions, plainText,
7
7
  } from "./deck-runtime.js";
8
8
  import { loadPreviewFonts } from "./preview-fonts.js";
9
- import { renderSlideSvg } from "./svg.js";
9
+ import { toSvg } from "./svg.js";
10
10
  import { drawnFaces } from "./drawn-faces.js";
11
+ import { selectSlides, slideArguments } from "./slide-sources.js";
11
12
 
12
13
  export { DeckError } from "./deck-runtime.js";
13
14
  export { loadPreviewFonts, previewBaseFaces, previewFontLayout } from "./preview-fonts.js";
@@ -36,17 +37,22 @@ button[aria-disabled="true"] { opacity: 0.45; cursor: default; }
36
37
  .counter { min-width: 4.5em; text-align: center; font-variant-numeric: tabular-nums; }
37
38
  .thumbs-wrap { min-width: 0; }
38
39
  .thumbs { position: relative; display: flex; gap: 8px; margin: 0; padding: 4px 2px; list-style: none; overflow-x: auto; scroll-snap-type: x proximity; }
39
- .thumbs li { flex: 0 0 auto; width: 132px; scroll-snap-align: center; }
40
- .thumb { display: block; width: 100%; padding: 0; min-height: 0; overflow: hidden; background: var(--opf-deck-stage, #f1f1f1); }
40
+ .thumbs li { position: relative; flex: 0 0 auto; width: 132px; scroll-snap-align: center; }
41
+ .thumbs .frame { display: block; box-sizing: content-box; aspect-ratio: var(--ratio, 16 / 9); overflow: hidden; border: 1px solid transparent; border-radius: var(--opf-deck-radius, 6px); background: var(--opf-deck-stage, #f1f1f1); }
42
+ .thumbs .frame svg.opf-slide { display: block; width: 100%; height: 100%; pointer-events: none; }
43
+ .thumb, .thumbs .thumb:hover { position: absolute; inset: 0; width: 100%; height: 100%; padding: 0; min-height: 0; background: transparent; }
41
44
  .thumb[aria-current="true"] { outline: 3px solid var(--opf-deck-accent, Highlight); outline-offset: 0; }
42
- .thumb .frame { display: block; width: 100%; aspect-ratio: var(--ratio, 16 / 9); }
43
- .thumb svg.opf-slide { display: block; width: 100%; height: 100%; pointer-events: none; }
44
45
  .sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
45
46
  .flip { display: inline-block; }
46
47
  .rtl .flip { transform: scaleX(-1); }
47
48
  .rtl .bar, .rtl .thumbs { direction: rtl; }
48
49
  @media (prefers-reduced-motion: no-preference) { button { transition: background-color 120ms; } }
49
- @media (forced-colors: active) { .viewport, button, .thumb[aria-current="true"] { border-color: CanvasText; } .thumb[aria-current="true"] { outline-color: Highlight; } }
50
+ @media (forced-colors: active) {
51
+ .viewport, button, .thumb[aria-current="true"] { border-color: CanvasText; }
52
+ .thumb[aria-current="true"] { outline-color: Highlight; }
53
+ /* A disabled control is drawn in GrayText at full opacity: fading would blend its frame and its focus ring into Canvas. */
54
+ button[aria-disabled="true"] { opacity: 1; color: GrayText; border-color: GrayText; }
55
+ }
50
56
  `;
51
57
 
52
58
  const supportsCustomElements = () => typeof customElements !== "undefined" && typeof HTMLElement !== "undefined";
@@ -140,7 +146,7 @@ function createElementClass() {
140
146
  if (had && before === this.getAttribute("fonts") && this.isConnected && this.#built) this.#start();
141
147
  }
142
148
 
143
- /** Extra `renderSvg` options (catalogs, imageResolver, date, ...). Set before the deck loads, or call `reload()`. */
149
+ /** Extra `toSvg` options (catalogs, imageResolver, date, ...). Set before the deck loads, or call `reload()`. */
144
150
  get renderOptions() { return this.#renderOptions; }
145
151
  set renderOptions(value) { this.#renderOptions = value && typeof value === "object" ? value : {}; }
146
152
 
@@ -455,13 +461,15 @@ function createElementClass() {
455
461
  button.setAttribute("part", "thumbnail");
456
462
  button.setAttribute("aria-label", `Go to ${slideLabel(info, position + 1, sequence.length)}`);
457
463
  button.tabIndex = position === cursor.position ? 0 : -1;
464
+ // The drawing is a sibling under the button, not its content: the slide's own text is not the button's visible
465
+ // label (axe label-content-name-mismatch, WCAG 2.5.3), and the button's name is the slide's label. The button
466
+ // stays the first child (#markThumbnail) and covers the drawing (CSS), so it takes every click.
458
467
  const frame = doc.createElement("span");
459
468
  frame.className = "frame";
460
469
  frame.style.setProperty("--ratio", `${first.width} / ${first.height}`);
461
470
  frame.setAttribute("aria-hidden", "true");
462
- button.append(frame);
463
471
  button.addEventListener("click", () => { this.#announce = true; this.#go(() => this.#cursor?.goto(position + 1)); });
464
- li.append(button);
472
+ li.append(button, frame);
465
473
  li._frame = frame; li._index = index;
466
474
  return li;
467
475
  });
@@ -513,21 +521,22 @@ const escapeHtml = (value) => String(value).replace(/&/g, "&amp;").replace(/</g,
513
521
  * `/fonts-browser`, or one with a `textMeasurement` and `embeddedFonts`). A `fonts` string is the font root URL written to the
514
522
  * element's `fonts` attribute.
515
523
  *
516
- * `slides`: `"first"` (default: the first slide plus a list of titles), `"all"` or a list of 1-based slide numbers.
517
- * `embed` puts the document in an `application/opf+json` script child, so the element upgrades without a request.
524
+ * `toHtml(deck)` draws the first slide plus a list of titles; `toHtml(deck, 3)`, `toHtml(deck, "1-3")` (`"1-"` for every slide) or
525
+ * `toHtml(deck, [1, 3])` draw the selected slides of the presented sequence (counting from 1); the second argument may be the
526
+ * options instead, told apart by type (RR-73). `embed` puts the document in an `application/opf+json` script child, so the element upgrades without a request.
518
527
  * `fontMode`: `"standalone"` (default, embeds per slide), `"shared"` (one set of font rules in the returned tag), or
519
528
  * `"external"` (no font rules; the host must supply CSS with the exact pinned faces the handle measures).
520
529
  */
521
- export function renderDeckHtml(input, options = {}) {
530
+ export function toHtml(input, slides, options) {
531
+ ({ slides, options } = slideArguments(slides, options));
522
532
  const deck = parseDeckDocument(input);
523
533
  const tag = options.tagName ?? OPF_DECK_TAG;
524
534
  const sequence = presentableIndexes(deck, { includeHidden: options.includeHidden });
525
- const wanted = options.slides ?? "first";
526
- const shown = wanted === "all" ? sequence.map((_, position) => position) : wanted === "first" ? [0] : wanted.map((number) => number - 1).filter((position) => position >= 0 && position < sequence.length);
535
+ const shown = slides === undefined ? [0] : selectSlides(slides, sequence.length).map((number) => number - 1);
527
536
  const handle = options.fonts !== null && typeof options.fonts === "object" ? options.fonts : undefined;
528
537
  const fontMode = options.fontMode ?? "standalone";
529
538
  if (!["standalone", "shared", "external"].includes(fontMode)) throw new DeckError("invalid-font-mode", "Choose standalone, shared or external SSR fonts.");
530
- // Every mode measures with the same handle; external writes no @font-face data (RR-61 `embedFonts: false`).
539
+ // Every mode measures with the same handle; external writes no @font-face data (`text: "system"`).
531
540
  const renderFonts = handle ?? options.renderOptions?.fonts;
532
541
  const sharedRules = new Set(), sharedCharacters = new Map();
533
542
  const attributes = { src: options.src, slide: options.slide, fonts: handle ? undefined : options.fonts, label: options.label, ...options.attributes };
@@ -538,11 +547,11 @@ export function renderDeckHtml(input, options = {}) {
538
547
  const index = sequence[position];
539
548
  // Shared rules are deduplicated across slides, so the slides embed whole faces and the shared set is cut below to what
540
549
  // every slide draws (RR-65); per-slide subsets would differ and repeat each face once per slide.
541
- let svg = renderSlideSvg(deck, index, { ...options.renderOptions, fonts: renderFonts, ...(fontMode === "external" ? { embedFonts: false } : {}), ...(fontMode === "shared" ? { subsetFonts: false } : {}), ...(date ? { date } : {}) });
550
+ let svg = toSvg(deck, index + 1, { ...options.renderOptions, fonts: renderFonts, ...(fontMode === "external" ? { text: "system" } : {}), ...(fontMode === "shared" ? { subsetFonts: false } : {}), ...(date ? { date } : {}) });
542
551
  if (fontMode === "shared") {
543
552
  drawnFaces(svg, sharedCharacters);
544
553
  svg = svg.replace(/<style>(@font-face[\s\S]*?)<\/style>/g, (_, css) => {
545
- // These rules are produced by renderSlideSvg after its used-face selection and validation.
554
+ // These rules are produced by toSvg after its used-face selection and validation.
546
555
  for (const rule of css.split("\n")) sharedRules.add(rule);
547
556
  return "";
548
557
  });
@@ -552,7 +561,7 @@ export function renderDeckHtml(input, options = {}) {
552
561
  const label = slideLabel(slideInfo(deck, index), position + 1, sequence.length);
553
562
  return `<figure role="group" aria-roledescription="slide" aria-label="${escapeHtml(label)}" style="margin:0 0 8px">${prepared}</figure>`;
554
563
  });
555
- const titles = wanted === "first" && sequence.length > 1
564
+ const titles = slides === undefined && sequence.length > 1
556
565
  ? `<nav aria-label="Slides"><ol>${sequence.map((index) => `<li>${escapeHtml(slideInfo(deck, index).title)}</li>`).join("")}</ol></nav>` : "";
557
566
  const embedded = options.embed ? `<script type="application/opf+json">${JSON.stringify(deck).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029")}</script>` : "";
558
567
  const subsets = options.renderOptions?.subsetFonts === false ? undefined : renderFonts?.subsets;
@@ -1,8 +1,9 @@
1
- import type { PdfDiagnostic, PdfMetadata, SvgToPdfOptions } from "./index.js";
1
+ import type { PdfDiagnostic, PdfMetadata, SlideSelection, SvgSlide, ToPdfOptions, ToSvgOptions } from "./index.js";
2
2
  import type { FontsHandle } from "./fonts.js";
3
3
  export type { PdfDiagnostic, PdfMetadata } from "./index.js";
4
4
 
5
- export interface BrowserSvgToPngOptions {
5
+ /** The options of `toPng` in a browser: the canvas's own, and the `toSvg` options that draw a deck (`fonts` is the browser handle). */
6
+ export interface BrowserToPngOptions extends ToSvgOptions {
6
7
  /** Multiplies the SVG's own size (default 1). The result is limited to 40 megapixels. */
7
8
  scale?: number;
8
9
  /** A CSS colour painted first (default white); `"transparent"` keeps the alpha channel. */
@@ -10,18 +11,19 @@ export interface BrowserSvgToPngOptions {
10
11
  signal?: AbortSignal;
11
12
  }
12
13
 
13
- export interface BrowserSvgToPdfOptions extends Pick<SvgToPdfOptions, "mode" | "background" | "serifFamily" | "metadata" | "tagged" | "compress" | "strict" | "rasterFallbackScale" | "defaultFontFamily" | "sansSerifFamily" | "monospaceFamily" | "onDiagnostic"> {
14
- /** Raster mode: pixel density of each page image (default 2). */
14
+ /** The options of `toPdf` in a browser: the PDF's own, and the `toSvg` options that draw a deck. */
15
+ export interface BrowserToPdfOptions extends Omit<ToSvgOptions, "fonts" | "onDiagnostic">, Pick<ToPdfOptions, "raster" | "background" | "serifFamily" | "metadata" | "tagged" | "compress" | "strict" | "rasterFallbackScale" | "defaultFontFamily" | "sansSerifFamily" | "monospaceFamily" | "onDiagnostic"> {
16
+ /** `raster: true`: pixel density of each page image (default 2). */
15
17
  scale?: number;
16
18
  /**
17
- * Raster mode only: the pdf-lib module (`import * as pdfLib from "pdf-lib"`), an optional peer of the renderer that this entry never imports itself, so a
18
- * bundle of it holds no PDF library. Without it raster mode tries to import "pdf-lib" by name and rejects with `OPFRenderError` `converter-missing`
19
- * when the page cannot. The default vector mode needs no option.
19
+ * `raster: true` only: the pdf-lib module (`import * as pdfLib from "pdf-lib"`), an optional peer of the renderer that this entry never imports itself, so a
20
+ * bundle of it holds no PDF library. Without it a raster PDF tries to import "pdf-lib" by name and rejects with `OPFRenderError` `converter-missing`
21
+ * when the page cannot. The default vector PDF needs no option.
20
22
  */
21
23
  pdfLib?: { PDFDocument: { create(options?: { updateMetadata?: boolean }): Promise<any> } };
22
24
  /**
23
- * The browser fonts handle (`loadFonts` from `/fonts-browser`): every face it holds can be embedded in the PDF, including script faces
24
- * a standalone SVG does not carry. Faces an SVG embeds as `@font-face` data (the `embeddedFonts` of the handle `renderSvg` was given) need no entry.
25
+ * The browser fonts handle (`loadFonts` from `/fonts-browser`): it draws a deck, and every face it holds can be embedded in the PDF, including
26
+ * script faces a standalone SVG does not carry. Faces an SVG embeds as `@font-face` data (the `embeddedFonts` of the handle `toSvg` was given) need no entry.
25
27
  */
26
28
  fonts?: FontsHandle;
27
29
  /** Extra font faces as bytes (TrueType outlines) when there is no handle. */
@@ -32,7 +34,15 @@ export interface BrowserSvgToPdfOptions extends Pick<SvgToPdfOptions, "mode" | "
32
34
  onProgress?: (progress: { page: number; pages: number }) => void;
33
35
  }
34
36
 
35
- export declare function svgToPng(svg: string | Uint8Array, options?: BrowserSvgToPngOptions): Promise<Uint8Array>;
36
- export declare function svgToPdf(svgs: string | Uint8Array | Array<string | Uint8Array>, options?: BrowserSvgToPdfOptions): Promise<Uint8Array>;
37
+ /** PNG with a canvas (RR-73): the arguments and results of the Node `toPng`. */
38
+ export declare function toPng(svg: Uint8Array | `<${string}`, options?: BrowserToPngOptions): Promise<Uint8Array>;
39
+ export declare function toPng(svgs: readonly SvgSlide[], options?: BrowserToPngOptions): Promise<Uint8Array[]>;
40
+ export declare function toPng(deck: unknown, slide: number, options?: BrowserToPngOptions): Promise<Uint8Array>;
41
+ export declare function toPng(deck: unknown, slides: Exclude<SlideSelection, number>, options?: BrowserToPngOptions): Promise<Uint8Array[]>;
42
+ export declare function toPng(deck: object, options?: BrowserToPngOptions): Promise<Uint8Array[]>;
43
+ export declare function toPng(source: string, options?: BrowserToPngOptions): Promise<Uint8Array | Uint8Array[]>;
44
+ /** One PDF (RR-73), a page per slide, of a deck drawn with `toSvg` (every slide or a selection) or of SVG slides. */
45
+ export declare function toPdf(source: unknown, options?: BrowserToPdfOptions): Promise<Uint8Array>;
46
+ export declare function toPdf(deck: unknown, slides: SlideSelection, options?: BrowserToPdfOptions): Promise<Uint8Array>;
37
47
  /** Format of an embedded picture and, for a JPEG, its size, component count and EXIF orientation. */
38
48
  export declare function sniffImage(bytes: Uint8Array): { format: "png" | "webp" | "gif" | "jpeg" | undefined; width?: number; height?: number; channels?: number; orientation?: number; space?: string };
@@ -1,22 +1,35 @@
1
1
  import { svgsToVectorPdf } from "./pdf-vector.js";
2
- import { OPFRenderError, packageName } from "./svg.js";
2
+ import { OPFRenderError, packageName, toSvg } from "./svg.js";
3
+ import { slideArguments, slideSources } from "./slide-sources.js";
3
4
 
4
- // RR-23: PNG and PDF output in a browser. `svgToPng` and `svgToPdf` of the root entry need Node (resvg, sharp); this entry
5
+ // RR-23: PNG and PDF output in a browser. `toPng` and `toPdf` of the root entry need Node (resvg, sharp); this entry
5
6
  // has the same two names for a page. It uses the same vector PDF converter as Node (fontkit, bidi-js and pako, all pure
6
7
  // JavaScript) over the SVG the preview draws, so the text stays real text in the same embedded subsets. What differs from
7
8
  // Node is only how pixels are made: a canvas decodes pictures and draws PNGs. No system font is read (a browser has no way
8
9
  // to hand one over) and nothing is fetched: faces come from the SVG's own @font-face data (the `embeddedFonts` of the fonts handle
9
- // `renderSvg` was given), from the faces the `fonts` handle holds (`loadFonts` from `/fonts-browser`, so script faces the SVG does not
10
+ // `toSvg` was given), from the faces the `fonts` handle holds (`loadFonts` from `/fonts-browser`, so script faces the SVG does not
10
11
  // embed reach the PDF too), or from `fontData`.
11
12
  //
12
- // RR-63: this entry imports no converter. The one optional piece is pdf-lib, for `mode: "raster"` only (the default vector PDF
13
+ // RR-63: this entry imports no converter. The one optional piece is pdf-lib, for `raster: true` only (the default vector PDF
13
14
  // needs nothing): the host imports it and passes it as `options.pdfLib`, so a bundler never has to resolve it.
14
15
 
15
16
  const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
16
17
  const MAX_PIXELS = 40_000_000;
17
18
 
18
- /** Convert one SVG to PNG bytes with a canvas. `scale` multiplies the SVG's own size; `background` is a CSS colour (default white, `"transparent"` for none). */
19
- export async function svgToPng(svg, options = {}) {
19
+ /**
20
+ * PNG with a canvas (RR-73): a deck drawn with `toSvg` (all slides, a slide number or a selection, as for the Node `toPng`) or SVG
21
+ * the renderer drew; one PNG for a slide number or one SVG, a list otherwise. `scale` multiplies the SVG's own size; `background`
22
+ * is a CSS colour (default white, `"transparent"` for none).
23
+ */
24
+ export async function toPng(source, slides, options) {
25
+ ({ slides, options } = slideArguments(slides, options));
26
+ const { svgs, one } = slideSources(source, slides, options, toSvg);
27
+ const pngs = [];
28
+ for (const svg of svgs) pngs.push(await svgPng(svg, options));
29
+ return one ? pngs[0] : pngs;
30
+ }
31
+
32
+ async function svgPng(svg, options = {}) {
20
33
  const text = svgText(svg);
21
34
  const size = pageSize(text);
22
35
  const scale = positive(options.scale, "scale", 1);
@@ -32,24 +45,26 @@ export async function svgToPng(svg, options = {}) {
32
45
  }
33
46
 
34
47
  /**
35
- * Convert SVG slides to a PDF, one slide per page (1 SVG pixel is 1 PDF point, as in Node). `mode: "vector"` (default) writes
36
- * real text, paths, gradients and images; `"raster"` draws each slide as an image (`scale`, default 2). Takes the options of
37
- * the Node `svgToPdf` that make sense here (`metadata`, `tagged`, `strict`, `compress`, `onDiagnostic`, the generic family
38
- * names, `rasterFallbackScale`), `pdfLib` (raster mode only: `import * as pdfLib from "pdf-lib"`), `fonts` (the handle `loadFonts()` returns: every face it holds can be embedded), `fontData`
48
+ * One PDF (RR-73) of a deck drawn with `toSvg` (all slides or a selection) or of SVG slides, one slide per page (1 SVG pixel is 1
49
+ * PDF point, as in Node). Vector by default (real text, paths, gradients and images); `raster: true` draws each slide as an image
50
+ * (`scale`, default 2). Takes the options of the Node `toPdf` that make sense here (`metadata`, `tagged`, `strict`, `compress`,
51
+ * `onDiagnostic`, the generic family names, `rasterFallbackScale`), `pdfLib` (`raster: true` only: `import * as pdfLib from "pdf-lib"`), `fonts` (the handle `loadFonts()` returns: every face it holds can be embedded), `fontData`
39
52
  * (`[{ data, family? }]`, extra face bytes without a handle), `signal` (an AbortSignal, checked between pages) and
40
53
  * `onProgress({ page, pages })`.
41
54
  */
42
- export async function svgToPdf(svgs, options = {}) {
43
- const inputs = (Array.isArray(svgs) ? svgs : [svgs]).map(svgText);
44
- if (!inputs.length) throw new OPFRenderError("empty-pdf", "svgToPdf requires at least one SVG slide.");
45
- const mode = options.mode ?? "vector";
46
- if (mode !== "vector" && mode !== "raster") throw new OPFRenderError("invalid-conversion-option", 'mode must be "vector" or "raster".', { option: "mode", value: mode });
47
- if (mode === "raster") return rasterPdf(inputs, options);
55
+ export async function toPdf(source, slides, options) {
56
+ ({ slides, options } = slideArguments(slides, options));
57
+ const inputs = slideSources(source, slides, options, toSvg).svgs.map(svgText);
58
+ if (!inputs.length) throw new OPFRenderError("empty-pdf", "toPdf needs at least one slide.");
59
+ if (options.raster !== undefined && typeof options.raster !== "boolean") throw new OPFRenderError("invalid-conversion-option", "raster must be true or false.", { option: "raster", value: options.raster });
60
+ if (options.raster === true) return rasterPdf(inputs, options);
48
61
  const scale = positive(options.rasterFallbackScale, "rasterFallbackScale", 2);
49
62
  const fontCss = fontFaceCss(inputs);
50
63
  return svgsToVectorPdf(inputs, {
51
64
  fontFiles: [], fontDirs: [],
52
65
  fontData: [...(options.fonts?.registry?.exportFaces?.() ?? []), ...(Array.isArray(options.fontData) ? options.fontData : [])],
66
+ // opf-render#188: shaped by HarfBuzz when the handle has the shaper (`shapeWasm`); fontkit otherwise.
67
+ shape: options.fonts?.outlines?.shape,
53
68
  imageCodec: canvasImageCodec(),
54
69
  defaultFontFamily: options.defaultFontFamily ?? "Roboto",
55
70
  sansSerifFamily: options.sansSerifFamily ?? options.defaultFontFamily ?? "Roboto",
@@ -68,7 +83,7 @@ export async function svgToPdf(svgs, options = {}) {
68
83
  producer: packageName,
69
84
  ErrorClass: OPFRenderError,
70
85
  // The element's own text has no fonts in a standalone SVG image: give it the faces the slides embed.
71
- rasterize: async (svg, factor) => ({ png: await svgToPng(fontCss && /<text[\s>]/.test(svg) ? svg.replace(/(<svg\b[^>]*>)/, `$1<style>${fontCss}</style>`) : svg, { scale: factor, background: "transparent", signal: options.signal }) }),
86
+ rasterize: async (svg, factor) => ({ png: await svgPng(fontCss && /<text[\s>]/.test(svg) ? svg.replace(/(<svg\b[^>]*>)/, `$1<style>${fontCss}</style>`) : svg, { scale: factor, background: "transparent", signal: options.signal }) }),
72
87
  });
73
88
  }
74
89
 
@@ -81,7 +96,7 @@ async function rasterPdf(inputs, options) {
81
96
  for (const [index, svg] of inputs.entries()) {
82
97
  options.signal?.throwIfAborted?.();
83
98
  const size = pageSize(svg);
84
- const png = await svgToPng(svg, { scale, background: options.background ?? "#FFFFFF", signal: options.signal });
99
+ const png = await svgPng(svg, { scale, background: options.background ?? "#FFFFFF", signal: options.signal });
85
100
  const image = await pdf.embedPng(png);
86
101
  pdf.addPage([size.width, size.height]).drawImage(image, { x: 0, y: 0, width: size.width, height: size.height });
87
102
  options.onProgress?.({ page: index + 1, pages: inputs.length });
@@ -90,14 +105,14 @@ async function rasterPdf(inputs, options) {
90
105
  return pdf.save({ addDefaultPage: false, useObjectStreams: false });
91
106
  }
92
107
 
93
- // pdf-lib for raster mode when the host did not pass it. The specifier is not a literal, so a browser bundler leaves it alone (and
108
+ // pdf-lib for `raster: true` when the host did not pass it. The specifier is not a literal, so a browser bundler leaves it alone (and
94
109
  // never fails for a host that does not install pdf-lib); it resolves where the page can import it by name, for example with an import map.
95
110
  async function importPdfLib() {
96
111
  const name = "pdf-lib";
97
112
  try {
98
113
  return await import(/* webpackIgnore: true */ /* @vite-ignore */ name);
99
114
  } catch (error) {
100
- throw new OPFRenderError("converter-missing", 'Raster-mode PDF output needs pdf-lib, an optional peer dependency of @openpresentation/opf-render: run `npm install pdf-lib@^1.17.1` and pass it as the pdfLib option (import * as pdfLib from "pdf-lib"). The default vector mode needs nothing.', { package: "pdf-lib", range: "^1.17.1", install: "npm install pdf-lib@^1.17.1", cause: error instanceof Error ? error.message : String(error) });
115
+ throw new OPFRenderError("converter-missing", 'A raster PDF (raster: true) needs pdf-lib, an optional peer dependency of @openpresentation/opf-render: run `npm install pdf-lib@^1.17.1` and pass it as the pdfLib option (import * as pdfLib from "pdf-lib"). The default vector PDF needs nothing.', { package: "pdf-lib", range: "^1.17.1", install: "npm install pdf-lib@^1.17.1", cause: error instanceof Error ? error.message : String(error) });
101
116
  }
102
117
  }
103
118
 
@@ -1620,12 +1620,12 @@ export const BUNDLED_FONT_MANIFEST=freeze({
1620
1620
  },
1621
1621
  {
1622
1622
  "name": "@expo-google-fonts/noto-sans-jp",
1623
- "version": "0.4.3",
1623
+ "version": "0.4.4",
1624
1624
  "pack": "scripts",
1625
1625
  "scripts": [
1626
1626
  "Jpan"
1627
1627
  ],
1628
- "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-jp/v/0.4.3",
1628
+ "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-jp/v/0.4.4",
1629
1629
  "license": "OFL-1.1",
1630
1630
  "licenseFile": "LICENSE_FONT",
1631
1631
  "licenseSha256": "1c05c68c34f9708415aada51f17e1b0092d2cea709bf4a94cd38114f9e73d7d9",
@@ -1640,25 +1640,25 @@ export const BUNDLED_FONT_MANIFEST=freeze({
1640
1640
  "family": "Noto Sans JP",
1641
1641
  "weight": 400,
1642
1642
  "italic": false,
1643
- "sha256": "d930d5d52d15231c283089760f84584272ad5e37e14607ba0d19c798e7a9caec"
1643
+ "sha256": "6255ea4692e97ef27330304fb96fd650f9111f868cd585fcf2802d42823930e5"
1644
1644
  },
1645
1645
  {
1646
1646
  "file": "700Bold/NotoSansJP_700Bold.ttf",
1647
1647
  "family": "Noto Sans JP",
1648
1648
  "weight": 700,
1649
1649
  "italic": false,
1650
- "sha256": "c5b7b9d6a6eb682b0d4e6bbb38509575fd2759a28f147daa74714d1359a7909e"
1650
+ "sha256": "7c03e14d8a03e8f5181dbd130712d51e4ae01ac648cbd59442ae8b16c0951835"
1651
1651
  }
1652
1652
  ]
1653
1653
  },
1654
1654
  {
1655
1655
  "name": "@expo-google-fonts/noto-sans-sc",
1656
- "version": "0.4.3",
1656
+ "version": "0.4.4",
1657
1657
  "pack": "scripts",
1658
1658
  "scripts": [
1659
1659
  "Hans"
1660
1660
  ],
1661
- "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-sc/v/0.4.3",
1661
+ "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-sc/v/0.4.4",
1662
1662
  "license": "OFL-1.1",
1663
1663
  "licenseFile": "LICENSE_FONT",
1664
1664
  "licenseSha256": "1c05c68c34f9708415aada51f17e1b0092d2cea709bf4a94cd38114f9e73d7d9",
@@ -1686,12 +1686,12 @@ export const BUNDLED_FONT_MANIFEST=freeze({
1686
1686
  },
1687
1687
  {
1688
1688
  "name": "@expo-google-fonts/noto-sans-tc",
1689
- "version": "0.4.3",
1689
+ "version": "0.4.4",
1690
1690
  "pack": "scripts",
1691
1691
  "scripts": [
1692
1692
  "Hant"
1693
1693
  ],
1694
- "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-tc/v/0.4.3",
1694
+ "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-tc/v/0.4.4",
1695
1695
  "license": "OFL-1.1",
1696
1696
  "licenseFile": "LICENSE_FONT",
1697
1697
  "licenseSha256": "1c05c68c34f9708415aada51f17e1b0092d2cea709bf4a94cd38114f9e73d7d9",
@@ -1719,12 +1719,12 @@ export const BUNDLED_FONT_MANIFEST=freeze({
1719
1719
  },
1720
1720
  {
1721
1721
  "name": "@expo-google-fonts/noto-sans-kr",
1722
- "version": "0.4.3",
1722
+ "version": "0.4.4",
1723
1723
  "pack": "scripts",
1724
1724
  "scripts": [
1725
1725
  "Kore"
1726
1726
  ],
1727
- "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-kr/v/0.4.3",
1727
+ "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-kr/v/0.4.4",
1728
1728
  "license": "OFL-1.1",
1729
1729
  "licenseFile": "LICENSE_FONT",
1730
1730
  "licenseSha256": "1c05c68c34f9708415aada51f17e1b0092d2cea709bf4a94cd38114f9e73d7d9",
@@ -1739,14 +1739,14 @@ export const BUNDLED_FONT_MANIFEST=freeze({
1739
1739
  "family": "Noto Sans KR",
1740
1740
  "weight": 400,
1741
1741
  "italic": false,
1742
- "sha256": "8cbc9b353bb9ce848fd69bb6a507319dfacc659cf5fd643db5d88f3c4970e1dd"
1742
+ "sha256": "b4a747e78805bc25b610dcfc3003cb5fb2e2ec034763774b24216ed15889eef4"
1743
1743
  },
1744
1744
  {
1745
1745
  "file": "700Bold/NotoSansKR_700Bold.ttf",
1746
1746
  "family": "Noto Sans KR",
1747
1747
  "weight": 700,
1748
1748
  "italic": false,
1749
- "sha256": "4ffa20c272ae6d689f7c8d34ff9c8039a326e9c334d9fc8852b2a7396227d077"
1749
+ "sha256": "c76f806dc46b8e7981410cb083801f7f124b7f8bc0d181cdae80e78f16d0e9cc"
1750
1750
  }
1751
1751
  ]
1752
1752
  },
@@ -2551,7 +2551,7 @@ export const BUNDLED_FONT_MANIFEST=freeze({
2551
2551
  },
2552
2552
  {
2553
2553
  "name": "@expo-google-fonts/noto-color-emoji",
2554
- "version": "0.4.6",
2554
+ "version": "0.4.7",
2555
2555
  "pack": "scripts",
2556
2556
  "scripts": [
2557
2557
  "Zsye"
@@ -2560,7 +2560,7 @@ export const BUNDLED_FONT_MANIFEST=freeze({
2560
2560
  "format": "COLRv1, SVG",
2561
2561
  "rasterFamily": "Noto Emoji"
2562
2562
  },
2563
- "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-color-emoji/v/0.4.6",
2563
+ "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-color-emoji/v/0.4.7",
2564
2564
  "license": "OFL-1.1",
2565
2565
  "licenseFile": "LICENSE_FONT",
2566
2566
  "licenseSha256": "ac564676d10054a8445923dfc2dfb13c042d97888bd27c1b6ec6dfe89a9d8d62",
@@ -2573,18 +2573,18 @@ export const BUNDLED_FONT_MANIFEST=freeze({
2573
2573
  "family": "Noto Color Emoji",
2574
2574
  "weight": 400,
2575
2575
  "italic": false,
2576
- "sha256": "7fb39738ab18f10612d6f4595e2e8e47a0afdf34738460442d99cd0c344a4d90"
2576
+ "sha256": "ad4a3478943b88660a194d1434793bc02c8007819f2d1e7da9ede289e78f9ca7"
2577
2577
  }
2578
2578
  ]
2579
2579
  },
2580
2580
  {
2581
2581
  "name": "@expo-google-fonts/noto-emoji",
2582
- "version": "0.4.7",
2582
+ "version": "0.4.8",
2583
2583
  "pack": "scripts",
2584
2584
  "scripts": [
2585
2585
  "Zsye"
2586
2586
  ],
2587
- "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-emoji/v/0.4.7",
2587
+ "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-emoji/v/0.4.8",
2588
2588
  "license": "OFL-1.1",
2589
2589
  "licenseFile": "LICENSE_FONT",
2590
2590
  "licenseSha256": "500bb1ccf43df7bbb522112f9133a52b16e1c35e809632f5d8609b179152de5b",
@@ -68,6 +68,32 @@ export function skipUndecodableLookups(font) {
68
68
  return guarded;
69
69
  }
70
70
 
71
+ // opf-render#125: fontkit 2.0.4 caches one Glyph per glyph id (font._glyphs) with the code points of the call that created it, and
72
+ // its layout reads the input's code points back from that cache (glyphsForString -> getGlyph(id, [codePoint]) -> GlyphInfo). A glyph
73
+ // first reached another way keeps other code points: a composite outline (run.bbox for outlineBounds, glyph.path) reaches its
74
+ // components with none, and a substitution's output carries its input's. Noto Sans Bengali draws ra (U+09B0) with the nukta glyph
75
+ // (U+09BC) as a component, so once any outline with ra was bounded, a later run with a nukta shaped the nukta as an unknown
76
+ // character, the Indic shaper broke its cluster and inserted a dotted circle (0.51 em each): the measured width of a string
77
+ // depended on what the process had measured before. Every request for a glyph with code points now gets a glyph that carries
78
+ // exactly those (a view of the cached glyph when they differ), so shaping is a function of the text alone. The PDF writer's own
79
+ // faces do the same (pdf-fonts.js parseFace).
80
+ const codePointsPinned = new WeakSet();
81
+ export function pinGlyphCodePoints(font) {
82
+ if (codePointsPinned.has(font) || typeof font?.getGlyph !== "function") return font;
83
+ codePointsPinned.add(font);
84
+ const getGlyph = font.getGlyph.bind(font);
85
+ font.getGlyph = (id, codePoints = []) => {
86
+ const glyph = getGlyph(id, codePoints);
87
+ if (!glyph || !codePoints.length) return glyph;
88
+ const cached = glyph.codePoints;
89
+ if (cached?.length === codePoints.length && codePoints.every((codePoint, index) => cached[index] === codePoint)) return glyph;
90
+ const view = Object.create(glyph);
91
+ view.codePoints = [...codePoints];
92
+ return view;
93
+ };
94
+ return font;
95
+ }
96
+
71
97
  /**
72
98
  * The face a registry draws for `family` (a validated concrete family: theme tokens are already resolved) in `style`, chosen
73
99
  * from `faces` ({family, familyGroup, weight, italic, fallbackOnly?, scripts?}) under the substitution policy, aliases and
@@ -143,6 +169,7 @@ export function createFontRegistry(entries, options = {}) {
143
169
  let font;
144
170
  try { font = create(data, entry.postscriptName); } catch (error) { throw new OPFFontError("invalid-font-data", error.message); }
145
171
  if (!font?.layout || !font.unitsPerEm) throw new OPFFontError("font-collection", "Select one font from a collection with postscriptName.");
172
+ pinGlyphCodePoints(font);
146
173
  const family = validFamily(entry.family ?? font.familyName);
147
174
  // OpenType groups Medium/SemiBold/ExtraBold under a preferred family, while
148
175
  // retaining separate legacy families for four-style native font selectors.
@@ -227,6 +254,10 @@ export function createFontRegistry(entries, options = {}) {
227
254
  if(face.fontFace) resolved.fontFace={...face.fontFace};
228
255
  // FF-45: the planner maps a symbol-encoded family's codes (symbol-fonts.js); the face only lends its glyphs.
229
256
  if(resolution.symbolEncoding) resolved.symbolEncoding=resolution.symbolEncoding;
257
+ // render#220: resolving a resolved style again keeps its encoding. Core composes each list paragraph from its block's resolved
258
+ // style, so the item asks for the open symbol face (Noto Sans Symbols 2) by name; that face is the encoding's own, so the codes
259
+ // still map. Any other family drops a stale encoding.
260
+ else if(style.symbolEncoding && symbolPreviewFaces(style.symbolEncoding).some(name=>name.toLowerCase()===face.family.toLowerCase())) resolved.symbolEncoding=symbolEncodingFamily(style.symbolEncoding);
230
261
  return resolved;
231
262
  };
232
263
  const metrics = (text,size,style,includeOutline=false) => {
@@ -2,7 +2,7 @@
2
2
  // `harfbuzz-subset.wasm` of the pinned harfbuzzjs package, MIT) keeps the glyphs a slide's characters reach through the
3
3
  // font's layout tables (every GSUB and GPOS feature is kept, so the browser shapes the subset exactly as the whole face),
4
4
  // renumbers them and trims cmap, hmtx, GPOS and the rest to them. The module has no imports, so it is instantiated
5
- // synchronously from its bytes and `renderSvg` stays synchronous; the same input always gives the same bytes.
5
+ // synchronously from its bytes and `toSvg` stays synchronous; the same input always gives the same bytes.
6
6
  //
7
7
  // Only a face whose license allows it is subset: a bundled face (matched by the sha256 of its bytes against the pinned
8
8
  // manifest) under an allowed license, whose family and file names do not contain one of its package's Reserved Font Names