@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.
package/dist/raster.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import {prepareRasterImages} from './raster-images.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
  import {separateLigatures} from './font-compatibility.js';
4
5
  import {monochromeColorFonts,rasterFontFiles} from './color-fonts.js';
5
6
  import {pinScriptClusters} from './raster-text.js';
@@ -7,38 +8,47 @@ import {loadConverter} from './converters.js';
7
8
  const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
8
9
  const DEFAULT_RASTER_SCALE = 1;
9
10
  const DEFAULT_RASTER_BACKGROUND = "#FFFFFF";
10
- // RR-12: PDF output is vector (selectable text, vector drawing) unless a caller asks for the raster-backed compatibility mode.
11
- const DEFAULT_PDF_MODE = "vector";
12
- let bundledFontFilesCache=null;
11
+ let bundledFontsCache=null;
13
12
 
14
- // The faces a raster or PDF conversion draws with, from the fonts handle (`loadFonts()` from `/fonts-node`): its `fontFiles`, and
15
- // whether the bundled base faces are added (`useBundledFonts`, default true; a Node handle already holds its own, so it says false) and
16
- // whether system fonts load (`loadSystemFonts`, default false). Without a handle the bundled base faces draw.
13
+ // The faces a raster or PDF conversion draws with (RR-74: one `fonts`). The fonts handle (`loadFonts()` from `/fonts-node`) gives its
14
+ // `fontFiles`, whether the bundled base faces are added (`useBundledFonts`, default true; a Node handle already holds its own, so it
15
+ // says false) and whether system fonts load (`loadSystemFonts`, default false). Font folders (a path or a list of paths) add every
16
+ // face in them to the bundled base faces. Without either the bundled base faces draw.
17
17
  function fontSettings(options) {
18
18
  const fonts = options.fonts ?? {};
19
- return { fontFiles: stringArray(fonts.fontFiles), useBundledFonts: fonts.useBundledFonts !== false, loadSystemFonts: fonts.loadSystemFonts === true };
19
+ if (typeof fonts === "string" || Array.isArray(fonts)) return { fontFiles: [], fontDirs: stringArray([fonts].flat()), useBundledFonts: true, loadSystemFonts: false };
20
+ return { fontFiles: stringArray(fonts.fontFiles), fontDirs: [], useBundledFonts: fonts.useBundledFonts !== false, loadSystemFonts: fonts.loadSystemFonts === true };
20
21
  }
21
22
 
22
23
  /**
23
- * Convert one SVG (the output of `renderSvg` or `renderSlideSvg`) to PNG bytes. `fonts` is the handle `loadFonts()` returns: the raster draws
24
- * with its font files, so the preview and the PNG use the same faces. Without it the bundled base faces draw.
24
+ * PNG (RR-73): a deck drawn with `toSvg` (`toPng(deck)` gives one PNG per slide, `toPng(deck, 3)` the third slide's PNG,
25
+ * `toPng(deck, "1-3")` those slides'), or SVG the renderer drew (one SVG gives one PNG, a list a list). `fonts` is the handle
26
+ * `loadFonts()` returns, so the preview and the PNG draw with the same faces, or font folders for SVG input. Without either the
27
+ * bundled base faces draw.
25
28
  */
