@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/README.md +85 -7
- package/dist/element.js +14 -7
- package/dist/export-browser.js +2 -0
- package/dist/font-manifest.js +17 -17
- package/dist/font-registry.js +31 -0
- package/dist/font-subset.js +7 -1
- package/dist/fonts-browser.d.ts +3 -2
- package/dist/fonts-node.d.ts +6 -2
- package/dist/fonts-node.js +20 -17
- package/dist/fonts.d.ts +9 -1
- package/dist/hb-shape.js +16 -6
- package/dist/node-resolve.js +24 -0
- package/dist/pdf-text.js +4 -4
- package/dist/pdf-vector.js +13 -3
- package/dist/pdf-writer.js +5 -2
- package/dist/player.js +10 -3
- package/dist/preview-fonts-node.js +3 -6
- package/dist/raster-text.js +102 -12
- package/dist/raster.js +20 -12
- package/dist/script-fonts.js +11 -2
- package/dist/svg.js +5 -1
- package/dist/text-paths.js +13 -1
- package/package.json +23 -20
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 {
|
|
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
|
-
/**
|
|
74
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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;
|
package/dist/pdf-vector.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/dist/pdf-writer.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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
|
|
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) {
|
|
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) {
|
|
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
|
|
27
|
-
|
|
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
|
}
|
package/dist/raster-text.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
57
|
-
|
|
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 +=
|
|
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 +
|
|
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
|
|
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,
|
|
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 }))),
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
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) {
|
package/dist/script-fonts.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/dist/text-paths.js
CHANGED
|
@@ -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: (
|
|
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
|
+
}
|