@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/index.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
@@ -118,18 +121,18 @@ export interface RenderSvgOptions {
118
121
  strictReferences?: boolean;
119
122
  }
120
123
 
121
- export interface SvgToPngOptions {
124
+ /** The options of `toPng`: the conversion's own, and the `toSvg` options that draw a deck. */
125
+ export interface ToPngOptions extends Omit<ToSvgOptions, "fonts"> {
122
126
  /**
123
- * The fonts the conversion draws with: the handle `loadFonts()` returns from `/fonts-node` (its `fontFiles`; `useBundledFonts` and
124
- * `loadSystemFonts` say whether the bundled base faces are added, default true, and whether system fonts load, default false). Without
125
- * it the bundled base faces draw. The same handle goes to `renderSvg`, so the preview and the PNG use the same faces.
127
+ * The fonts the deck is laid out and drawn with: the handle `loadFonts()` returns from `/fonts-node` (it measures the deck, and its
128
+ * `fontFiles` draw; `useBundledFonts` and `loadSystemFonts` say whether the bundled base faces are added, default true, and whether
129
+ * system fonts load, default false), so the preview and the PNG use the same faces. For SVG input it may instead be font folders
130
+ * (a path or a list of paths), whose faces are added to the bundled base faces. Without it the bundled base faces draw.
126
131
  */
127
- fonts?: RenderFonts;
132
+ fonts?: RenderFonts | string | readonly string[];
128
133
  scale?: number;
129
134
  background?: string;
130
135
  dpi?: number;
131
- /** Extra directories of font files to draw with. */
132
- fontDirs?: string[];
133
136
  defaultFontFamily?: string;
134
137
  sansSerifFamily?: string;
135
138
  monospaceFamily?: string;
@@ -198,14 +201,14 @@ export interface PdfMetadata {
198
201
  modificationDate?: Date | string;
199
202
  }
200
203
 
201
- export interface SvgToPdfOptions extends SvgToPngOptions {
204
+ export interface ToPdfOptions extends Omit<ToPngOptions, "onDiagnostic"> {
202
205
  /**
203
- * `"vector"` (default): shapes, gradients and patterns as PDF vector graphics, pictures as images, text as real text
204
- * with embedded font subsets (selectable, searchable, correct copy and paste); the fonts are the bundled files, the `fonts` handle's
205
- * `fontFiles` and the `fontDirs` you supply, never system fonts. `"raster"`: each slide an image, as before.
206
+ * `true` draws each slide as an image (`scale`). Default false, a vector PDF: shapes, gradients and patterns as PDF vector graphics,
207
+ * pictures as images, text as real text with embedded font subsets (selectable, searchable, correct copy and paste); the fonts are
208
+ * the bundled files, the `fonts` handle's `fontFiles` and the font folders you supply, never system fonts.
206
209
  */
207
- mode?: "vector" | "raster";
208
- /** Vector mode paints the page this colour first (default white, as raster mode composites on white); `"none"` leaves it unpainted. */
210
+ raster?: boolean;
211
+ /** A vector PDF paints the page this colour first (default white, as a raster PDF composites on white); `"none"` leaves it unpainted. */
209
212
  background?: string;
210
213
  /** Vector only. Family drawn for the generic `serif`; default `defaultFontFamily`. */
211
214
  serifFamily?: string;
@@ -219,8 +222,8 @@ export interface SvgToPdfOptions extends SvgToPngOptions {
219
222
  strict?: boolean;
220
223
  /** Vector only. Pixel density of the rare element rasterized because it has no vector form; default 2. */
221
224
  rasterFallbackScale?: number;
222
- /** Vector only. Receives font embedding reports, substitutions, fallbacks and unsupported features. */
223
- onDiagnostic?: (diagnostic: PdfDiagnostic) => void;
225
+ /** Vector only: font embedding reports, substitutions, fallbacks and unsupported features; given a deck, also the diagnostics of drawing it. */
226
+ onDiagnostic?: (diagnostic: PdfDiagnostic | RenderDiagnostic) => void;
224
227
  /** Vector only. Checked between pages; an abort rejects with the signal's reason. */
225
228
  signal?: AbortSignal;
226
229
  /** Vector only. Called after each page is written. */
@@ -278,14 +281,32 @@ export declare class OPFRenderError extends Error {
278
281
  constructor(code: string, message: string, details?: Record<string, unknown>);
279
282
  }
280
283
 
281
- export declare function resolvePresentation(input: unknown, options?: RenderSvgOptions): ResolvedPresentation;
284
+ export declare function resolvePresentation(input: unknown, options?: ToSvgOptions): ResolvedPresentation;
282
285
 
283
- /** The SVG of every slide of the deck, in order. */
284
- export declare function renderSvg(input: unknown, options?: RenderSvgOptions): string[];
286
+ /** An SVG slide the renderer drew: its text, or its bytes. */
287
+ export type SvgSlide = string | Uint8Array;
285
288
 
286
- /** The SVG of one slide of the deck (`index` is zero-based; out of range throws `slide-index-out-of-range`). */
287
- export declare function renderSlideSvg(input: unknown, index: number, options?: RenderSvgOptions): string;
289
+ /**
290
+ * The deck as SVG (RR-73). `toSvg(deck)`: every slide, in order (without the hidden ones under `skipHidden`). `toSvg(deck, 3)`: the
291
+ * third slide (slides count from 1; out of range throws `slide-out-of-range`). `toSvg(deck, "1-3")` or `toSvg(deck, [1, 3])`: the
292
+ * selected slides (a malformed selection throws `invalid-slide-selection`). The second argument may be the options instead.
293
+ */
294
+ export declare function toSvg(input: unknown, options?: ToSvgOptions): string[];
295
+ export declare function toSvg(input: unknown, slide: number, options?: ToSvgOptions): string;
296
+ export declare function toSvg(input: unknown, slides: Exclude<SlideSelection, number>, options?: ToSvgOptions): string[];
288
297
 
289
- export declare function svgToPng(svg: string | Uint8Array, options?: SvgToPngOptions): Promise<Uint8Array>;
298
+ /**
299
+ * PNG (RR-73) of a deck, drawn with `toSvg` (the slide arguments are `toSvg`'s: one PNG for a slide number, a list otherwise), or of
300
+ * SVG the renderer drew (one SVG gives one PNG, a list a list). A slide selection with SVG input throws `invalid-conversion-option`.
301
+ */
302
+ export declare function toPng(svg: Uint8Array | `<${string}`, options?: ToPngOptions): Promise<Uint8Array>;
303
+ export declare function toPng(svgs: readonly SvgSlide[], options?: ToPngOptions): Promise<Uint8Array[]>;
304
+ export declare function toPng(deck: unknown, slide: number, options?: ToPngOptions): Promise<Uint8Array>;
305
+ export declare function toPng(deck: unknown, slides: Exclude<SlideSelection, number>, options?: ToPngOptions): Promise<Uint8Array[]>;
306
+ export declare function toPng(deck: object, options?: ToPngOptions): Promise<Uint8Array[]>;
307
+ /** A string is SVG when it starts with `<`, and the deck's JSON text otherwise. */
308
+ export declare function toPng(source: string, options?: ToPngOptions): Promise<Uint8Array | Uint8Array[]>;
290
309
 
291
- export declare function svgToPdf(svgs: string | Uint8Array | Array<string | Uint8Array>, options?: SvgToPdfOptions): Promise<Uint8Array>;
310
+ /** One PDF (RR-73), a page per slide: of a deck drawn with `toSvg` (every slide, or a selection: `toPdf(deck, "2-4")`), or of SVG slides. */
311
+ export declare function toPdf(source: unknown, options?: ToPdfOptions): Promise<Uint8Array>;
312
+ export declare function toPdf(deck: unknown, slides: SlideSelection, options?: ToPdfOptions): Promise<Uint8Array>;
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export * from './svg.js';
2
- export {svgToPng,svgToPdf} from './raster.js';
2
+ export {toPng,toPdf} from './raster.js';
@@ -1,6 +1,6 @@
1
1
  import { fontPolicyFor } from "@openpresentation/opf/font-policy";
2
2
  import { pickFace } from "./font-registry.js";
3
- import { renderSvg, resolvePresentation } from "./svg.js";
3
+ import { resolvePresentation, toSvg } from "./svg.js";
4
4
  export { lazyFontEntries, lazyFontList } from "./lazy-font-list.js";
5
5
 
6
6
  /**
@@ -17,7 +17,7 @@ const lc = value => String(value).trim().toLowerCase();
17
17
 
18
18
  /**
19
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 fonts handle
20
+ * `toSvg` options. The analysis supplies its own measurement and reports nothing: callbacks, the host's fonts handle
21
21
  * (measurement and embedded fonts), asset resolution and tracing are dropped.
22
22
  */
23
23
  const NOT_RESOLVE_OPTIONS = new Set(["fonts", "onDiagnostic", "trace", "glyphFallback", "imageResolver", "strictAssets", "signal"]);
@@ -27,10 +27,10 @@ function resolveOptionsOf(options) {
27
27
 
28
28
  /**
29
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,
30
+ * design overrides), as `toSvg` resolves them with the same `options` (`catalogs`, ...). This is the role families,
31
31
  * not what is drawn: use `presentationFaces` for that.
32
32
  *
33
- * A document that does not resolve throws the error `renderSvg` throws for it (an OPFRenderError such as
33
+ * A document that does not resolve throws the error `toSvg` throws for it (an OPFRenderError such as
34
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
35
  * `options.catalogs` needs those catalogs here too.
36
36
  */
@@ -57,7 +57,7 @@ function registryView({ faces, policy = "none", aliases = new Map(), fallbackFam
57
57
 
58
58
  /**
59
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
60
+ * the renderer's own layout and painting run with a recording measurement, so it follows exactly what `toSvg` measures
61
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
62
  * styles, header and footer furniture, chart labels, metrics, quotes and timelines, the code role only where code is
63
63
  * drawn, per-slide design overrides, font scheme and theme resolution, and script slots (a script run is requested in its
@@ -73,8 +73,8 @@ function registryView({ faces, policy = "none", aliases = new Map(), fallbackFam
73
73
  * family found. The result is the faces the registry paints, for example `Intos 700` for a 600-weight label of an Aptos
74
74
  * deck. A style the registry cannot resolve is left as named (drawing reports it).
75
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.
76
+ * `options` are the `toSvg` options the document resolves with (`catalogs`, `date`, `validate`, ...). A document that
77
+ * does not resolve throws what `toSvg` throws for it.
78
78
  * @param {unknown} presentation
79
79
  * @param {object} [options]
80
80
  * @param {{faces: Iterable<{family: string, weight: number, italic: boolean}>, policy?: "none"|"metric"|"visual", aliases?: ReadonlyMap<string, string>, fallbackFamily?: string}} [registry]
@@ -98,7 +98,7 @@ export function presentationFaces(presentation, options = {}, registry) {
98
98
  measure(text, size, style) { if (text !== "") record(settle(style)); return String(text).length * size * RECORDED_EM; },
99
99
  resolveStyle: settle,
100
100
  };
101
- renderSvg(presentation, { ...resolveOptionsOf(options), fonts: { textMeasurement } });
101
+ toSvg(presentation, { ...resolveOptionsOf(options), fonts: { textMeasurement } });
102
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
103
  }
104
104
 
@@ -106,8 +106,8 @@ export function presentationFaces(presentation, options = {}, registry) {
106
106
  * The vendored faces (of `lazy`) a presentation draws that the registry does not hold: what a registry loads before it can
107
107
  * paint the document as Node paints it with every vendored face loaded. `held` lists the registry's faces
108
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.
109
+ * registry's. `renderOptions` are the `toSvg` options the document resolves with (`catalogs`, ...), and a document that
110
+ * does not resolve throws what `toSvg` throws for it. Face level: a bold or italic run added by an edit adds just that face.
111
111
  */
112
112
  export function lazyFacesNeeded(presentation, renderOptions, { lazy, held, loaded = new Set(), policy = "none", aliases = new Map(), fallbackFamily }) {
113
113
  const heldFaces = [...held], heldKeys = new Set(heldFaces.map(faceKeyOf));
@@ -0,0 +1,24 @@
1
+ // render#199: the files the Node entries read from disk (harfbuzzjs's .wasm, the installed `@expo-google-fonts/*` packages and this
2
+ // package's vendored `fonts/` directory) are located at run time, by Node's own resolution from this file, in a form bundlers leave
3
+ // alone. A bundler (Next.js Turbopack, webpack) treats `require.resolve` on `createRequire(import.meta.url)` as a module import: a
4
+ // literal .wasm specifier is compiled as a WebAssembly module (which fails `next build`), a computed one (`${name}/package.json`)
5
+ // becomes a stub that always throws, and `new URL(`../${dir}/`, import.meta.url)` becomes the URL of an unrelated emitted asset.
6
+ // Here `createRequire` is reached through the module namespace and the specifier is a variable behind the bundlers' ignore comments,
7
+ // so neither is traced, and the package root is computed from this file's path. Plain Node resolves exactly as before.
8
+ import * as nodeModule from "node:module";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ /** This package's root directory (the parent of `dist/`). */
13
+ export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
14
+
15
+ const requireHere = nodeModule.createRequire(import.meta.url);
16
+
17
+ /**
18
+ * The absolute path of an installed file (`harfbuzzjs/dist/harfbuzz.wasm`, `@expo-google-fonts/roboto/package.json`), resolved as
19
+ * `require.resolve` resolves it from this package, or from `directory` when given. Throws `MODULE_NOT_FOUND` like `require.resolve`.
20
+ */
21
+ export function resolveInstalled(specifier, directory) {
22
+ const resolver = directory === undefined ? requireHere : nodeModule.createRequire(path.join(directory, "package.json"));
23
+ return resolver.resolve(/* webpackIgnore: true */ /* turbopackIgnore: true */ specifier);
24
+ }
package/dist/pdf-fonts.js CHANGED
@@ -4,7 +4,7 @@ import { isTrueTypeOutlines, subsetTrueType } from "./pdf-subset.js";
4
4
  import { hex, name, num, sha256, textString, utf16Hex } from "./pdf-writer.js";
5
5
 
6
6
  // Font selection, shaping and embedding for the vector PDF export (RR-12). Fonts come only from the faces the
7
- // caller supplies (the same bundled font files, `fontFiles` and `fontDirs` the PNG preview draws with, and the
7
+ // caller supplies (the same bundled font files, fonts handle or font folders the PNG preview draws with, and the
8
8
  // `@font-face` data of the SVG itself); nothing is read from the system and nothing is fetched. A face whose
9
9
  // OS/2 embedding bits forbid embedding is never embedded.
10
10
 
package/dist/pdf-text.js CHANGED
@@ -7,8 +7,8 @@ import { attributesOf, inheritStyle, parseFontFamilies, parseFontWeight, parseLe
7
7
  // SVG text layout for the vector PDF export (RR-12): the renderer has already decided every line, position and
8
8
  // width (x, y, text-anchor, textLength), so this module only turns that into positioned glyph runs: text chunks,
9
9
  // the Unicode bidirectional algorithm (the SVG carries directional isolates around right-to-left paragraphs),
10
- // per-character font fallback, OpenType shaping with fontkit and textLength adjustment. It never wraps, shrinks
11
- // or truncates text.
10
+ // per-character font fallback, OpenType shaping (HarfBuzz through `env.shape` when the fonts handle has it, fontkit
11
+ // otherwise) and textLength adjustment. It never wraps, shrinks or truncates text.
12
12
 
13
13
  const bidi = bidiFactory();
14
14
  const IGNORED = /[⁦-⁩‎‏‪-‮؜]/u;
@@ -245,8 +245,8 @@ function shapeRun(run, env) {
245
245
  const size = parseFloat(style["font-size"]) || 16;
246
246
  const direction = run.level % 2 === 1 ? "rtl" : "ltr";
247
247
  const language = openTypeLanguage(style.lang ?? style["xml:lang"]);
248
- // RR-64: outlined text is shaped by HarfBuzz (env.shape), the browser's shaper; the vector PDF keeps fontkit.
249
- // HarfBuzz takes the BCP 47 tag (`lang`); fontkit the OpenType language system (`language`).
248
+ // RR-64, opf-render#188: outlined text and the vector PDF are shaped by HarfBuzz (env.shape), the browser's shaper, when the
249
+ // fonts handle carries it; fontkit otherwise. HarfBuzz takes the BCP 47 tag (`lang`); fontkit the OpenType language system (`language`).
250
250
  const glyphs = (env.shape ?? shape)(run.face, run.text, { features: featuresOf(style), language, lang: style.lang ?? style["xml:lang"], direction });
251
251
  const scale = size / run.face.upem;
252
252
  const letterSpacing = parseLength(style["letter-spacing"], { fontSize: size }) ?? 0;
@@ -81,6 +81,8 @@ class Converter {
81
81
  this.env = {
82
82
  fonts: this.fonts,
83
83
  defaults: this.defaults,
84
+ // opf-render#188: HarfBuzz, the browser's shaper, from the fonts handle (`fonts.outlines.shape`); fontkit without one.
85
+ ...(typeof options.shape === "function" ? { shape: options.shape } : {}),
84
86
  diagnostic: (d) => this.diagnostic(d),
85
87
  // A feature the PDF cannot give: reported once per character and family, fatal under `strict`.
86
88
  missing: (d) => {
@@ -89,7 +91,7 @@ class Converter {
89
91
  this.reported.add(key);
90
92
  this.unsupported(d.code, d.message, { fontFamily: d.fontFamily, codePoint: d.codePoint });
91
93
  },
92
- unavailable: (families) => this.error("pdf-font-unavailable", `No embeddable font face is available for ${families.length ? `'${families.join("', '")}'` : "the requested text"}. Supply font files (fontFiles/fontDirs) or keep useBundledFonts enabled.`, {}),
94
+ unavailable: (families) => this.error("pdf-font-unavailable", `No embeddable font face is available for ${families.length ? `'${families.join("', '")}'` : "the requested text"}. Pass the fonts handle or font folders as fonts, or keep useBundledFonts enabled.`, {}),
93
95
  };
94
96
  }
95
97
 
@@ -797,16 +799,24 @@ class Converter {
797
799
  // The text object starts at the glyph's natural position (PDFium merges an /ActualText span across adjacent objects only),
798
800
  // and the font's horizontal placement is a leading adjustment inside it.
799
801
  const shift = glyph.xOffset ? [num(-glyph.xOffset * 1000 / upem, 3)] : [];
800
- operations.push({ at: [run.x + run.scaleX * penBefore, run.y - glyph.yOffset * scale], items: [...shift, `<${code}>`] });
802
+ // A glyph shown alone only because it follows a placed one keeps the baseline (the glyphs after it continue on its line).
803
+ operations.push({ at: [run.x + run.scaleX * penBefore, run.y - (placed ? glyph.yOffset * scale : 0)], items: [...shift, `<${code}>`] });
801
804
  current = null;
802
- pending = 0;
805
+ // After a glyph that is not itself placed, the text continues from it: what its shaped advance (and its placement)
806
+ // differs from the font's width moves the next glyph (opf-render#188: a zero-width joiner after a mark put the rest of
807
+ // the run its nominal width off). After a placed glyph the next one is placed by its own Td.
808
+ pending = placed ? 0 : nominal - shaped + glyph.xOffset * 1000 / upem;
803
809
  placedBefore = placed;
804
810
  return;
805
811
  }
806
812
  if (current === null) { current = { items: [] }; operations.push(current); }
813
+ // A small horizontal placement (at most the threshold above) moves the glyph inside the TJ array and back after it
814
+ // (opf-render#188: it was dropped, which put an Arabic or Telugu glyph up to 0.025 em off).
815
+ const shift = glyph.xOffset * 1000 / upem;
816
+ pending -= shift;
807
817
  addNumber();
808
818
  hex += code;
809
- pending += nominal - shaped;
819
+ pending += nominal - shaped + shift;
810
820
  };
811
821
  // Each unit is a glyph, or for a left-to-right run the glyph map cannot give back (reordered Indic or Khmer clusters, a
812
822
  // no-break space) a cluster of glyphs. A unit whose text the map cannot give carries it as /ActualText, one span per
@@ -1228,7 +1238,7 @@ async function listFonts(directory, { readdir, path }) {
1228
1238
  }
1229
1239
 
1230
1240
  /**
1231
- * Convert SVG slides to a vector PDF. `options` carries the resolved conversion settings (see svgToPdf in raster.js).
1241
+ * Convert SVG slides to a vector PDF. `options` carries the resolved conversion settings (see toPdf in raster.js).
1232
1242
  */
1233
1243
  export async function svgsToVectorPdf(svgs, options) {
1234
1244
  const converter = new Converter(options);
@@ -1,4 +1,4 @@
1
- import pako from "pako";
1
+ import { deflate } from "pako";
2
2
  import { sha256Bytes } from "./sha256.js";
3
3
 
4
4
  // A small PDF object writer for the vector export (RR-12). Everything it emits is a pure function of the objects
@@ -62,8 +62,11 @@ export function ref(number) {
62
62
  return `${number} 0 R`;
63
63
  }
64
64
 
65
+ // legacyHash keeps the classic zlib hash (canonical zlib bytes, as pako 1 wrote them). pako 3 defaults to the faster hash
66
+ // of Chromium zlib (Node), which writes different and about 1% larger streams: the inflated content is the same, the
67
+ // PDF bytes and their hashes are not.
65
68
  export function flate(bytes) {
66
- return pako.deflate(bytes, { level: 9 });
69
+ return deflate(bytes, { level: 9, legacyHash: true });
67
70
  }
68
71
 
69
72
  export function sha256(bytes) {
package/dist/pdf.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { svgToPdf } from "./index.js";
2
- export type { SvgToPdfOptions, PdfDiagnostic, PdfMetadata } from "./index.js";
1
+ export { toPdf } from "./index.js";
2
+ export type { ToPdfOptions, PdfDiagnostic, PdfMetadata } from "./index.js";
package/dist/pdf.js CHANGED
@@ -1,4 +1,4 @@
1
- // RR-63: the PDF format entry (Node). The default vector `svgToPdf` needs no converter for text and shapes; it loads the optional peer
2
- // `sharp` for pictures and `@resvg/resvg-js` for the rare element it rasterizes, and `mode: "raster"` also loads `pdf-lib`. A converter
1
+ // RR-63: the PDF format entry (Node). The default vector `toPdf` needs no converter for text and shapes; it loads the optional peer
2
+ // `sharp` for pictures and `@resvg/resvg-js` for the rare element it rasterizes, and `raster: true` also loads `pdf-lib`. A converter
3
3
  // that is not installed rejects with `OPFRenderError` code `converter-missing`. For a page use `@openpresentation/opf-render/export-browser`.
4
- export { svgToPdf } from "./raster.js";
4
+ export { toPdf } from "./raster.js";
package/dist/player.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { BrowserFontsHandle } from "./fonts-browser.js";
2
- import type { RenderSvgOptions } from "./svg.js";
2
+ import type { ToSvgOptions } from "./svg.js";
3
3
 
4
4
  /**
5
5
  * Why a deck could not be loaded, drawn or presented. `code` is stable: `invalid-document`, `fetch-failed`, `fonts-unavailable`,
@@ -19,8 +19,8 @@ export type BlankMode = "none" | "black" | "white";
19
19
  export interface PresentOptions {
20
20
  /** The self-hosted font root URL (see `copyPreviewFonts`), or a browser fonts handle the page already has (`loadFonts` from `/fonts-browser`). Without it layout uses estimated widths and system fonts. */
21
21
  fonts?: string | BrowserFontsHandle;
22
- /** Extra `renderSlideSvg` options (catalogs, imageResolver, date, ...). */
23
- renderOptions?: Partial<RenderSvgOptions>;
22
+ /** Extra `toSvg` options (catalogs, imageResolver, date, ...). */
23
+ renderOptions?: Partial<ToSvgOptions>;
24
24
  /** 1-based slide to start on, in the sequence that plays. Default 1. */
25
25
  startSlide?: number;
26
26
  /** Play hidden slides too. Default false. */
package/dist/player.js CHANGED
@@ -35,7 +35,10 @@ const AUDIENCE_CSS = `
35
35
  .goto[hidden] { display: none; }
36
36
  .sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
37
37
  .msg { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; text-align: center; }
38
- @media (forced-colors: active) { .hud button { border-color: ButtonText; } }
38
+ @media (forced-colors: active) {
39
+ .hud button { border-color: ButtonText; }
40
+ .hud button[aria-disabled="true"] { opacity: 1; color: GrayText; border-color: GrayText; }
41
+ }
39
42
  `;
40
43
 
41
44
  const PRESENTER_CSS = `
@@ -63,7 +66,10 @@ button[aria-pressed="true"] { background: #3d5a99; }
63
66
  button[aria-disabled="true"] { opacity: 0.45; cursor: default; }
64
67
  .sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
65
68
  @media (max-width: 760px) { main { grid-template-columns: 1fr; overflow: auto; } }
66
- @media (forced-colors: active) { button, .notes, .slidebox { border-color: ButtonText; } }
69
+ @media (forced-colors: active) {
70
+ button, .notes, .slidebox { border-color: ButtonText; }
71
+ button[aria-disabled="true"] { opacity: 1; color: GrayText; border-color: GrayText; }
72
+ }
67
73
  `;
68
74
 
69
75
  /** Document indexes in the presentation order, from the options. */
@@ -632,7 +638,7 @@ async function readSource(source, signal) {
632
638
  * player covers the page at once (and asks for full screen), then resolves with the running session when the first slide is
633
639
  * drawn. Call it from a click or key handler: full screen and the speaker view both need a user gesture.
634
640
  *
635
- * Options: `fonts` (the self-hosted font root URL, or a browser fonts handle from `loadFonts`), `renderOptions` (extra `renderSlideSvg` options),
641
+ * Options: `fonts` (the self-hosted font root URL, or a browser fonts handle from `loadFonts`), `renderOptions` (extra `toSvg` options),
636
642
  * `startSlide` (1-based), `includeHidden`, `fullscreen` (default true), `presenterView` (open the speaker view too),
637
643
  * `role: "presenter"` (this window is the speaker view: for a second tab or window of your page), `channel` (name, or `false`
638
644
  * for no sync), `container`, `signal` and `now` (an injectable clock).
package/dist/png.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { svgToPng } from "./index.js";
2
- export type { SvgToPngOptions } from "./index.js";
1
+ export { toPng } from "./index.js";
2
+ export type { ToPngOptions } from "./index.js";
package/dist/png.js CHANGED
@@ -1,4 +1,4 @@
1
- // RR-63: the PNG format entry (Node). `svgToPng` loads the optional peers `@resvg/resvg-js` (and `sharp` for WebP and rotated JPEG
1
+ // RR-63: the PNG format entry (Node). `toPng` loads the optional peers `@resvg/resvg-js` (and `sharp` for WebP and rotated JPEG
2
2
  // pictures) on first use and rejects with `OPFRenderError` code `converter-missing` when they are not installed. For a page use
3
3
  // `@openpresentation/opf-render/export-browser`.
4
- export { svgToPng } from "./raster.js";
4
+ export { toPng } from "./raster.js";
@@ -5,16 +5,14 @@
5
5
  import crypto from "node:crypto";
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
- import { createRequire } from "node:module";
9
- import { fileURLToPath } from "node:url";
10
8
  import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
11
9
  import { previewFontLayout } from "./preview-fonts.js";
12
10
  import { scriptFontPackages } from "./script-font-pack.js";
11
+ import { packageRoot as rendererRoot, resolveInstalled } from "./node-resolve.js";
13
12
 
14
13
  const ALLOWED_LICENSES = ["OFL-1.1", "Apache-2.0", "MIT", "UFL-1.0"];
15
14
  const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
16
15
  const safeFile = /^[A-Za-z0-9_./-]+$/;
17
- const rendererRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
18
16
 
19
17
  function within(directory, file) {
20
18
  const resolved = path.resolve(directory, file);
@@ -23,9 +21,8 @@ function within(directory, file) {
23
21
  }
24
22
 
25
23
  function resolvePackageDirectory(name, from) {
26
- const searched = [createRequire(path.join(rendererRoot, "package.json")), createRequire(path.join(from ?? process.cwd(), "package.json"))];
27
- for (const requireFrom of searched) {
28
- try { return path.dirname(requireFrom.resolve(`${name}/package.json`)); } catch { /* try the next location */ }
24
+ for (const directory of [rendererRoot, from ?? process.cwd()]) {
25
+ try { return path.dirname(resolveInstalled(`${name}/package.json`, directory)); } catch { /* try the next location */ }
29
26
  }
30
27
  return undefined;
31
28
  }
@@ -9,7 +9,9 @@
9
9
  // For the SVG about to be rasterized (never the emitted SVG), every <text> whose content has characters of an affected script (the
10
10
  // Indic scripts, Thai, Lao, Khmer, Myanmar), or Hangul in a KOR-language text, becomes a <g> that carries the text's attributes and
11
11
  // draws its content cluster by cluster at absolute positions:
12
- // - the x of each cluster is the fontkit advance of the prefix of its run, measured with the face resvg will pick for the run
12
+ // - the x of each cluster is the fontkit pen at its first character in its run shaped as a whole (opf-render#189: a prefix shaped
13
+ // alone loses the context of the next character, such as a Thai leading vowel kerned to its consonant; the prefix's advance is used
14
+ // where the run's glyphs do not map back to its characters, or the run has a kinzi), measured with the face resvg will pick for the run
13
15
  // (fontdb's family, style and weight matching over the same font files, with the same glyph fallback order), the OpenType language
14
16
  // of the SVG's lang and the policy features, scaled to the run's textLength when it has one; text-anchor middle and end are
15
17
  // resolved into absolute starts, baseline-shift into the y;
@@ -27,6 +29,7 @@
27
29
  import { readFile } from "node:fs/promises";
28
30
  import { create } from "fontkit";
29
31
  import { disabledFeaturesFor } from "./font-compatibility.js";
32
+ import { pinGlyphCodePoints } from "./font-registry.js";
30
33
  import { openTypeLanguage } from "./script-fonts.js";
31
34
 
32
35
  const AFFECTED = /[\p{scx=Devanagari}\p{scx=Bengali}\p{scx=Gurmukhi}\p{scx=Gujarati}\p{scx=Oriya}\p{scx=Tamil}\p{scx=Telugu}\p{scx=Kannada}\p{scx=Malayalam}\p{scx=Sinhala}\p{scx=Thai}\p{scx=Lao}\p{scx=Khmer}\p{scx=Myanmar}]/u;
@@ -353,6 +356,8 @@ async function readFace(file) {
353
356
  const data = new Uint8Array(await readFile(file));
354
357
  const font = create(data);
355
358
  if (!font?.layout || !font.unitsPerEm) return null;
359
+ // opf-render#125: an outline drawn for one cluster (ऱ, whose component is the nukta glyph) must not change how a later nukta shapes.
360
+ pinGlyphCodePoints(font);
356
361
  const records = font.name?.records ?? {};
357
362
  const names = Object.values(records.preferredFamily ?? {}).length ? Object.values(records.preferredFamily) : Object.values(records.fontFamily ?? {});
358
363
  const os2 = font["OS/2"], selection = os2?.fsSelection;
@@ -433,16 +438,42 @@ function measurePrefixes(runs, ends, props) {
433
438
  };
434
439
  const widths = [];
435
440
  try {
441
+ const pens = new Map();
442
+ // opf-render#189: the pen at a cluster boundary inside a run is read from the run shaped as a whole, so an advance its context
443
+ // changes (a Thai leading vowel kerned to its consonant) is the one the browser draws; a prefix shaped alone ends without it.
444
+ const prefix = (run, length) => {
445
+ if (!pens.has(run)) pens.set(run, contextPens(run.face, run.text, props));
446
+ return pens.get(run)?.get(length) ?? width(run.face, run.text.slice(0, length));
447
+ };
436
448
  let runIndex = 0, before = 0;
437
449
  for (const end of ends) {
438
450
  while (runIndex < runs.length && runs[runIndex].start + runs[runIndex].text.length <= end) { before += width(runs[runIndex].face, runs[runIndex].text); runIndex++; }
439
451
  const run = runs[runIndex];
440
- widths.push(run && end > run.start ? before + width(run.face, run.text.slice(0, end - run.start)) : before);
452
+ widths.push(run && end > run.start ? before + prefix(run, end - run.start) : before);
441
453
  }
442
454
  } catch { return null; }
443
455
  return widths;
444
456
  }
445
457
 
458
+ /**
459
+ * The pen (px) at each character offset of `value` where a glyph carrying characters starts, with `value` shaped as one run: the sum
460
+ * of the advances of the glyphs before it. Null for text with a kinzi (measured without it, so its offsets are not the text's).
461
+ */
462
+ function contextPens(face, value, props) {
463
+ if (value.includes(KINZI)) return null;
464
+ const { run } = shapeRun(face, value, props);
465
+ const pens = new Map(), scale = props.fontSize / face.font.unitsPerEm;
466
+ let consumed = 0, pen = 0;
467
+ for (const [index, glyph] of run.glyphs.entries()) {
468
+ if (glyph.codePoints?.length) {
469
+ if (!pens.has(consumed)) pens.set(consumed, pen * scale);
470
+ for (const point of glyph.codePoints) consumed += point > 0xffff ? 2 : 1;
471
+ }
472
+ pen += run.positions[index].xAdvance;
473
+ }
474
+ return consumed === value.length ? pens : null;
475
+ }
476
+
446
477
  /** The glyph outlines of one cluster, or null when fontkit needed the mark retry (its glyph placement is then not the browser's). */
447
478
  function shapeOutlines(face, value, props) {
448
479
  let shaped;