26
- export async function svgToPng(svg, options = {}) {
27
- const rendered = await rasterizeSvg(svg, options);
28
- return rendered.png;
29
+ export async function toPng(source, slides, options) {
30
+ ({ slides, options } = slideArguments(slides, options));
31
+ const { svgs, one } = slideSources(source, slides, options, toSvg);
32
+ const pngs = [];
33
+ for (const svg of svgs) pngs.push((await rasterizeSvg(svg, options)).png);
34
+ return one ? pngs[0] : pngs;
29
35
  }
30
36
 
31
- /** Convert SVG slides (the output of `renderSvg`) to a PDF, one page each. `fonts` is as for `svgToPng`; `mode` is `"vector"` (default) or `"raster"`. */
32
- export async function svgToPdf(svgs, options = {}) {
33
- const pdfInputs = normalizeSvgList(svgs);
37
+ /**
38
+ * One PDF (RR-73), a page per slide, of a deck drawn with `toSvg` (all slides, or a selection: `toPdf(deck, "2-4")`) or of SVG
39
+ * slides the renderer drew. Vector by default (selectable text in embedded subsets); `raster: true` draws each slide as an image.
40
+ * `fonts` is as for `toPng`.
41
+ */
42
+ export async function toPdf(source, slides, options) {
43
+ ({ slides, options } = slideArguments(slides, options));
44
+ const pdfInputs = slideSources(source, slides, options, toSvg).svgs;
34
45
  if (!pdfInputs.length) {
35
- throw new OPFRenderError("empty-pdf", "svgToPdf requires at least one SVG slide.");
46
+ throw new OPFRenderError("empty-pdf", "toPdf needs at least one slide.");
36
47
  }
37
- const mode = options.mode ?? DEFAULT_PDF_MODE;
38
- if (mode !== "vector" && mode !== "raster") {
39
- throw new OPFRenderError("invalid-conversion-option", 'mode must be "vector" or "raster".', { option: "mode", value: mode });
48
+ if (options.raster !== undefined && typeof options.raster !== "boolean") {
49
+ throw new OPFRenderError("invalid-conversion-option", "raster must be true or false.", { option: "raster", value: options.raster });
40
50
  }
41
- if (mode === "vector") return vectorPdf(pdfInputs, options);
51
+ if (options.raster !== true) return vectorPdf(pdfInputs, options);
42
52
 
43
53
  const { PDFDocument } = await loadPdfLib();
44
54
  const pdf = await PDFDocument.create({ updateMetadata: false });
@@ -65,19 +75,23 @@ export async function svgToPdf(svgs, options = {}) {
65
75
  async function vectorPdf(inputs, options) {
66
76
  const settings = fontSettings(options);
67
77
  if (settings.loadSystemFonts) {
68
- throw new OPFRenderError("pdf-system-fonts-unsupported", 'Vector PDF output embeds only the font files you supply: fonts.loadSystemFonts is not supported with mode "vector". Pass a fonts handle with fontFiles (and fontDirs), or use mode "raster".', { option: "fonts.loadSystemFonts" });
78
+ throw new OPFRenderError("pdf-system-fonts-unsupported", 'Vector PDF output embeds only the font files you supply: so fonts.loadSystemFonts does not apply. Pass a fonts handle with fontFiles, or font folders, or use raster: true.', { option: "fonts.loadSystemFonts" });
69
79
  }
70
80
  const scale = positiveNumber(options.rasterFallbackScale, "rasterFallbackScale", 2);
71
81
  const fontFiles = [
72
82
  ...(settings.useBundledFonts ? await bundledFontFiles() : []),
73
83
  ...settings.fontFiles
74
84
  ];
85
+ // opf-render#188: the runs are shaped by HarfBuzz, as the browser shapes them, through the fonts handle's shaper (or that of
86
+ // the bundled handle the conversion draws with); fontkit shapes them when neither has one.
87
+ const shape = options.fonts?.outlines?.shape ?? (settings.useBundledFonts ? (await bundledFonts()).outlines?.shape : undefined);
75
88
  const svgs = [];
76
89
  for (const input of inputs) svgs.push(await prepareRasterImages(normalizeSvgInput(input)));
77
90
  const {svgsToVectorPdf} = await import("./pdf-vector.js");
78
91
  return svgsToVectorPdf(svgs, {
79
92
  fontFiles,
80
- fontDirs: stringArray(options.fontDirs),
93
+ fontDirs: settings.fontDirs,
94
+ shape,
81
95
  defaultFontFamily: options.defaultFontFamily ?? "Roboto",
82
96
  sansSerifFamily: options.sansSerifFamily ?? options.defaultFontFamily ?? "Roboto",
83
97
  monospaceFamily: options.monospaceFamily ?? "Roboto Mono",
@@ -123,7 +137,7 @@ async function rasterizeSvg(svgInput, options) {
123
137
  const font = {
124
138
  loadSystemFonts: settings.loadSystemFonts,
125
139
  fontFiles,
126
- fontDirs: stringArray(options.fontDirs),
140
+ fontDirs: settings.fontDirs,
127
141
  defaultFontFamily: options.defaultFontFamily ?? "Roboto",
128
142
  sansSerifFamily: options.sansSerifFamily ?? options.defaultFontFamily ?? "Roboto",
129
143
  monospaceFamily: options.monospaceFamily ?? "Roboto Mono"
@@ -167,23 +181,18 @@ const loadResvg = () => loadConverter("@resvg/resvg-js");
167
181
  const loadPdfLib = () => loadConverter("pdf-lib");
168
182
 
169
183
  async function bundledFontFiles() {
170
- if (!bundledFontFilesCache) {
171
- bundledFontFilesCache = resolveBundledFontFiles().catch(error => {
172
- bundledFontFilesCache = null;
184
+ return (await bundledFonts()).fontFiles;
185
+ }
186
+
187
+ // The bundled base handle (`loadFonts()`), loaded once: its font files, and its shaper for the vector PDF.
188
+ function bundledFonts() {
189
+ if (!bundledFontsCache) {
190
+ bundledFontsCache = import('./fonts-node.js').then(({loadFonts}) => loadFonts()).catch(error => {
191
+ bundledFontsCache = null;
173
192
  throw error;
174
193
  });
175
194
  }
176
- return bundledFontFilesCache;
177
- }
178
-
179
- async function resolveBundledFontFiles() {
180
- const {loadFonts} = await import('./fonts-node.js');
181
- return (await loadFonts()).fontFiles;
182
- }
183
-
184
- function normalizeSvgList(value) {
185
- if (Array.isArray(value)) return value;
186
- return [value];
195
+ return bundledFontsCache;
187
196
  }
188
197
 
189
198
  function normalizeSvgInput(value) {
@@ -65,7 +65,7 @@ const UNDRAWN_VALUE = /^(?:https?|data|blob|pkg|file|mailto):\S*$/i;
65
65
 
66
66
  // The built-in variables that copy a document value into drawn text: the inline token and the whole-field reference
67
67
  // (a logo or photo reference draws a picture, not text).
68
- const ORGANIZATION_TOKEN = /\{\{\s*organization\.|var:organization\.(?!logo\b)/;
68
+ const ORGANIZATION_TOKEN = /\{\{\s*organization\.|var:organization\.(?!(?:[^.\s]+\.)?logo\b)/;
69
69
  const SPEAKER_TOKEN = /\{\{\s*speaker\.|var:speaker\.(?!photo\b|image\b|avatar\b)/;
70
70
  const SECTION_TOKEN = /\{\{\s*slide\.section\s*[|}]/;
71
71
 
@@ -130,7 +130,7 @@ function scriptOfFamily(family) {
130
130
  * scheme (`slide.design`), so each such slide is resolved too. `han` is the CJK script the scheme names, which tells
131
131
  * Han-only text apart when the language does not (a Yu Gothic deck whose language is English).
132
132
  *
133
- * `renderOptions` are the host's `renderSvg` options: their `catalogs` (core `Catalog[]`) go to core's `resolveScriptFonts`
133
+ * `renderOptions` are the host's `toSvg` options: their `catalogs` (core `Catalog[]`) go to core's `resolveScriptFonts`
134
134
  * unchanged, and the families the renderer resolves per slide count too, so a scheme that exists only in the host's catalogs
135
135
  * and names a script font still loads that font.
136
136
  */
@@ -176,8 +176,8 @@ function* namedFontFamilies(value, key) {
176
176
  * itemization, so language-dependent punctuation counts), the scripts its font schemes need (`design`, for example
177
177
  * Jpan for a Yu Gothic scheme) and the CJK characters that a glyph fallback might have to draw with another CJK face.
178
178
  * The text decides, so a document is analyzed without resolving its layouts and catalogs; `renderOptions` (the host's
179
- * `renderSvg` options) matter only for font schemes the host supplies in `renderOptions.catalogs`, which then resolve
180
- * like the renderer resolves them; a document that then does not resolve throws what `renderSvg` throws for it.
179
+ * `toSvg` options) matter only for font schemes the host supplies in `renderOptions.catalogs`, which then resolve
180
+ * like the renderer resolves them; a document that then does not resolve throws what `toSvg` throws for it.
181
181
  */
182
182
  export function analyzePresentationScripts(presentation, profile, renderOptions) {
183
183
  profile ??= presentationScriptProfile(presentation, renderOptions);
@@ -198,7 +198,7 @@ export function analyzePresentationScripts(presentation, profile, renderOptions)
198
198
  * (Japanese, Korean, Simplified or Traditional Chinese) and makes curly quotes, dashes and the ellipsis
199
199
  * East Asian. Text the preview never draws (ids, alt text, sources, assets, catalogs, notes) is ignored.
200
200
  * Greek and Cyrillic add nothing: the office registry always carries Noto Sans (the glyph-fallback face) and Roboto covers them.
201
- * Pass `profile` to reuse a resolved profile. Other options are the host's `renderSvg` options (`catalogs`, see `analyzePresentationScripts`).
201
+ * Pass `profile` to reuse a resolved profile. Other options are the host's `toSvg` options (`catalogs`, see `analyzePresentationScripts`).
202
202
  */
203
203
  export function detectPresentationScripts(presentation, { profile, ...renderOptions } = {}) {
204
204
  return analyzePresentationScripts(presentation, profile, renderOptions).detected;
@@ -46,7 +46,9 @@ export const SCRIPT_FONT_FAMILIES = freeze({
46
46
  // text, not scripts a character carries: emoji and math characters are Common, so they stay in their
47
47
  // neighbours' run and reach these faces through the glyph fallback chain (emoji-presentation clusters
48
48
  // take the emoji face first). The keys are last so the chain tries every real script face before them.
49
- Zsye: {sans: "Noto Color Emoji"},
49
+ // `fallback` is a further designated face, tried after the colour face: a host that installed only the monochrome Noto Emoji package
50
+ // still previews and exports emoji. The colour face stays first wherever it is loaded.
51
+ Zsye: {sans: "Noto Color Emoji", fallback: "Noto Emoji"},
50
52
  Zmth: {serif: "STIX Two Math", sans: "Noto Sans Math"},
51
53
  });
52
54
 
@@ -315,7 +317,7 @@ export function detectScripts(value, profile, { includeLanguage = true, ignoreKe
315
317
  export function designatedFamilies(script, serif = false) {
316
318
  const entry = SCRIPT_FONT_FAMILIES[script];
317
319
  if (!entry) return [];
318
- return (serif ? [entry.serif, entry.sans] : [entry.sans, entry.serif]).filter(Boolean);
320
+ return (serif ? [entry.serif, entry.sans, entry.fallback] : [entry.sans, entry.serif, entry.fallback]).filter(Boolean);
319
321
  }
320
322
 
321
323
  const hanKeys = ["Jpan", "Hans", "Hant", "Kore"];
@@ -0,0 +1,47 @@
1
+ // RR-73: what `toPng` and `toPdf` draw. They take a deck (an object, or JSON text) and draw it with `toSvg`, or SVG the renderer
2
+ // already drew (a string or bytes starting with `<`, or a list of them). The second argument is a slide selection for a deck (`3`
3
+ // or `"1-3"`, slides counting from 1) or the options, told apart by type, as for `toSvg`.
4
+ import { parseSlideSelection } from "@openpresentation/opf";
5
+ import { OPFRenderError } from "./svg.js";
6
+
7
+ /** The slide numbers (from 1) a selection names: a list of numbers or text such as "1,3-5"; core's parser, with the renderer's error. */
8
+ export function selectSlides(slides, count) {
9
+ try {
10
+ return parseSlideSelection(slides, count, "slides");
11
+ } catch (error) {
12
+ throw new OPFRenderError("invalid-slide-selection", error.message, { option: "slides", slideCount: count, cause: error });
13
+ }
14
+ }
15
+
16
+ // The 0.17 option names (RR-73, RR-74): pre-v1 there is no alias, but a call that still passes one fails with the new name.
17
+ const RENAMED = { embedFonts: "text: \"system\" (embedFonts: false)", textAsPaths: "text: \"paths\"", mode: "raster: true (vector is the default)", fontDirs: "fonts: a font folder or a list of them" };
18
+
19
+ /** Throws for an option of 0.17 that 0.18 renamed, naming its replacement. */
20
+ export function rejectRenamedOptions(options, code) {
21
+ for (const name of Object.keys(RENAMED)) {
22
+ if (options?.[name] !== undefined) throw new OPFRenderError(code, `The ${name} option was replaced by ${RENAMED[name]}.`, { option: name });
23
+ }
24
+ }
25
+
26
+ /** `(slides, options)` with the options moved into place when the second argument is the options object. */
27
+ export function slideArguments(slides, options) {
28
+ if (slides !== null && typeof slides === "object" && !Array.isArray(slides)) return { slides: undefined, options: slides };
29
+ return { slides, options: options ?? {} };
30
+ }
31
+
32
+ const isSvg = (value) => value instanceof Uint8Array || (typeof value === "string" && /^\s*</.test(value));
33
+
34
+ /**
35
+ * The SVG slides to convert, and whether the caller asked for one (a slide number, or one SVG): `draw` is `toSvg`, called with the
36
+ * conversion options for a deck (they carry the fonts handle and the drawing options).
37
+ */
38
+ export function slideSources(source, slides, options, draw) {
39
+ rejectRenamedOptions(options, "invalid-conversion-option");
40
+ if (Array.isArray(source) || isSvg(source)) {
41
+ if (slides !== undefined) throw new OPFRenderError("invalid-conversion-option", "A slide selection applies to a deck; SVG input is already drawn, so pass the slides you want.", { option: "slides" });
42
+ if (Array.isArray(source) && !source.every(isSvg)) throw new OPFRenderError("invalid-svg-input", "A list must hold SVG slides (strings or bytes starting with <).");
43
+ return { svgs: Array.isArray(source) ? source : [source], one: !Array.isArray(source) };
44
+ }
45
+ const drawn = draw(source, slides, options);
46
+ return typeof drawn === "string" ? { svgs: [drawn], one: true } : { svgs: drawn, one: false };
47
+ }
package/dist/svg.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { RenderFonts, ScriptFonts } from "./fonts.js";
2
- import type { Catalog, Finding, UnresolvedReferenceDiagnostic } from "@openpresentation/opf";
2
+ import type { Catalog, Finding, SlideSelection, UnresolvedReferenceDiagnostic } from "@openpresentation/opf";
3
+ export type { SlideSelection } from "@openpresentation/opf";
3
4
  import type { SlideComposition, LayoutDiagnostic, TextMeasurement } from "@openpresentation/opf/composition";
4
5
  export declare const packageName = "@openpresentation/opf-render";
5
6
 
@@ -49,8 +50,8 @@ export type RenderDiagnostic = LayoutDiagnostic | {
49
50
  placeholder: "label" | "icon";
50
51
  };
51
52
 
52
- export interface RenderSvgOptions {
53
- /** Leave out slides marked `hidden: true`, as the player does. Default false: one SVG per slide. With true the result holds only the visible slides, in order, so an index no longer names the slide at that index. Read by the deck-level `renderSvg`; `renderSlideSvg` draws the slide it is given. */
53
+ export interface ToSvgOptions {
54
+ /** Leave out slides marked `hidden: true` when drawing the whole deck, as the player does. Default false: one SVG per slide. A slide number or selection draws exactly the slides it names. */
54
55
  skipHidden?: boolean;
55
56
  /**
56
57
  * The fonts the deck is laid out and drawn with: the handle `loadFonts()` returns (from `@openpresentation/opf-render/fonts-node` or
@@ -58,10 +59,12 @@ export interface RenderSvgOptions {
58
59
  * core's portable width estimate and the SVG names the design fonts without embedding them.
59
60
  */
60
61
  fonts?: RenderFonts;
61
- /** `false` writes no `@font-face` data into the SVG (RR-61): the fonts handle still measures and the SVG still names the families, for a host whose page already has the faces (a browser `loadFonts` handle adds them to the document). Default true: each SVG embeds the faces of `fonts.embeddedFonts` its own text draws. */
62
- embedFonts?: boolean;
63
62
  /**
64
- * `true` draws every `<text>` as glyph outlines (RR-64): `<use>` of outlines kept once per slide in `<defs>`, so the SVG needs
63
+ * How the SVG draws text (RR-74). `"fonts"` (default): each SVG embeds, as `@font-face` data, the faces of `fonts.embeddedFonts` its
64
+ * own text draws (RR-61). `"system"`: no `@font-face` data; the fonts handle still measures and the SVG still names the families,
65
+ * for a host whose page already has the faces (a browser `loadFonts` handle adds them to the document).
66
+ *
67
+ * `"paths"`: every `<text>` drawn as glyph outlines (RR-64): `<use>` of outlines kept once per slide in `<defs>`, so the SVG needs
65
68
  * no font and looks the same in every viewer (about 40 KB a slide over the core example decks, against 0.5 to 4 MB of embedded
66
69
  * faces). Needs the fonts handle `loadFonts()` returns (`text-as-paths-needs-fonts` otherwise). The text is no longer text: it
67
70
  * cannot be edited in place. The glyphs are shaped by HarfBuzz (the browser's shaper; the Node handle always, a browser handle
@@ -70,9 +73,9 @@ export interface RenderSvgOptions {
70
73
  * A run stays text, reported as `text-as-paths-fallback` with a `reason`, when its font is a colour or bitmap font
71
74
  * (`"colour-font"`), its face's fsType restricts embedding (`"restricted"`), or it has no pinned width and HarfBuzz and the
72
75
  * layout's measurement disagree on it (`"shaping"`). Use it for thumbnails, previews and portable SVG files, not for an editing
73
- * surface or the vector PDF (which keeps real text from `<text>` SVG). Default false.
76
+ * surface or the vector PDF (which keeps real text from `<text>` SVG).
74
77
  */
75
- textAsPaths?: boolean;
78
+ text?: "fonts" | "system" | "paths";
76
79
  /**
77
80
  * `false` embeds whole faces (RR-65). By default, with a fonts handle that carries `subsets` (the Node `loadFonts` handle, a browser
78
81
  * handle given `subsetWasm`), each face an SVG embeds is cut to the characters the slide draws, keeping every layout feature, so the
@@ -119,20 +122,8 @@ export interface RenderSvgOptions {
119
122
  strictReferences?: boolean;
120
123
  }
121
124
 
122
- export interface SvgToPngOptions {
123
- /** The fonts the conversion draws with (Node): see `SvgToPngOptions` in the main entry. */
124
- fonts?: RenderFonts;
125
- scale?: number;
126
- background?: string;
127
- dpi?: number;
128
- /** Extra directories of font files to draw with. */
129
- fontDirs?: string[];
130
- defaultFontFamily?: string;
131
- sansSerifFamily?: string;
132
- monospaceFamily?: string;
133
- }
134
-
135
- export interface SvgToPdfOptions extends SvgToPngOptions {}
125
+ /** The Node conversion options (types only: `toPng` and `toPdf` run in Node, or from `/export-browser`). */
126
+ export type { ToPngOptions, ToPdfOptions } from "./index.js";
136
127
 
137
128
  export interface ResolvedPresentation {
138
129
  presentation: unknown;
@@ -185,12 +176,16 @@ export declare class OPFRenderError extends Error {
185
176
  constructor(code: string, message: string, details?: Record<string, unknown>);
186
177
  }
187
178
 
188
- export declare function resolvePresentation(input: unknown, options?: RenderSvgOptions): ResolvedPresentation;
189
-
190
- /** The SVG of every slide of the deck, in order. */
191
- export declare function renderSvg(input: unknown, options?: RenderSvgOptions): string[];
192
-
193
- /** The SVG of one slide of the deck (`index` is zero-based; out of range throws `slide-index-out-of-range`). */
194
- export declare function renderSlideSvg(input: unknown, index: number, options?: RenderSvgOptions): string;
179
+ export declare function resolvePresentation(input: unknown, options?: ToSvgOptions): ResolvedPresentation;
195
180
 
181
+ /** An SVG slide the renderer drew: its text, or its bytes. */
182
+ export type SvgSlide = string | Uint8Array;
196
183
 
184
+ /**
185
+ * The deck as SVG (RR-73). `toSvg(deck)`: every slide, in order (without the hidden ones under `skipHidden`). `toSvg(deck, 3)`: the
186
+ * third slide (slides count from 1; out of range throws `slide-out-of-range`). `toSvg(deck, "1-3")` or `toSvg(deck, [1, 3])`: the
187
+ * selected slides (a malformed selection throws `invalid-slide-selection`). The second argument may be the options instead.
188
+ */
189
+ export declare function toSvg(input: unknown, options?: ToSvgOptions): string[];
190
+ export declare function toSvg(input: unknown, slide: number, options?: ToSvgOptions): string;
191
+ export declare function toSvg(input: unknown, slides: Exclude<SlideSelection, number>, options?: ToSvgOptions): string[];
package/dist/svg.js CHANGED
@@ -19,11 +19,13 @@ import {
19
19
  } from "@openpresentation/opf";
20
20
  import { adjustedFontSize, baselineShift, createScriptFonts } from "./script-fonts.js";
21
21
  import { disabledFeaturesStyle } from "./font-compatibility.js";
22
+ import { isSymbolEncodedFamily } from "./symbol-fonts.js";
22
23
  import { fontPolicyFor } from "@openpresentation/opf/font-policy";
23
24
  import { isDatasetChart, isDatasetTable, renderCatalogChart } from "./charts.js";
24
25
  import { renderCaption, renderFootnotes } from "./annotations.js";
25
26
  import { drawnFaces } from "./drawn-faces.js";
26
27
  import { dataUrlFsType, embeddingAllowed } from "./font-fstype.js";
28
+ import { rejectRenamedOptions, selectSlides } from "./slide-sources.js";
27
29
 
28
30
  export const packageName = "@openpresentation/opf-render";
29
31
 
@@ -615,9 +617,12 @@ function bindSlide(presentation, index, context) {
615
617
  // RR-17 (FF-41): a family that names its weight (Arial Black, Segoe UI Semibold and Light) keeps its own name through composition, so each run
616
618
  // resolves it again and draws the replacement's encoded weight (Montserrat 900, Red Hat Display 600 and 300); resolving the role to the
617
619
  // replacement family first would draw every run at 400 or 700 and measure it that way. Every other family resolves once, here.
620
+ // A symbol-encoded family (Symbol, Wingdings, Webdings; FF-45) keeps its name too: each run resolves it again and the script-font planner
621
+ // maps the run's private-use codes through the symbol table (symbol-fonts.js), as it does for a symbol run. Resolving the role to the open
622
+ // symbol face here would drop the encoding, and the codes would reach a face with no glyph for them (missing-glyph).
618
623
  for (const role of ["heading","body","code","accent"]) if (design.fonts[role] !== undefined) {
619
624
  const named = design.fonts[role], resolved = resolveTextStyle({fontFamily:named,fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
620
- design.fonts[role] = fontPolicyFor(named)?.replacement?.weight !== undefined && resolved.toLowerCase() !== named.toLowerCase() ? named : resolved;
625
+ design.fonts[role] = isSymbolEncodedFamily(named) || fontPolicyFor(named)?.replacement?.weight !== undefined && resolved.toLowerCase() !== named.toLowerCase() ? named : resolved;
621
626
  }
622
627
  const geometry = composeSlide(slide, { ...slideContext.options, fontFamilies: design.fonts, textRasterPadding:context.options.textRasterPadding, textMeasurement });
623
628
  return {
@@ -640,22 +645,24 @@ function bindSlide(presentation, index, context) {
640
645
  }
641
646
 
642
647
  // `options.fonts` is the handle `loadFonts()` returns (or any object with a `textMeasurement` and, to embed faces in the SVG, an
643
- // `embeddedFonts` list). The renderer reads the two as plain options from here on. `embedFonts: false` (RR-61) keeps the
644
- // measurement and drops the faces, for a host whose page already holds them.
645
- // `textAsPaths: true` (RR-64) draws text as glyph outlines with the handle's `outlines` engine (a `loadFonts` handle carries one).
646
- // `subsetFonts: false` (RR-65) embeds whole faces; by default a handle with a subset engine (`fonts.subsets`) cuts each embedded
647
- // face to the characters the slide draws.
648
+ // `embeddedFonts` list). The renderer reads the two as plain options from here on. `text` (RR-74) says how the SVG draws text:
649
+ // `"fonts"` (default) embeds the faces the slide draws, cut to its glyphs when the handle carries `subsets` (RR-61, RR-65);
650
+ // `"system"` embeds no face and names the families, for a host whose page already holds them; `"paths"` draws glyph outlines with
651
+ // the handle's `outlines` engine, so the SVG needs no font (RR-64). `subsetFonts: false` embeds whole faces.
652
+ const TEXT_MODES = ["fonts", "system", "paths"];
648
653
  function normalizeOptions(options) {
649
- const { fonts, embedFonts, textAsPaths, subsetFonts, ...rest } = options ?? {};
654
+ rejectRenamedOptions(options, "invalid-render-options");
655
+ const { fonts, text = "fonts", subsetFonts, ...rest } = options ?? {};
656
+ if (!TEXT_MODES.includes(text)) throw new OPFRenderError("invalid-render-options", 'text must be "fonts", "system" or "paths".', { option: "text", value: text });
650
657
  if (subsetFonts !== undefined && typeof subsetFonts !== "boolean") throw new OPFRenderError("invalid-render-options", "subsetFonts must be true or false.", { option: "subsetFonts" });
651
- if (textAsPaths !== undefined && typeof textAsPaths !== "boolean") throw new OPFRenderError("invalid-render-options", "textAsPaths must be true or false.", { option: "textAsPaths" });
652
- if (textAsPaths && typeof fonts?.outlines?.outlineSlideText !== "function") throw new OPFRenderError("text-as-paths-needs-fonts", "textAsPaths needs the fonts handle loadFonts() returns (from /fonts-node or /fonts-browser), passed as `fonts`.", { option: "textAsPaths" });
653
- return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: embedFonts === false ? undefined : fonts?.embeddedFonts, ...(textAsPaths ? { outlines: fonts.outlines } : {}), ...(subsetFonts !== false && fonts?.subsets ? { subsets: fonts.subsets } : {}) };
658
+ if (typeof fonts === "string" || Array.isArray(fonts)) throw new OPFRenderError("invalid-render-options", "Drawing a deck needs the fonts handle loadFonts() returns (from /fonts-node or /fonts-browser): it measures the text. Font folders are read by toPng and toPdf for SVG input.", { option: "fonts" });
659
+ if (text === "paths" && typeof fonts?.outlines?.outlineSlideText !== "function") throw new OPFRenderError("text-as-paths-needs-fonts", 'text: "paths" needs the fonts handle loadFonts() returns (from /fonts-node or /fonts-browser), passed as `fonts`.', { option: "text" });
660
+ return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: text === "system" ? undefined : fonts?.embeddedFonts, ...(text === "paths" ? { outlines: fonts.outlines } : {}), ...(subsetFonts !== false && fonts?.subsets ? { subsets: fonts.subsets } : {}) };
654
661
  }
655
662
 
656
663
  /**
657
664
  * Resolve a presentation for drawing: template variables filled, the boundary check passed, and every slide bound to its
658
- * layout, design, script fonts and composed geometry. `renderSvg` and `renderSlideSvg` draw from this.
665
+ * layout, design, script fonts and composed geometry. `toSvg` draws from this.
659
666
  */
660
667
  export function resolvePresentation(input, options = {}) {
661
668
  return resolveDeck(input, normalizeOptions(options));
@@ -678,27 +685,24 @@ function resolveDeck(input, options) {
678
685
  }
679
686
 
680
687
  /**
681
- * The SVG of every slide of the deck, in order. `skipHidden: true` leaves out slides marked `hidden` (the sequence the player
682
- * presents); the result is then shorter than the deck, so an index no longer names the slide at that index.
688
+ * The deck as SVG (RR-73): `toSvg(deck)` gives every slide, in order, as an array; `toSvg(deck, 3)` the third slide as one string
689
+ * (slides count from 1); `toSvg(deck, "1-3")` (`"2,4"`, `"5-"`, the CLI's --slides syntax, or a list such as `[1, 3]`) the selected slides as an array. The
690
+ * second argument may be the options instead, told apart by type. `skipHidden: true` leaves slides marked `hidden` out of the
691
+ * whole deck (the sequence the player presents); a selection draws exactly the slides it names.
683
692
  */
684
- export function renderSvg(input, options = {}) {
685
- const drawn = normalizeOptions(options);
693
+ export function toSvg(input, slides, options) {
694
+ if (slides !== null && typeof slides === "object" && !Array.isArray(slides)) { options = slides; slides = undefined; }
695
+ const drawn = normalizeOptions(options ?? {});
686
696
  const resolved = resolveDeck(input, drawn);
687
- const indexes = resolved.slides.map((_, index) => index).filter(index => !(options.skipHidden === true && resolved.presentation.slides[index]?.hidden === true));
688
- return indexes.map(index => renderResolvedSlide(resolved, index, drawn));
689
- }
690
-
691
- /** The SVG of one slide of the deck (a zero-based index). */
692
- export function renderSlideSvg(input, index, options = {}) {
693
- const drawn = normalizeOptions(options);
694
- const resolved = resolveDeck(input, drawn);
695
- if (!Number.isInteger(index) || index < 0 || index >= resolved.slides.length) {
696
- throw new OPFRenderError("slide-index-out-of-range", `Slide index ${index} is out of range.`, {
697
- slideIndex: index,
698
- slideCount: resolved.slides.length
699
- });
697
+ const count = resolved.slides.length;
698
+ if (typeof slides === "number") {
699
+ if (!Number.isInteger(slides) || slides < 1 || slides > count) throw new OPFRenderError("slide-out-of-range", `Slide ${slides} is not in the presentation, which has ${count} slide${count === 1 ? "" : "s"} (slides count from 1).`, { slide: slides, slideCount: count });
700
+ return renderResolvedSlide(resolved, slides - 1, drawn);
700
701
  }
701
- return renderResolvedSlide(resolved, index, drawn);
702
+ const numbers = slides === undefined
703
+ ? resolved.slides.map((_, index) => index + 1).filter(number => !(options?.skipHidden === true && resolved.presentation.slides[number - 1]?.hidden === true))
704
+ : selectSlides(slides, count);
705
+ return numbers.map(number => renderResolvedSlide(resolved, number - 1, drawn));
702
706
  }
703
707
 
704
708
  function renderResolvedSlide(resolved, slideIndex, options) {
@@ -1697,12 +1701,27 @@ function renderBranding(bound,presentation,width,height,options) {
1697
1701
  // The group keeps its bare opacity attribute (opf-pptx's watermark parity test reads it); the picture itself is hidden.
1698
1702
  pieces.push(tag('g',{opacity:typeof design.watermark==='object'?design.watermark.opacity??.08:.08},renderImage({value:design.watermark,path:rootFor('watermark')},{x:width*.3,y:height*.3,width:width*.4,height:height*.4},bound,{...options,imageFit:'contain',imageDecorative:true})));
1699
1703
  }
1700
- // Cover and section slides: the deck logo core composed at the top-left of the free area, anchored left.
1704
+ // Cover and section slides: the logo core composed (the primary organization's full logo, or the design.logo override, in the
1705
+ // variant for this slide's background) at the top-left of the free area, anchored left. RR-71: the picture is named by its
1706
+ // asset's alt text, else by the organization it belongs to ("Acme Corp logo"); an unnamed organization still says "Logo".
1701
1707
  const logo=bound.geometry.logo;
1702
- if(logo)pieces.push(renderImage({value:logo.source,path:logo.path},logo.box,bound,{...options,imageAnchor:logo.anchor??'left',imageLabel:'Logo',imageGenerated:true}));
1708
+ if(logo)pieces.push(renderImage({value:logo.source,path:logo.path},logo.box,bound,{...options,imageAnchor:logo.anchor??'left',imageLabel:logoLabel(presentation,logo.path),imageGenerated:true}));
1703
1709
  return pieces.join('');
1704
1710
  }
1705
1711
 
1712
+ // The organization a logo path (`organization.logo.full`, `organization.1.logo.icon.onDark`) belongs to: the record at the index the path
1713
+ // names, else the single organization object.
1714
+ function logoOrganization(presentation,path){
1715
+ const match=/^organization(?:\.(\d+))?\.logo(?:\.|$)/.exec(path??'');
1716
+ if(!match)return undefined;
1717
+ const organization=presentation?.organization;
1718
+ return Array.isArray(organization)?organization[Number(match[1])]:match[1]===undefined?organization:undefined;
1719
+ }
1720
+ function logoLabel(presentation,path){
1721
+ const name=logoOrganization(presentation,path)?.name;
1722
+ return typeof name==='string'&&name.trim()?`${name.trim()} logo`:'Logo';
1723
+ }
1724
+
1706
1725
  // A family name is written unquoted only when it is a valid sequence of CSS identifiers; a word that starts with a digit
1707
1726
  // ("Source Sans 3", "Noto Sans Symbols 2") or any other non-identifier character makes an unquoted name invalid CSS, which
1708
1727
  // drops the whole font-family in a browser (FF-45). Such names are single-quoted (the attribute is double-quoted).
@@ -1,4 +1,4 @@
1
- // RR-64: `textAsPaths: true` draws every <text> of a slide as glyph outlines, so the SVG needs no font. The renderer has
1
+ // RR-64: `text: "paths"` draws every <text> of a slide as glyph outlines, so the SVG needs no font. The renderer has
2
2
  // already decided every line, position and width (x, y, text-anchor, textLength); the vector PDF export's SVG text layout
3
3
  // (layoutText: text chunks, bidirectional runs, per-character fallback, fontkit shaping, textLength) turns that into positioned
4
4
  // glyph runs, and this module writes each glyph as a <use> of its outline. Outlines are kept once per slide in <defs>, in font
@@ -96,7 +96,7 @@ export function outlineSlideText(content, { library, shaper, rootStyle = {}, rep
96
96
  defaults: [],
97
97
  splitByOwner: true,
98
98
  // HarfBuzz, the browser's shaper, when the handle carries one (RR-64 phase 2); fontkit otherwise.
99
- ...(shaper ? { shape: (face, text, options) => shaper.shape(face.hash, face.data, text, { ...options, language: options.lang }) } : {}),
99
+ ...(shaper ? { shape: harfBuzzShape(shaper) } : {}),
100
100
  // The renderer reports its own font substitutions and missing glyphs while it lays the slide out.
101
101
  diagnostic: () => {},
102
102
  missing: () => {},
@@ -316,15 +316,27 @@ function precise(value) { return String(Number(Number(value).toPrecision(6))); }
316
316
 
317
317
 
318
318
  /**
319
- * The outline engine a fonts handle carries as `outlines` (RR-64): `renderSvg(deck, { fonts, textAsPaths: true })` draws each
319
+ * The outline engine a fonts handle carries as `outlines` (RR-64): `toSvg(deck, { fonts, text: "paths" })` draws each
320
320
  * slide's text with it. The faces are read from the handle when a slide is outlined, so faces it loads later are used too.
321
321
  */
322
322
  export function textOutlines(fonts) {
323
323
  return Object.freeze({
324
324
  outlineSlideText: (content, options) => {
325
325
  const library = outlineLibrary(fonts);
326
- if (!library) throw options.fail("text-as-paths-needs-fonts", "textAsPaths needs font faces to outline: pass the handle loadFonts() returns as `fonts`.");
326
+ if (!library) throw options.fail("text-as-paths-needs-fonts", 'text: "paths" needs font faces to outline: pass the handle loadFonts() returns as `fonts`.');
327
327
  return outlineSlideText(content, { ...options, library, shaper: fonts.shaper });
328
328
  },
329
+ // opf-render#188: the vector PDF shapes its runs with the same HarfBuzz. Each character stays with the glyph it became (cluster
330
+ // level 2: a mark, a reordered vowel sign), so the PDF's glyph-to-Unicode map and /ActualText spans give the text as before;
331
+ // the glyphs and positions are the outlines'.
332
+ ...(fonts.shaper ? { shape: harfBuzzShape(fonts.shaper, { clusterLevel: 2 }) } : {}),
329
333
  });
330
334
  }
335
+
336
+ /**
337
+ * A layoutText `env.shape` over a HarfBuzz shaper (hb-shape.js), for faces of a FontLibrary (keyed by their content hash).
338
+ * HarfBuzz takes the BCP 47 tag (`lang`); fontkit the OpenType language system (`language`).
339
+ */
340
+ function harfBuzzShape(shaper, { clusterLevel } = {}) {
341
+ return (face, text, options) => shaper.shape(face.hash, face.data, text, { ...options, language: options.lang, clusterLevel });
342
+ }