@openpresentation/opf-render 0.18.0 → 0.18.2

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/fonts.d.ts CHANGED
@@ -57,7 +57,15 @@ export interface RenderFonts {
57
57
  /** Cuts an embedded face to code points (RR-65), with hb-subset; a face whose license, Reserved Font Name or fsType does not allow it keeps its own data URL. */
58
58
  export interface FontSubsets { subsetDataUrl(font: EmbeddedFont, codePoints: Iterable<number>): string }
59
59
  /** Draws a slide's text as glyph outlines (RR-64). Read by `toSvg`; hosts pass the handle and `text: "paths"`. */
60
- export interface TextOutlines { outlineSlideText(content: string[], options: object): { content: string[]; defs: string } }
60
+ export interface TextOutlines {
61
+ outlineSlideText(content: string[], options: object): { content: string[]; defs: string };
62
+ /**
63
+ * HarfBuzz shaping of one run of a face (opf-render#188), present when the handle has the shaper (the Node handle always, a browser
64
+ * handle with `shapeWasm`). Read by `toPdf`, which then shapes the vector PDF's text as the browser shapes it, and by `toPng` and
65
+ * the raster PDF, which pin complex-script clusters at its pens (opf-render#189); fontkit otherwise.
66
+ */
67
+ shape?(face: { hash: string; data: Uint8Array }, text: string, options?: { direction?: "ltr" | "rtl"; lang?: string; features?: Record<string, boolean> }): { gid: number; codePoints: number[]; advance: number; xOffset: number; yOffset: number }[];
68
+ }
61
69
  /** What `fonts.ensure(presentation)` loaded: the script packages and vendored faces that were missing, and drawn CJK characters no face covers. */
62
70
  export interface EnsureResult { scripts: string[]; lazy: LazyFont[]; uncovered: string[] }
63
71
  /** The fonts handle `loadFonts()` returns from `/fonts-node` and `/fonts-browser`: pass it as `{ fonts }` to every deck-level function. */
package/dist/hb-shape.js CHANGED
@@ -70,8 +70,13 @@ function shaperOver(instance, state) {
70
70
  return font;
71
71
  };
72
72
  return {
73
- /** Shape `text` with the face (`key` names it, `data` is its bytes); `features` is an object of OpenType feature booleans. */
74
- shape(key, data, text, { direction = "ltr", language, features } = {}) {
73
+ /**
74
+ * Shape `text` with the face (`key` names it, `data` is its bytes); `features` is an object of OpenType feature booleans.
75
+ * `clusterLevel` is HarfBuzz's cluster level (0 by default: a mark joins its base's cluster and a reordered syllable is one
76
+ * cluster; 2 keeps each character with the glyph it became). It changes only which glyph carries which characters, never the
77
+ * glyphs or their positions.
78
+ */
79
+ shape(key, data, text, { direction = "ltr", language, features, clusterLevel } = {}) {
75
80
  const font = fontFor(key, data);
76
81
  const buffer = hb.hb_buffer_create();
77
82
  const units = text.length, textPointer = hb.malloc(units * 2 + 2);
@@ -81,6 +86,7 @@ function shaperOver(instance, state) {
81
86
  try {
82
87
  hb.hb_buffer_add_utf16(buffer, textPointer, units, 0, units);
83
88
  hb.hb_buffer_set_direction(buffer, HB_DIRECTION[direction] ?? HB_DIRECTION.ltr);
89
+ if (clusterLevel) hb.hb_buffer_set_cluster_level(buffer, clusterLevel);
84
90
  if (language) { const tag = string(language); allocated.push(tag.pointer); hb.hb_buffer_set_language(buffer, hb.hb_language_from_string(tag.pointer, tag.length)); }
85
91
  hb.hb_buffer_guess_segment_properties(buffer);
86
92
  const entries = Object.entries(features ?? {});
@@ -102,12 +108,16 @@ function shaperOver(instance, state) {
102
108
  const info = infos + index * INFO_SIZE, position = positions + index * POSITION_SIZE;
103
109
  glyphs.push({ gid: data32.getUint32(info, true), cluster: data32.getUint32(info + 8, true), advance: data32.getInt32(position, true), xOffset: data32.getInt32(position + 8, true), yOffset: data32.getInt32(position + 12, true) });
104
110
  }
105
- // The characters of each cluster go to its first glyph in output order; later glyphs of the cluster carry none.
111
+ // The characters of each cluster go to one of its glyphs: the first in logical order (output order, reversed right to left)
112
+ // that advances the pen, else the first; its other glyphs carry none (a mark or dot the font split off a letter).
106
113
  const starts = [...new Set(glyphs.map((glyph) => glyph.cluster))].sort((a, b) => a - b);
107
- const seen = new Set();
114
+ const carriers = new Map();
115
+ for (const glyph of direction === "rtl" ? [...glyphs].reverse() : glyphs) {
116
+ const carrier = carriers.get(glyph.cluster);
117
+ if (!carrier || (carrier.advance === 0 && glyph.advance !== 0)) carriers.set(glyph.cluster, glyph);
118
+ }
108
119
  for (const glyph of glyphs) {
109
- if (seen.has(glyph.cluster)) { glyph.codePoints = []; continue; }
110
- seen.add(glyph.cluster);
120
+ if (carriers.get(glyph.cluster) !== glyph) { glyph.codePoints = []; continue; }
111
121
  const end = starts[starts.indexOf(glyph.cluster) + 1] ?? units;
112
122
  glyph.codePoints = [...text.slice(glyph.cluster, end)].map((character) => character.codePointAt(0));
113
123
  }
@@ -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-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) => {
@@ -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
@@ -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/player.js CHANGED
@@ -11,7 +11,7 @@ import { loadPreviewFonts } from "./preview-fonts.js";
11
11
  export { DeckError } from "./deck-runtime.js";
12
12
 
13
13
  const AUDIENCE_CSS = `
14
- :host { all: initial; }
14
+ :host { all: initial; color-scheme: inherit; }
15
15
  * { box-sizing: border-box; }
16
16
  [hidden] { display: none !important; }
17
17
  .player { position: fixed; inset: 0; z-index: 2147483000; background: #000; color: #fff; font: 15px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; outline: none; overflow: hidden; touch-action: none; user-select: none; -webkit-user-select: none; }
@@ -35,7 +35,11 @@ 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:focus-visible { outline-color: Highlight; }
41
+ .hud button[aria-disabled="true"] { opacity: 1; color: GrayText; border-color: GrayText; }
42
+ }
39
43
  `;
40
44
 
41
45
  const PRESENTER_CSS = `
@@ -63,7 +67,10 @@ button[aria-pressed="true"] { background: #3d5a99; }
63
67
  button[aria-disabled="true"] { opacity: 0.45; cursor: default; }
64
68
  .sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
65
69
  @media (max-width: 760px) { main { grid-template-columns: 1fr; overflow: auto; } }
66
- @media (forced-colors: active) { button, .notes, .slidebox { border-color: ButtonText; } }
70
+ @media (forced-colors: active) {
71
+ button, .notes, .slidebox { border-color: ButtonText; }
72
+ button[aria-disabled="true"] { opacity: 1; color: GrayText; border-color: GrayText; }
73
+ }
67
74
  `;
68
75
 
69
76
  /** Document indexes in the presentation order, from the options. */
@@ -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,10 +9,14 @@
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
- // resolved into absolute starts, baseline-shift into the y;
17
+ // resolved into absolute starts, baseline-shift into the y. When the conversion has HarfBuzz (`shape`, the fonts handle's
18
+ // `outlines.shape`), the pens are HarfBuzz's, the browser's shaper (fontkit kerns after a decomposed Thai sara am differently);
19
+ // fontkit's otherwise;
16
20
  // - a cluster of a script whose fontkit glyph runs equal HarfBuzz's in the corpus (Devanagari, Gujarati, Oriya, Tamil, Kannada,
17
21
  // Sinhala) is drawn as fontkit's glyph outlines (<path> per glyph), so a conjunct under a pre-base vowel is the browser's;
18
22
  // - any other cluster (Bengali, Gurmukhi, Telugu, Malayalam, Thai, Lao, Khmer and Myanmar, where fontkit orders marks differently
@@ -27,7 +31,9 @@
27
31
  import { readFile } from "node:fs/promises";
28
32
  import { create } from "fontkit";
29
33
  import { disabledFeaturesFor } from "./font-compatibility.js";
34
+ import { pinGlyphCodePoints } from "./font-registry.js";
30
35
  import { openTypeLanguage } from "./script-fonts.js";
36
+ import { sha256Bytes } from "./sha256.js";
31
37
 
32
38
  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;
33
39
  // Scripts whose fontkit glyph runs equal HarfBuzz's for every corpus sample (glyph ids, order and advances, no retry): drawn as outlines.
@@ -53,8 +59,11 @@ const PAINT = new Set(["fill", "fill-opacity", "fill-rule", "opacity", "stroke",
53
59
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
54
60
  const faceCache = new Map();
55
61
 
56
- /** Rewrites complex-script text of `svg` into per-cluster positioned pieces for resvg (see the file comment). */
57
- export async function pinScriptClusters(svg, { fontFiles = [], defaultFontFamily = "Roboto", sansSerifFamily, serifFamily, monospaceFamily, cursiveFamily, fantasyFamily } = {}) {
62
+ /**
63
+ * Rewrites complex-script text of `svg` into per-cluster positioned pieces for resvg (see the file comment). `shape` is the fonts
64
+ * handle's HarfBuzz shaper (`outlines.shape`): the clusters are then pinned at HarfBuzz's pens, else at fontkit's.
65
+ */
66
+ export async function pinScriptClusters(svg, { fontFiles = [], defaultFontFamily = "Roboto", sansSerifFamily, serifFamily, monospaceFamily, cursiveFamily, fantasyFamily, shape } = {}) {
58
67
  if (!AFFECTED.test(svg) && !HANGUL.test(svg)) return svg;
59
68
  // resvg-js names for the generic families (its own defaults where rasterizeSvg sets none) and for text without a font-family.
60
69
  const generics = {
@@ -78,7 +87,7 @@ export async function pinScriptClusters(svg, { fontFiles = [], defaultFontFamily
78
87
  const inner = svg.slice(tokens.lastIndex, closeIndex);
79
88
  tokens.lastIndex = closeIndex + "</text>".length;
80
89
  if (!needsRewrite(inner, props)) continue;
81
- const rewritten = rewriteText({ attrs, attributes, inner, props }, await database(), generics);
90
+ const rewritten = rewriteText({ attrs, attributes, inner, props }, await database(), generics, typeof shape === "function" ? shape : undefined);
82
91
  if (rewritten === null) continue;
83
92
  output += svg.slice(end, match.index) + rewritten;
84
93
  end = tokens.lastIndex;
@@ -215,7 +224,7 @@ export function clusterPieces(text, pinAll = false) {
215
224
  return pieces;
216
225
  }
217
226
 
218
- function rewriteText(text, db, generics) {
227
+ function rewriteText(text, db, generics, shape) {
219
228
  const root = parseInner(text.inner);
220
229
  if (!root) return null;
221
230
  const rootProps = text.props;
@@ -260,7 +269,7 @@ function rewriteText(text, db, generics) {
260
269
  const base = selectFace(db, leaf.props, generics);
261
270
  if (!base) return null;
262
271
  leaf.runs = faceRuns(db, base, leaf.value);
263
- const widths = measurePrefixes(leaf.runs, leaf.pieces.map(piece => piece.end), leaf.props);
272
+ const widths = measurePrefixes(leaf.runs, leaf.pieces.map(piece => piece.end), leaf.props, shape);
264
273
  if (!widths) return null;
265
274
  let previous = 0;
266
275
  for (const [index, piece] of leaf.pieces.entries()) { piece.width = widths[index] - previous; previous = widths[index]; }
@@ -353,11 +362,13 @@ async function readFace(file) {
353
362
  const data = new Uint8Array(await readFile(file));
354
363
  const font = create(data);
355
364
  if (!font?.layout || !font.unitsPerEm) return null;
365
+ // opf-render#125: an outline drawn for one cluster (ऱ, whose component is the nukta glyph) must not change how a later nukta shapes.
366
+ pinGlyphCodePoints(font);
356
367
  const records = font.name?.records ?? {};
357
368
  const names = Object.values(records.preferredFamily ?? {}).length ? Object.values(records.preferredFamily) : Object.values(records.fontFamily ?? {});
358
369
  const os2 = font["OS/2"], selection = os2?.fsSelection;
359
370
  return {
360
- file, font, families: new Set(names.filter(name => typeof name === "string")), outlines: new Map(),
371
+ file, font, data, families: new Set(names.filter(name => typeof name === "string")), outlines: new Map(),
361
372
  weight: os2?.usWeightClass ?? 400, stretch: os2?.usWidthClass ?? 5,
362
373
  style: selection?.italic || (!os2 && font.head?.macStyle?.italic) ? "italic" : selection?.oblique ? "oblique" : "normal"
363
374
  };
@@ -423,8 +434,8 @@ function shapeRun(face, value, props) {
423
434
  catch { return { run: layout({ abvm: false, blwm: false, mark: false, mkmk: false }), retry: true }; }
424
435
  }
425
436
 
426
- /** Advance widths (px) of the prefixes of the runs' text ending at each offset of `ends`. */
427
- function measurePrefixes(runs, ends, props) {
437
+ /** Advance widths (px) of the prefixes of the runs' text ending at each offset of `ends`, at HarfBuzz's pens when `shape` is given. */
438
+ function measurePrefixes(runs, ends, props, shape) {
428
439
  const width = (face, value) => {
429
440
  if (!value) return 0;
430
441
  // The Myanmar kinzi is a zero-advance mark in HarfBuzz; fontkit's universal shaper keeps the nga's advance, so it is not measured.
@@ -433,16 +444,95 @@ function measurePrefixes(runs, ends, props) {
433
444
  };
434
445
  const widths = [];
435
446
  try {
447
+ const pens = new Map();
448
+ // 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
449
+ // changes (a Thai leading vowel kerned to its consonant) is the one the browser draws; a prefix shaped alone ends without it.
450
+ // With the conversion's HarfBuzz (opf-render#189 follow-up), a run's pens are HarfBuzz's when it gives every boundary the run
451
+ // needs; a run it does not is measured with fontkit throughout, so no run mixes the two shapers.
452
+ const harfBuzz = new Map();
453
+ if (shape) for (const run of runs) {
454
+ const found = harfBuzzPens(run.face, run.text, props, shape);
455
+ if (found && ends.every(end => end <= run.start || end >= run.start + run.text.length || found.has(end - run.start))) harfBuzz.set(run, found);
456
+ }
457
+ const prefix = (run, length) => {
458
+ const shaped = harfBuzz.get(run)?.get(length);
459
+ if (shaped !== undefined) return shaped;
460
+ if (!pens.has(run)) pens.set(run, contextPens(run.face, run.text, props));
461
+ return pens.get(run)?.get(length) ?? width(run.face, run.text.slice(0, length));
462
+ };
463
+ const whole = run => harfBuzz.get(run)?.get(run.text.length) ?? width(run.face, run.text);
436
464
  let runIndex = 0, before = 0;
437
465
  for (const end of ends) {
438
- while (runIndex < runs.length && runs[runIndex].start + runs[runIndex].text.length <= end) { before += width(runs[runIndex].face, runs[runIndex].text); runIndex++; }
466
+ while (runIndex < runs.length && runs[runIndex].start + runs[runIndex].text.length <= end) { before += whole(runs[runIndex]); runIndex++; }
439
467
  const run = runs[runIndex];
440
- widths.push(run && end > run.start ? before + width(run.face, run.text.slice(0, end - run.start)) : before);
468
+ widths.push(run && end > run.start ? before + prefix(run, end - run.start) : before);
441
469
  }
442
470
  } catch { return null; }
443
471
  return widths;
444
472
  }
445
473
 
474
+ /**
475
+ * The pen (px) at each character offset of `value` where a glyph carrying characters starts, with `value` shaped as one run: the sum
476
+ * 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).
477
+ */
478
+ function contextPens(face, value, props) {
479
+ if (value.includes(KINZI)) return null;
480
+ const { run } = shapeRun(face, value, props);
481
+ const pens = new Map(), scale = props.fontSize / face.font.unitsPerEm;
482
+ let consumed = 0, pen = 0;
483
+ for (const [index, glyph] of run.glyphs.entries()) {
484
+ if (glyph.codePoints?.length) {
485
+ if (!pens.has(consumed)) pens.set(consumed, pen * scale);
486
+ for (const point of glyph.codePoints) consumed += point > 0xffff ? 2 : 1;
487
+ }
488
+ pen += run.positions[index].xAdvance;
489
+ }
490
+ return consumed === value.length ? pens : null;
491
+ }
492
+
493
+ /**
494
+ * HarfBuzz's pen (px) at each character offset of `value`, shaped as one run by `shape` (the fonts handle's `outlines.shape`), where
495
+ * the glyphs before it carry exactly the characters before it (a syllable boundary, also when HarfBuzz reorders inside a syllable),
496
+ * and at the end of the run. Null when HarfBuzz cannot shape the run or its glyphs do not carry its characters.
497
+ */
498
+ function harfBuzzPens(face, value, props, shape) {
499
+ let glyphs;
500
+ try {
501
+ face.harfBuzz ??= { hash: hexDigest(sha256Bytes(face.data)), data: face.data };
502
+ const off = disabledFeaturesFor([...face.families][0]);
503
+ glyphs = shape(face.harfBuzz, value, { direction: "ltr", lang: props.lang, ...(off ? { features: Object.fromEntries(off.map(tag => [tag, false])) } : {}) });
504
+ } catch { return null; }
505
+ if (!Array.isArray(glyphs)) return null;
506
+ const pens = new Map(), scale = props.fontSize / face.font.unitsPerEm;
507
+ // How many more times each character was carried by the glyphs so far than it occurs in the text before `consumed`; `unmatched`
508
+ // counts the characters where that is not zero, so it is zero exactly where the glyphs so far carry the text before `consumed`.
509
+ const balance = new Map();
510
+ let consumed = 0, unmatched = 0, pen = 0;
511
+ const count = (character, step) => {
512
+ const before = balance.get(character) ?? 0, after = before + step;
513
+ if (!before) unmatched++;
514
+ if (!after) { unmatched--; balance.delete(character); } else balance.set(character, after);
515
+ };
516
+ for (const glyph of glyphs) {
517
+ if (glyph.codePoints?.length) {
518
+ if (!unmatched && !pens.has(consumed)) pens.set(consumed, pen * scale);
519
+ for (const point of glyph.codePoints) {
520
+ if (consumed >= value.length) return null;
521
+ const own = String.fromCodePoint(value.codePointAt(consumed));
522
+ count(String.fromCodePoint(point), 1);
523
+ count(own, -1);
524
+ consumed += own.length;
525
+ }
526
+ }
527
+ pen += glyph.advance;
528
+ }
529
+ if (consumed !== value.length || unmatched) return null;
530
+ pens.set(value.length, pen * scale);
531
+ return pens;
532
+ }
533
+
534
+ const hexDigest = bytes => Array.from(bytes, byte => byte.toString(16).toUpperCase().padStart(2, "0")).join("");
535
+
446
536
  /** The glyph outlines of one cluster, or null when fontkit needed the mark retry (its glyph placement is then not the browser's). */
447
537
  function shapeOutlines(face, value, props) {
448
538
  let shaped;
package/dist/raster.js CHANGED
@@ -8,7 +8,7 @@ import {loadConverter} from './converters.js';
8
8
  const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
9
9
  const DEFAULT_RASTER_SCALE = 1;
10
10
  const DEFAULT_RASTER_BACKGROUND = "#FFFFFF";
11
- let bundledFontFilesCache=null;
11
+ let bundledFontsCache=null;
12
12
 
13
13
  // The faces a raster or PDF conversion draws with (RR-74: one `fonts`). The fonts handle (`loadFonts()` from `/fonts-node`) gives its
14
14
  // `fontFiles`, whether the bundled base faces are added (`useBundledFonts`, default true; a Node handle already holds its own, so it
@@ -82,12 +82,16 @@ async function vectorPdf(inputs, options) {
82
82
  ...(settings.useBundledFonts ? await bundledFontFiles() : []),
83
83
  ...settings.fontFiles
84
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);
85
88
  const svgs = [];
86
89
  for (const input of inputs) svgs.push(await prepareRasterImages(normalizeSvgInput(input)));
87
90
  const {svgsToVectorPdf} = await import("./pdf-vector.js");
88
91
  return svgsToVectorPdf(svgs, {
89
92
  fontFiles,
90
93
  fontDirs: settings.fontDirs,
94
+ shape,
91
95
  defaultFontFamily: options.defaultFontFamily ?? "Roboto",
92
96
  sansSerifFamily: options.sansSerifFamily ?? options.defaultFontFamily ?? "Roboto",
93
97
  monospaceFamily: options.monospaceFamily ?? "Roboto Mono",
@@ -147,9 +151,13 @@ async function rasterizeSvg(svgInput, options) {
147
151
 
148
152
  if (options.dpi !== undefined) renderOptions.dpi = positiveNumber(options.dpi, "dpi", 96);
149
153
 
154
+ // opf-render#189: complex-script clusters are pinned at HarfBuzz's pens (the browser's shaper) through the fonts handle's shaper,
155
+ // or that of the bundled handle the conversion draws with; at fontkit's when neither has one.
156
+ const shape = options.fonts?.outlines?.shape ?? (settings.useBundledFonts ? (await bundledFonts()).outlines?.shape : undefined);
157
+ const pinning = { ...font, shape };
150
158
  // An SVG used as an image draws its text with these fonts: resvg gives the nested document none.
151
159
  const nestedSvg = async text => {
152
- const pinned = await pinScriptClusters(text, font);
160
+ const pinned = await pinScriptClusters(text, pinning);
153
161
  const probe = new Resvg(pinned, { font, logLevel: "off" });
154
162
  const zoom = Math.min(4, Math.max(1, 2048 / Math.max(probe.width, probe.height)));
155
163
  return new Resvg(pinned, { ...renderOptions, fitTo: { mode: "zoom", value: zoom }, background: "rgba(0, 0, 0, 0)" }).render().asPng();
@@ -157,7 +165,7 @@ async function rasterizeSvg(svgInput, options) {
157
165
  // FF-31: resvg ignores the SVG's ligature properties, so separate the letters a Gelasio ligature would join.
158
166
  // FF-45: colour families (Noto Color Emoji) are drawn with their monochrome stand-in, which resvg can draw.
159
167
  // FF-44: resvg loses the advance of a vowel sign or space inside a complex-script cluster, so pin each cluster (raster-text.js).
160
- const svg = await pinScriptClusters(separateLigatures(monochromeColorFonts(await prepareRasterImages(normalizeSvgInput(svgInput), { nestedSvg }))), font);
168
+ const svg = await pinScriptClusters(separateLigatures(monochromeColorFonts(await prepareRasterImages(normalizeSvgInput(svgInput), { nestedSvg }))), pinning);
161
169
 
162
170
  try {
163
171
  const image = new Resvg(svg, renderOptions).render();
@@ -177,18 +185,18 @@ const loadResvg = () => loadConverter("@resvg/resvg-js");
177
185
  const loadPdfLib = () => loadConverter("pdf-lib");
178
186
 
179
187
  async function bundledFontFiles() {
180
- if (!bundledFontFilesCache) {
181
- bundledFontFilesCache = resolveBundledFontFiles().catch(error => {
182
- bundledFontFilesCache = null;
188
+ return (await bundledFonts()).fontFiles;
189
+ }
190
+
191
+ // The bundled base handle (`loadFonts()`), loaded once: its font files, and its shaper for the vector PDF.
192
+ function bundledFonts() {
193
+ if (!bundledFontsCache) {
194
+ bundledFontsCache = import('./fonts-node.js').then(({loadFonts}) => loadFonts()).catch(error => {
195
+ bundledFontsCache = null;
183
196
  throw error;
184
197
  });
185
198
  }
186
- return bundledFontFilesCache;
187
- }
188
-
189
- async function resolveBundledFontFiles() {
190
- const {loadFonts} = await import('./fonts-node.js');
191
- return (await loadFonts()).fontFiles;
199
+ return bundledFontsCache;
192
200
  }
193
201
 
194
202
  function normalizeSvgInput(value) {
@@ -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"];
@@ -613,6 +615,13 @@ export function createScriptFonts(profile = {}, measurement, options = {}) {
613
615
  const push = (text, family, stack, sizeAdjust, ascent) => {
614
616
  const own = family === style.fontFamily && (!stack || stack.length === 1);
615
617
  const last = out.at(-1);
618
+ // opf#361: white space that follows a size-adjusted run (the space between two Hangul words of Malgun Gothic) stays in that run,
619
+ // as PowerPoint keeps it in the script font, so it is measured and drawn at the run's adjusted size. Left as its own run at the
620
+ // composed size between two adjusted runs, a browser drew it wider than its measured advance.
621
+ if (last?.sizeAdjust && !sizeAdjust) {
622
+ const space = /^\s+/u.exec(text)?.[0];
623
+ if (space) { last.text += space; text = text.slice(space.length); if (!text) return; }
624
+ }
616
625
  if (last && last.family === family && last.sizeAdjust === sizeAdjust && last.lineAscent === ascent?.lineAscent && JSON.stringify(last.stack) === JSON.stringify(stack)) last.text += text;
617
626
  else out.push({text, family, own, ...(stack && !own ? {stack} : {}), ...(sizeAdjust ? {sizeAdjust} : {}), ...(ascent ?? {})});
618
627
  };
package/dist/svg.js CHANGED
@@ -19,6 +19,7 @@ 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";
@@ -616,9 +617,12 @@ function bindSlide(presentation, index, context) {
616
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
617
618
  // resolves it again and draws the replacement's encoded weight (Montserrat 900, Red Hat Display 600 and 300); resolving the role to the
618
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).
619
623
  for (const role of ["heading","body","code","accent"]) if (design.fonts[role] !== undefined) {
620
624
  const named = design.fonts[role], resolved = resolveTextStyle({fontFamily:named,fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
621
- 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;
622
626
  }
623
627
  const geometry = composeSlide(slide, { ...slideContext.options, fontFamilies: design.fonts, textRasterPadding:context.options.textRasterPadding, textMeasurement });
624
628
  return {
@@ -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: () => {},
@@ -326,5 +326,17 @@ export function textOutlines(fonts) {
326
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
+ }