@openpresentation/opf-render 0.16.0 → 0.17.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.
@@ -0,0 +1,121 @@
1
+ // RR-64 phase 2: HarfBuzz shaping for text drawn as outlines. Browsers shape with HarfBuzz, and fontkit differs from it on some
2
+ // scripts (Thai tone marks, Bengali and Khmer conjunct forms, Myanmar medials, Telugu offsets, Hebrew points, Japanese combining
3
+ // sound marks), so outlines shaped by HarfBuzz are what the browser draws. This drives `harfbuzz.wasm` of the pinned harfbuzzjs
4
+ // package (MIT) through HarfBuzz's C API. The module needs only five small host functions, so it is instantiated synchronously
5
+ // from its bytes and the renderer stays synchronous. Glyphs come back in the shape `pdf-fonts.js` `shape()` returns: left to
6
+ // right, `{gid, codePoints, advance, xOffset, yOffset}` in font units.
7
+
8
+ const HB_MEMORY_MODE_READONLY = 1;
9
+ const HB_DIRECTION = { ltr: 4, rtl: 5 };
10
+ const INFO_SIZE = 20, POSITION_SIZE = 20, FEATURE_SIZE = 16;
11
+
12
+ // The five host functions the module imports, bound to the instance's memory once it exists.
13
+ function hostImports() {
14
+ const state = { memory: null };
15
+ const imports = {
16
+ env: {
17
+ // Emscripten grows the heap through the host: grow the memory to the requested size.
18
+ emscripten_resize_heap: (requested) => {
19
+ try {
20
+ const missing = (requested >>> 0) - state.memory.buffer.byteLength;
21
+ if (missing > 0) state.memory.grow(Math.ceil(missing / 65536));
22
+ return 1;
23
+ } catch { return 0; }
24
+ },
25
+ _abort_js: () => { throw new Error("HarfBuzz aborted."); },
26
+ _setitimer_js: () => 0,
27
+ _emscripten_runtime_keepalive_clear: () => {},
28
+ },
29
+ wasi_snapshot_preview1: { proc_exit: (code) => { throw new Error(`HarfBuzz exited (${code}).`); } },
30
+ };
31
+ return { imports, state };
32
+ }
33
+
34
+ /** A synchronous HarfBuzz shaper over `harfbuzz.wasm` (its bytes or a compiled WebAssembly.Module): Node. */
35
+ export function createShaper(wasm) {
36
+ const { imports, state } = hostImports();
37
+ return shaperOver(new WebAssembly.Instance(wasm instanceof WebAssembly.Module ? wasm : new WebAssembly.Module(wasm), imports), state);
38
+ }
39
+
40
+ /** The same shaper, instantiated asynchronously (a browser may refuse to compile a large module synchronously). */
41
+ export async function instantiateShaper(wasm) {
42
+ const { imports, state } = hostImports();
43
+ const result = await WebAssembly.instantiate(wasm, imports);
44
+ return shaperOver(result instanceof WebAssembly.Instance ? result : result.instance, state);
45
+ }
46
+
47
+ function shaperOver(instance, state) {
48
+ const hb = instance.exports;
49
+ const memory = state.memory = hb.memory;
50
+ hb.__wasm_call_ctors?.();
51
+ const fonts = new Map();
52
+ const utf8 = new TextEncoder();
53
+ const string = (text) => {
54
+ const bytes = utf8.encode(text), pointer = hb.malloc(bytes.length + 1);
55
+ const view = new Uint8Array(memory.buffer, pointer, bytes.length + 1);
56
+ view.set(bytes);
57
+ view[bytes.length] = 0;
58
+ return { pointer, length: bytes.length };
59
+ };
60
+ // One HarfBuzz font per face, kept for the shaper's lifetime (faces are few and shaped again and again).
61
+ const fontFor = (key, data) => {
62
+ let font = fonts.get(key);
63
+ if (font) return font;
64
+ const pointer = hb.malloc(data.length);
65
+ new Uint8Array(memory.buffer, pointer, data.length).set(data);
66
+ const blob = hb.hb_blob_create(pointer, data.length, HB_MEMORY_MODE_READONLY, 0, 0);
67
+ const face = hb.hb_face_create(blob, 0);
68
+ font = hb.hb_font_create(face);
69
+ fonts.set(key, font);
70
+ return font;
71
+ };
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 } = {}) {
75
+ const font = fontFor(key, data);
76
+ const buffer = hb.hb_buffer_create();
77
+ const units = text.length, textPointer = hb.malloc(units * 2 + 2);
78
+ const view = new Uint16Array(memory.buffer, textPointer, units);
79
+ for (let index = 0; index < units; index++) view[index] = text.charCodeAt(index);
80
+ const allocated = [textPointer];
81
+ try {
82
+ hb.hb_buffer_add_utf16(buffer, textPointer, units, 0, units);
83
+ hb.hb_buffer_set_direction(buffer, HB_DIRECTION[direction] ?? HB_DIRECTION.ltr);
84
+ 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
+ hb.hb_buffer_guess_segment_properties(buffer);
86
+ const entries = Object.entries(features ?? {});
87
+ let featurePointer = 0;
88
+ if (entries.length) {
89
+ featurePointer = hb.malloc(entries.length * FEATURE_SIZE);
90
+ allocated.push(featurePointer);
91
+ entries.forEach(([tag, on], index) => { const spec = string(`${on ? "+" : "-"}${tag}`); allocated.push(spec.pointer); hb.hb_feature_from_string(spec.pointer, spec.length, featurePointer + index * FEATURE_SIZE); });
92
+ }
93
+ hb.hb_shape(font, buffer, featurePointer, entries.length);
94
+ const lengthPointer = hb.malloc(4);
95
+ allocated.push(lengthPointer);
96
+ const infos = hb.hb_buffer_get_glyph_infos(buffer, lengthPointer);
97
+ const count = new Uint32Array(memory.buffer, lengthPointer, 1)[0];
98
+ const positions = hb.hb_buffer_get_glyph_positions(buffer, 0);
99
+ const data32 = new DataView(memory.buffer);
100
+ const glyphs = [];
101
+ for (let index = 0; index < count; index++) {
102
+ const info = infos + index * INFO_SIZE, position = positions + index * POSITION_SIZE;
103
+ 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
+ }
105
+ // The characters of each cluster go to its first glyph in output order; later glyphs of the cluster carry none.
106
+ const starts = [...new Set(glyphs.map((glyph) => glyph.cluster))].sort((a, b) => a - b);
107
+ const seen = new Set();
108
+ for (const glyph of glyphs) {
109
+ if (seen.has(glyph.cluster)) { glyph.codePoints = []; continue; }
110
+ seen.add(glyph.cluster);
111
+ const end = starts[starts.indexOf(glyph.cluster) + 1] ?? units;
112
+ glyph.codePoints = [...text.slice(glyph.cluster, end)].map((character) => character.codePointAt(0));
113
+ }
114
+ return glyphs.map(({ gid, codePoints, advance, xOffset, yOffset }) => ({ gid, codePoints, advance, xOffset, yOffset }));
115
+ } finally {
116
+ hb.hb_buffer_destroy(buffer);
117
+ for (const pointer of allocated) hb.free(pointer);
118
+ }
119
+ },
120
+ };
121
+ }
package/dist/index.d.ts CHANGED
@@ -60,6 +60,28 @@ export interface RenderSvgOptions {
60
60
  fonts?: RenderFonts;
61
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
62
  embedFonts?: boolean;
63
+ /**
64
+ * `true` draws every `<text>` as glyph outlines (RR-64): `<use>` of outlines kept once per slide in `<defs>`, so the SVG needs
65
+ * 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
+ * faces). Needs the fonts handle `loadFonts()` returns (`text-as-paths-needs-fonts` otherwise). The text is no longer text: it
67
+ * cannot be edited in place. The glyphs are shaped by HarfBuzz (the browser's shaper; the Node handle always, a browser handle
68
+ * given `shapeWasm`), are hidden from assistive technology, and each outlined line is also invisible real text pinned to the
69
+ * drawn width, so screen readers, selection, copy and find-in-page get the words. Each element keeps its `data-opf-*` trace.
70
+ * A run stays text, reported as `text-as-paths-fallback` with a `reason`, when its font is a colour or bitmap font
71
+ * (`"colour-font"`), its face's fsType restricts embedding (`"restricted"`), or it has no pinned width and HarfBuzz and the
72
+ * 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.
74
+ */
75
+ textAsPaths?: boolean;
76
+ /**
77
+ * `false` embeds whole faces (RR-65). By default, with a fonts handle that carries `subsets` (the Node `loadFonts` handle, a browser
78
+ * handle given `subsetWasm`), each face an SVG embeds is cut to the characters the slide draws, keeping every layout feature, so the
79
+ * browser draws it exactly like the whole face (over the core example decks a slide's SVG falls from 2.4 MB to 59 KB on average).
80
+ * Faces whose license does not allow a modified version under their own name (Carlito, Raleway, Lora, Playfair Display, PT Serif,
81
+ * Merriweather Sans, Source Sans 3), whose fsType forbids subsetting, or that a host supplied, are always embedded whole. Set it to
82
+ * false for an SVG whose text will be edited outside the renderer.
83
+ */
84
+ subsetFonts?: boolean;
63
85
  /** `"chain"` (default): a character the resolved face lacks is drawn with the first bundled face that has it, and reported as `font-glyph-fallback`. `"none"`: exact faces, a missing glyph raises `missing-glyph`. */
64
86
  glyphFallback?: "chain" | "none";
65
87
  /** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */
package/dist/pdf-text.js CHANGED
@@ -59,7 +59,7 @@ export function layoutText(textNode, env, parentStyle, link) {
59
59
  const preserve = own["xml:space"] === "preserve" || /^pre/.test(own["white-space"] ?? "");
60
60
  for (const child of node.children) {
61
61
  if (child.text !== undefined) {
62
- for (const character of child.text.replace(/[\r\n\t]/g, " ")) chars.push({ ch: character, style: own, scope: nextScope, decoration: nextDecoration, link: activeLink, preserve });
62
+ for (const character of child.text.replace(/[\r\n\t]/g, " ")) chars.push({ ch: character, style: own, scope: nextScope, decoration: nextDecoration, link: activeLink, preserve, owner: node });
63
63
  } else if (isElement(child)) {
64
64
  if (child.name === "tspan" || child.name === "textPath" || child.name === "tref") walk(child, own, nextScope, nextDecoration, activeLink);
65
65
  else if (child.name === "a") {
@@ -89,7 +89,8 @@ export function layoutText(textNode, env, parentStyle, link) {
89
89
  const styleIds = new Map();
90
90
  const faceCache = new Map();
91
91
  const linkIds = new Map();
92
- const runsByChunk = chunks.map((chunk) => buildRuns(chunk, env, styleIds, faceCache, linkIds));
92
+ const ownerIds = new Map();
93
+ const runsByChunk = chunks.map((chunk) => buildRuns(chunk, env, styleIds, faceCache, linkIds, ownerIds));
93
94
 
94
95
  // textLength: the renderer gives lines and tabs an exact width; reproduce it per scope over all its runs.
95
96
  const scopeWidth = new Map();
@@ -155,7 +156,7 @@ function collapseWhitespace(chars) {
155
156
  for (const char of kept) chars.push(char);
156
157
  }
157
158
 
158
- function buildRuns(chunk, env, styleIds, faceCache, linkIds) {
159
+ function buildRuns(chunk, env, styleIds, faceCache, linkIds, ownerIds) {
159
160
  const text = chunk.chars.map((char) => char.ch).join("");
160
161
  const baseDirection = chunk.chars[0].style.direction === "rtl" ? "rtl" : "ltr";
161
162
  // Levels per UTF-16 unit; characters take the level of their first unit.
@@ -165,7 +166,7 @@ function buildRuns(chunk, env, styleIds, faceCache, linkIds) {
165
166
  for (const char of chunk.chars) { levels.push(embedding.levels[unit]); unit += char.ch.length; }
166
167
  const mirrored = bidi.getMirroredCharactersMap(text, embedding.levels);
167
168
  const runs = [];
168
- const chunkInfo = { logical: chunk.chars.filter((char) => !IGNORED.test(char.ch)).map((char) => char.ch).join("") };
169
+ const chunkInfo = { logical: chunk.chars.filter((char) => !IGNORED.test(char.ch)).map((char) => char.ch).join(""), rtl: baseDirection === "rtl" || /^[⁧‫‮]/.test(text) };
169
170
  let current = null;
170
171
  unit = 0;
171
172
  chunk.chars.forEach((char, index) => {
@@ -174,9 +175,12 @@ function buildRuns(chunk, env, styleIds, faceCache, linkIds) {
174
175
  if (IGNORED.test(char.ch)) return;
175
176
  const level = levels[index];
176
177
  const resolved = pickFace(char, env, styleIds, faceCache);
177
- const key = `${resolved.styleId}|${resolved.face.id}|${level}|${char.scope?.id ?? 0}|${linkId(linkIds, char.link)}|${[...char.decoration].sort().join(",")}`;
178
+ // RR-64: outlined text keeps each element's trace, so with `env.splitByOwner` a run never spans two elements.
179
+ const owner = env.splitByOwner ? linkId(ownerIds, { node: char.owner }) : 0;
180
+ const key = `${resolved.styleId}|${resolved.face.id}|${level}|${char.scope?.id ?? 0}|${linkId(linkIds, char.link)}|${[...char.decoration].sort().join(",")}|${owner}`;
178
181
  if (!current || current.key !== key) {
179
- current = { chunk: chunkInfo, key, face: resolved.face, style: char.style, level, scope: char.scope, link: char.link, decoration: char.decoration, chars: [], text: "", logical: "", weight: resolved.weight, italic: resolved.italic };
182
+ // `start`: the run's first character in logical order, so a reader can put runs back in reading order (RR-64).
183
+ current = { chunk: chunkInfo, start: index, key, face: resolved.face, style: char.style, level, scope: char.scope, link: char.link, decoration: char.decoration, owner: char.owner, chars: [], text: "", logical: "", weight: resolved.weight, italic: resolved.italic };
180
184
  runs.push(current);
181
185
  }
182
186
  current.logical += char.ch;
@@ -241,7 +245,9 @@ function shapeRun(run, env) {
241
245
  const size = parseFloat(style["font-size"]) || 16;
242
246
  const direction = run.level % 2 === 1 ? "rtl" : "ltr";
243
247
  const language = openTypeLanguage(style.lang ?? style["xml:lang"]);
244
- const glyphs = shape(run.face, run.text, { features: featuresOf(style), language, direction });
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`).
250
+ const glyphs = (env.shape ?? shape)(run.face, run.text, { features: featuresOf(style), language, lang: style.lang ?? style["xml:lang"], direction });
245
251
  const scale = size / run.face.upem;
246
252
  const letterSpacing = parseLength(style["letter-spacing"], { fontSize: size }) ?? 0;
247
253
  const wordSpacing = parseLength(style["word-spacing"], { fontSize: size }) ?? 0;
@@ -744,6 +744,9 @@ class Converter {
744
744
  run.fill = await this.paint(run.style.fill, run.style, ctx, box, "fill");
745
745
  run.stroke = await this.paint(run.style.stroke, run.style, ctx, box, "stroke");
746
746
  }
747
+ // Text with neither fill nor stroke draws nothing (an outlined slide's invisible readable layer, RR-64): it adds no font,
748
+ // no glyph and no structure to the PDF.
749
+ if (!runs.some((run) => run.fill || run.stroke)) return;
747
750
  // Runs are drawn in visual (left to right) order. Right-to-left runs are marked /ReversedChars so extractors read
748
751
  // their characters back to front, and a cluster whose glyph-to-Unicode map cannot give its text (a ligature, a
749
752
  // letter split into base and dots, a mirrored bracket) carries /ActualText; a left-to-right run the map cannot
@@ -54,28 +54,23 @@ export function scriptPackageEntries(packages, {baseUrl}) {
54
54
  // Everywhere: the schema URL, embedded assets, catalog records, speaker notes, ids, image alt text and sources,
55
55
  // links, metadata and typed `extensions` passthrough. On the presentation: name (aria-label fallback), description,
56
56
  // filename, author, audience, purpose, tone, takeaway, duration, tags, narrative, language and version.
57
- // On a slide: beat. The organization (name), the speaker (name and title) and a slide's section are drawn only as
58
- // generated header/footer furniture (`organization: true` / `speaker: true` / `section: true` in a design), so they count
59
- // only when a design asks for them; built-in variables copy their values into slide strings before this runs. Everything else is drawn text: titles, text blocks, list items, runs, table cells, chart labels,
60
- // categories and series names, code, quotes, metrics, timelines, furniture text.
57
+ // On a slide: beat. The organization (name), the speaker (name and title) and a slide's section are drawn only through the
58
+ // built-in variables that read them ({{organization.name}}, {{speaker.title}}, {{slide.section}}, FA-31), so they count
59
+ // only when a string the preview draws holds such a token. Everything else is drawn text: titles, text blocks, list items,
60
+ // runs, table cells, chart labels, categories and series names, code, quotes, metrics, timelines, header and footer text.
61
61
  const UNDRAWN_KEYS = Object.freeze(["$schema", "assets", "catalogs", "notes", "id", "alt", "altText", "src", "url", "href", "link", "metadata", "extensions"]);
62
62
  const UNDRAWN_ROOT_KEYS = Object.freeze(["name", "description", "filename", "author", "authors", "creator", "audience", "purpose", "tone", "takeaway", "duration", "tags", "narrative", "language", "license", "version", "keywords"]);
63
63
  const UNDRAWN_SLIDE_KEYS = Object.freeze(["beat"]);
64
64
  const UNDRAWN_VALUE = /^(?:https?|data|blob|pkg|file|mailto):\S*$/i;
65
65
 
66
- /** True when a design in the presentation turns on generated `field` furniture (`organization`, `speaker` or `section`). */
67
- function furnitureUses(presentation, field) {
68
- const designs = [presentation?.design, ...(Array.isArray(presentation?.slides) ? presentation.slides.map(slide => slide?.design) : [])];
69
- const pattern = new RegExp(`"${field}"\\s*:\\s*true`);
70
- return designs.some(design => design && pattern.test(JSON.stringify(design)));
71
- }
66
+ // The built-in variables that copy a document value into drawn text: the inline token and the whole-field reference
67
+ // (a logo or photo reference draws a picture, not text).
68
+ const ORGANIZATION_TOKEN = /\{\{\s*organization\.|var:organization\.(?!logo\b)/;
69
+ const SPEAKER_TOKEN = /\{\{\s*speaker\.|var:speaker\.(?!photo\b|image\b|avatar\b)/;
70
+ const SECTION_TOKEN = /\{\{\s*slide\.section\s*[|}]/;
72
71
 
73
- /** The strings a preview of this presentation draws. */
74
- export function* drawnStrings(presentation) {
75
- const undrawn = new Set(UNDRAWN_KEYS), undrawnRoot = new Set(UNDRAWN_ROOT_KEYS), undrawnSlide = new Set(UNDRAWN_SLIDE_KEYS);
76
- if (!furnitureUses(presentation, "organization")) undrawnRoot.add("organization");
77
- if (!furnitureUses(presentation, "speaker")) undrawnRoot.add("speaker");
78
- if (!furnitureUses(presentation, "section")) undrawnSlide.add("section");
72
+ /** The strings under `presentation`, skipping the keys in the three sets (root keys, slide keys, and everywhere). */
73
+ function* walkStrings(presentation, undrawn, undrawnRoot, undrawnSlide) {
79
74
  function* visit(item, level) {
80
75
  if (typeof item === "string") { if (!UNDRAWN_VALUE.test(item)) yield item; }
81
76
  else if (Array.isArray(item)) for (const child of item) yield* visit(child, level === "slides" ? "slide" : level === "root" ? "deep" : level);
@@ -89,6 +84,18 @@ export function* drawnStrings(presentation) {
89
84
  yield* visit(presentation, "root");
90
85
  }
91
86
 
87
+ /** The strings a preview of this presentation draws. */
88
+ export function* drawnStrings(presentation) {
89
+ const undrawn = new Set(UNDRAWN_KEYS), undrawnRoot = new Set([...UNDRAWN_ROOT_KEYS, "organization", "speaker"]), undrawnSlide = new Set([...UNDRAWN_SLIDE_KEYS, "section"]);
90
+ // The organization, the speaker and a slide's section draw only where a drawn string reads them with a built-in variable.
91
+ for (const text of walkStrings(presentation, undrawn, undrawnRoot, undrawnSlide)) {
92
+ if (ORGANIZATION_TOKEN.test(text)) undrawnRoot.delete("organization");
93
+ if (SPEAKER_TOKEN.test(text)) undrawnRoot.delete("speaker");
94
+ if (SECTION_TOKEN.test(text)) undrawnSlide.delete("section");
95
+ }
96
+ yield* walkStrings(presentation, undrawn, undrawnRoot, undrawnSlide);
97
+ }
98
+
92
99
  /**
93
100
  * The script profile of a presentation: core `resolveScriptFonts` output, exactly what the renderer plans
94
101
  * with. When it throws, the renderer ignores the document language, so the profile is empty and Han text is
package/dist/svg.d.ts CHANGED
@@ -60,6 +60,28 @@ export interface RenderSvgOptions {
60
60
  fonts?: RenderFonts;
61
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
62
  embedFonts?: boolean;
63
+ /**
64
+ * `true` draws every `<text>` as glyph outlines (RR-64): `<use>` of outlines kept once per slide in `<defs>`, so the SVG needs
65
+ * 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
+ * faces). Needs the fonts handle `loadFonts()` returns (`text-as-paths-needs-fonts` otherwise). The text is no longer text: it
67
+ * cannot be edited in place. The glyphs are shaped by HarfBuzz (the browser's shaper; the Node handle always, a browser handle
68
+ * given `shapeWasm`), are hidden from assistive technology, and each outlined line is also invisible real text pinned to the
69
+ * drawn width, so screen readers, selection, copy and find-in-page get the words. Each element keeps its `data-opf-*` trace.
70
+ * A run stays text, reported as `text-as-paths-fallback` with a `reason`, when its font is a colour or bitmap font
71
+ * (`"colour-font"`), its face's fsType restricts embedding (`"restricted"`), or it has no pinned width and HarfBuzz and the
72
+ * 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.
74
+ */
75
+ textAsPaths?: boolean;
76
+ /**
77
+ * `false` embeds whole faces (RR-65). By default, with a fonts handle that carries `subsets` (the Node `loadFonts` handle, a browser
78
+ * handle given `subsetWasm`), each face an SVG embeds is cut to the characters the slide draws, keeping every layout feature, so the
79
+ * browser draws it exactly like the whole face (over the core example decks a slide's SVG falls from 2.4 MB to 59 KB on average).
80
+ * Faces whose license does not allow a modified version under their own name (Carlito, Raleway, Lora, Playfair Display, PT Serif,
81
+ * Merriweather Sans, Source Sans 3), whose fsType forbids subsetting, or that a host supplied, are always embedded whole. Set it to
82
+ * false for an SVG whose text will be edited outside the renderer.
83
+ */
84
+ subsetFonts?: boolean;
63
85
  /** `"chain"` (default): a character the resolved face lacks is drawn with the first bundled face that has it, and reported as `font-glyph-fallback`. `"none"`: exact faces, a missing glyph raises `missing-glyph`. */
64
86
  glyphFallback?: "chain" | "none";
65
87
  /** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */
package/dist/svg.js CHANGED
@@ -22,6 +22,8 @@ import { disabledFeaturesStyle } from "./font-compatibility.js";
22
22
  import { fontPolicyFor } from "@openpresentation/opf/font-policy";
23
23
  import { isDatasetChart, isDatasetTable, renderCatalogChart } from "./charts.js";
24
24
  import { renderCaption, renderFootnotes } from "./annotations.js";
25
+ import { drawnFaces } from "./drawn-faces.js";
26
+ import { dataUrlFsType, embeddingAllowed } from "./font-fstype.js";
25
27
 
26
28
  export const packageName = "@openpresentation/opf-render";
27
29
 
@@ -540,7 +542,7 @@ function resolveContext(presentation, index, context) {
540
542
  }
541
543
  }
542
544
 
543
- function bindSlide(presentation, slide, index, context) {
545
+ function bindSlide(presentation, index, context) {
544
546
  const slidePath = `slides.${index}`;
545
547
  // Core resolves the slide's canvas, layout, theme, colour scheme, font scheme and family names the one way every engine must
546
548
  // (slide design, deck design, theme, engine default), from the records the document embeds and the catalogs the host
@@ -548,6 +550,9 @@ function bindSlide(presentation, slide, index, context) {
548
550
  // `layout` composes automatically; one whose reference resolves nowhere does too, with an `unresolved-reference` diagnostic,
549
551
  // or the render fails under `strictReferences`.
550
552
  const slideContext = resolveContext(presentation, index, context);
553
+ // FA-31: every string the slide draws (titles, body text, table cells, notes) is the substituted slide: {{slide.number}}, {{slide.section}} and
554
+ // {{deck.slideCount}} hold this slide's number, its section and the deck's slide count, the same values the furniture draws. Core resolved them.
555
+ const slide = slideContext.slide;
551
556
  const layout = slideContext.options.layout;
552
557
  const placeholders = Array.isArray(layout?.placeholders) ? layout.placeholders : [];
553
558
  const titleBindings = [];
@@ -637,9 +642,15 @@ function bindSlide(presentation, slide, index, context) {
637
642
  // `options.fonts` is the handle `loadFonts()` returns (or any object with a `textMeasurement` and, to embed faces in the SVG, an
638
643
  // `embeddedFonts` list). The renderer reads the two as plain options from here on. `embedFonts: false` (RR-61) keeps the
639
644
  // measurement and drops the faces, for a host whose page already holds them.
645
+ // `textAsPaths: true` (RR-64) draws text as glyph outlines with the handle's `outlines` engine (a `loadFonts` handle carries one).
646
+ // `subsetFonts: false` (RR-65) embeds whole faces; by default a handle with a subset engine (`fonts.subsets`) cuts each embedded
647
+ // face to the characters the slide draws.
640
648
  function normalizeOptions(options) {
641
- const { fonts, embedFonts, ...rest } = options ?? {};
642
- return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: embedFonts === false ? undefined : fonts?.embeddedFonts };
649
+ const { fonts, embedFonts, textAsPaths, subsetFonts, ...rest } = options ?? {};
650
+ if (subsetFonts !== undefined && typeof subsetFonts !== "boolean") throw new OPFRenderError("invalid-render-options", "subsetFonts must be true or false.", { option: "subsetFonts" });
651
+ if (textAsPaths !== undefined && typeof textAsPaths !== "boolean") throw new OPFRenderError("invalid-render-options", "textAsPaths must be true or false.", { option: "textAsPaths" });
652
+ if (textAsPaths && typeof fonts?.outlines?.outlineSlideText !== "function") throw new OPFRenderError("text-as-paths-needs-fonts", "textAsPaths needs the fonts handle loadFonts() returns (from /fonts-node or /fonts-browser), passed as `fonts`.", { option: "textAsPaths" });
653
+ return { ...rest, textMeasurement: fonts?.textMeasurement, embeddedFonts: embedFonts === false ? undefined : fonts?.embeddedFonts, ...(textAsPaths ? { outlines: fonts.outlines } : {}), ...(subsetFonts !== false && fonts?.subsets ? { subsets: fonts.subsets } : {}) };
643
654
  }
644
655
 
645
656
  /**
@@ -657,7 +668,7 @@ function resolveDeck(input, options) {
657
668
  }
658
669
 
659
670
  const context = { presentation, options };
660
- const slides = presentation.slides.map((slide, index) => bindSlide(presentation, slide, index, context));
671
+ const slides = presentation.slides.map((_, index) => bindSlide(presentation, index, context));
661
672
 
662
673
  return {
663
674
  presentation,
@@ -700,7 +711,7 @@ function renderResolvedSlide(resolved, slideIndex, options) {
700
711
  const { width, height } = bound.design.dimensions;
701
712
  // The title may be TextRun[] (FA-10): the accessible name is its plain text.
702
713
  const title = (Array.isArray(bound.slide.title) ? flattenText(bound.slide.title) : bound.slide.title) ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
703
- const content = [
714
+ let content = [
704
715
  renderBackground(bound, width, height, options),
705
716
  renderBranding(bound, resolved.presentation, width, height, options),
706
717
  ...renderSlideContent(bound, width, height, options),
@@ -709,7 +720,14 @@ function renderResolvedSlide(resolved, slideIndex, options) {
709
720
  renderFurniture(bound, resolved.presentation, width, height, options, "header"),
710
721
  renderFurniture(bound, resolved.presentation, width, height, options, "footer")
711
722
  ].filter(Boolean);
712
- const children = [renderEmbeddedFonts(embeddedFontsFor(options.embeddedFonts, content)), ...content].filter(Boolean);
723
+ // RR-64: text as glyph outlines. The used-face rule below then embeds only the faces of text that stays text.
724
+ let glyphs = "";
725
+ if (options.outlines) {
726
+ const outlined = options.outlines.outlineSlideText(content, { rootStyle: lang ? { lang } : {}, report: diagnostic => reportDiagnostic(diagnostic, options), fail: (code, message) => new OPFRenderError(code, message, { slideIndex }) });
727
+ content = outlined.content;
728
+ glyphs = outlined.defs;
729
+ }
730
+ const children = [glyphs, renderEmbeddedFonts(embeddedFontsFor(options.embeddedFonts, content, options.subsets, diagnostic => reportDiagnostic(diagnostic, options))), ...content].filter(Boolean);
713
731
  // FF-44: Chromium (123+) trims adjacent fullwidth punctuation by default (CSS text-spacing-trim: normal; a sequence such as
714
732
  // 「」。 is up to 10 percent narrower), but measurement and PowerPoint advance every such character by its full width, so the browser
715
733
  // would stretch the glyphs back to the pinned textLength. space-all keeps the drawn advances equal to the measured ones. Only slides
@@ -1732,12 +1750,30 @@ function nestedReset(style, run) {
1732
1750
  return parent && !disabledFeaturesStyle([run.stack ?? run.family].flat()[0]) ? RESET_POLICY_FEATURES : undefined;
1733
1751
  }
1734
1752
 
1735
- function scriptLine(text, style, bound, type, { rtl = false, placement, trace, fontSize } = {}) {
1753
+ // RR-59 (#175): bidi rule L1 resets white space at the end of a line (with the isolate marks around it) to the paragraph level. A run
1754
+ // drawn as its own text chunk (a <text>, or a tspan with its own x) is such a line, and its paragraph is left to right, so the trailing
1755
+ // space of a right-to-left isolate drew at the run's right edge instead of its left, where it separates the run from the next one: at an
1756
+ // Arabic/Latin boundary the gap closed up (PowerPoint keeps it). A positioned run of a right-to-left line therefore draws without its
1757
+ // trailing white space, its glyphs starting after that white space's measured advance. Returns the white space ("" when there is none,
1758
+ // or when the text is all white space).
1759
+ function rtlTrailingSpace(text) {
1760
+ const found = /\S(\s+)$/u.exec(text);
1761
+ return found ? found[1] : "";
1762
+ }
1763
+
1764
+ /**
1765
+ * `runPlacement` ({x, width, fontSize}) positions the runs like `placement`, but only when the text plans into more than one run
1766
+ * (RR-59, #175): a list entry's line has no placed origin from core, and drawn as one chunk of nested tspans resvg shapes the whole
1767
+ * chunk with the outer face first and, when a fallback face covers every character, replaces every glyph with that face's (the Latin
1768
+ * phrase of an Arabic item took a wider fallback face and ran into the bullet).
1769
+ */
1770
+ function scriptLine(text, style, bound, type, { rtl = false, placement, runPlacement, trace, fontSize } = {}) {
1736
1771
  const value = String(text ?? "");
1737
1772
  const scripts = (style?.lang && bound.scriptFontsFor?.(style.lang)) || bound.scriptFonts;
1738
1773
  rtl = rtl && value !== "";
1739
1774
  const isolate = content => rtl ? `${RIGHT_TO_LEFT_ISOLATE}${content}${POP_DIRECTIONAL_ISOLATE}` : content;
1740
1775
  const runs = value && scripts ? scripts.plan(value, style) : [{ text: value, own: true }];
1776
+ if (!placement && runPlacement && runs.length > 1) placement = runPlacement;
1741
1777
  // RR-38: PowerPoint's baseline of a line in Arabic Typesetting sits above core's (one em below the line top): the caller moves it up by `baselineShift` em.
1742
1778
  const shiftOf = list => { const shift = baselineShift(list); return shift ? { baselineShift: shift } : {}; };
1743
1779
  // FF-45: symbol runs (Wingdings, Symbol, Webdings codes drawn as their Unicode equivalents) are always positioned when the
@@ -1763,16 +1799,20 @@ function scriptLine(text, style, bound, type, { rtl = false, placement, trace, f
1763
1799
  advance += width;
1764
1800
  const start = offset;
1765
1801
  offset += run.symbol ? run.symbol.source.length : run.text.length;
1802
+ // RR-59: a right-to-left run's trailing white space sits at its left edge (see rtlTrailingSpace); the glyphs draw after it.
1803
+ const trail = rtl && !run.symbol ? rtlTrailingSpace(run.text) : "";
1804
+ const drawn = trail ? run.text.slice(0, -trail.length) : run.text;
1805
+ const gap = trail ? Math.min(width, Math.max(0, width - scripts.runWidths([{ ...run, text: drawn }], placement.fontSize, style)[0] * factor)) : 0;
1766
1806
  // A symbol glyph keeps the open face's shape: it is compressed to its code's advance only when wider, never stretched.
1767
- const pinned = run.symbol ? natural[index] > width + 1e-6 : width > 0;
1807
+ const pinned = run.symbol ? natural[index] > width + 1e-6 : width - gap > 0;
1768
1808
  return tag("tspan", {
1769
- x: stableNumber(placement.x + left),
1770
- textLength: pinned ? stableNumber(width) : undefined, lengthAdjust: pinned ? "spacingAndGlyphs" : undefined,
1809
+ x: stableNumber(placement.x + left + gap),
1810
+ textLength: pinned ? stableNumber(width - gap) : undefined, lengthAdjust: pinned ? "spacingAndGlyphs" : undefined,
1771
1811
  "font-family": run.own ? undefined : fontStack(run.family, type),
1772
1812
  "font-size": adjusted(run),
1773
1813
  style: run.own ? undefined : nestedReset(style, run),
1774
- ...(trace ? trace(start, offset) : {})
1775
- }, isolate(escapeText(run.text)));
1814
+ ...(trace ? trace(start, offset - trail.length) : {})
1815
+ }, isolate(escapeText(drawn)));
1776
1816
  }).join("");
1777
1817
  return { content, positioned: true, ...shiftOf(runs) };
1778
1818
  }
@@ -1826,10 +1866,11 @@ function codeSyntax(item, layout, bound, options) {
1826
1866
  // the SVG names is one the browser can never select, so leaving it out changes no pixel. Only a face flagged embed:"always" is
1827
1867
  // embedded in every SVG. RR-59: each drawn style is checked on its own, so a family keeps an exactly matched face and, for
1828
1868
  // a drawn style none of its faces matches (a weight the family lacks), every face the browser could choose from.
1829
- function embeddedFontsFor(fonts = [], content) {
1869
+ function embeddedFontsFor(fonts = [], content, subsets, report = () => {}) {
1830
1870
  if (!fonts.length) return fonts;
1831
1871
  fonts.forEach(assertEmbeddableFont);
1832
- const drawn = drawnFaces(content.join("\n"));
1872
+ const characters = subsets ? new Map() : undefined;
1873
+ const drawn = drawnFaces(content.join("\n"), characters);
1833
1874
  const wanted = font => {
1834
1875
  const triples = drawn.get(String(font.family).toLowerCase());
1835
1876
  if (!triples) return false;
@@ -1839,33 +1880,28 @@ function embeddedFontsFor(fonts = [], content) {
1839
1880
  return matching.length ? matching.includes(font) : true;
1840
1881
  });
1841
1882
  };
1842
- return fonts.filter(font => font?.embed === "always" || wanted(font));
1843
- }
1844
-
1845
- const FONT_WEIGHT_KEYWORDS = { normal: "400", bold: "700" };
1846
- /** family (lowercase) to the set of "weight|style" pairs the markup draws text in. */
1847
- function drawnFaces(markup) {
1848
- const drawn = new Map();
1849
- const tokens = /<(\/?)([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>|<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|[^<]+/g;
1850
- const stack = [{ families: [], weight: "400", style: "normal", text: false }];
1851
- const attribute = (attributes, name) => { const found = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(attributes); return found ? found[1] ?? found[2] : undefined; };
1852
- for (const [token, close, name, attributes, selfClose] of markup.matchAll(tokens)) {
1853
- const top = stack.at(-1);
1854
- if (name === undefined) {
1855
- if (top.text && /\S/.test(token)) for (const family of top.families) { const set = drawn.get(family) ?? new Set(); set.add(`${top.weight}|${top.style}`); drawn.set(family, set); }
1856
- continue;
1857
- }
1858
- if (close) { if (stack.length > 1) stack.pop(); continue; }
1859
- const family = attribute(attributes, "font-family"), weight = attribute(attributes, "font-weight"), style = attribute(attributes, "font-style");
1860
- const next = {
1861
- families: family === undefined ? top.families : family.split(",").map(item => item.trim().replace(/^&quot;|&quot;$|^["']|["']$/g, "").toLowerCase()).filter(Boolean),
1862
- weight: weight === undefined ? top.weight : FONT_WEIGHT_KEYWORDS[weight.trim()] ?? weight.trim(),
1863
- style: style === undefined ? top.style : /italic|oblique/.test(style) ? "italic" : "normal",
1864
- text: top.text || name === "text",
1865
- };
1866
- if (!selfClose) stack.push(next);
1867
- }
1868
- return drawn;
1883
+ const drawnOrAlways = fonts.filter(font => font?.embed === "always" || wanted(font));
1884
+ const used = drawnOrAlways.filter(embeddable);
1885
+ const refused = drawnOrAlways.filter(font => !embeddable(font));
1886
+ if (refused.length) report({ code: "font-embedding-restricted", faces: refused.map(font => ({ fontFamily: font.family, weight: font.weight, italic: Boolean(font.italic), fsType: embeddingChecked.get(font).fsType })),
1887
+ message: `${[...new Set(refused.map(font => `'${font.family}'`))].join(", ")} forbid${refused.length === 1 ? "s" : ""} embedding (OS/2 fsType), so the SVG names ${refused.length === 1 ? "it" : "them"} without the font data; a viewer without the font draws the generic family.` });
1888
+ // RR-65: each face cut to the characters the slide draws in its family (the handle's subset engine decides whether the face
1889
+ // may be subset at all). A face no text draws (embed: "always") stays whole.
1890
+ if (!subsets) return used;
1891
+ return used.map(font => {
1892
+ const points = characters.get(String(font.family).toLowerCase());
1893
+ return points?.size ? { ...font, dataUrl: subsets.subsetDataUrl(font, points) } : font;
1894
+ });
1895
+ }
1896
+
1897
+ // RR-76: a face whose OS/2 fsType forbids embedding (restricted, or bitmap-only: an SVG carries outlines) is never written as
1898
+ // @font-face data. The SVG still names its family, so a viewer without the font draws the generic family; a diagnostic says
1899
+ // why. The check reads only the font's table directory and its fsType bytes, once per face object and data URL.
1900
+ const embeddingChecked = new WeakMap();
1901
+ function embeddable(font) {
1902
+ let checked = embeddingChecked.get(font);
1903
+ if (!checked || checked.dataUrl !== font.dataUrl) { const fsType = dataUrlFsType(font.dataUrl); checked = { dataUrl: font.dataUrl, fsType, allowed: embeddingAllowed(fsType) }; embeddingChecked.set(font, checked); }
1904
+ return checked.allowed;
1869
1905
  }
1870
1906
 
1871
1907
  // Every supplied face is checked, drawn or not, so a bad entry fails on any slide. The data URI check is remembered per face
@@ -1959,16 +1995,29 @@ function renderRichLines(value,fit,box,bound,config) {
1959
1995
  ? (linked ? bound.design.colors.hyperlink : config.fill)
1960
1996
  : resolveColorRef(run.color, bound, config.fill);
1961
1997
  let fixedAdvance=(fragment.kind==='tab'||placed)&&fragment.width>0;
1998
+ // RR-59 (#175): measured fragments of a line core did not place (a list entry) keep their measured origin; a mixed-script one
1999
+ // positions its runs (scriptLine runPlacement) as a placed line does.
2000
+ const measuredLine=!asFlow&&!naturalFlow&&fragment.kind!=='tab'&&fragment.width>0;
2001
+ // A right-to-left fragment draws without its trailing white space, after that white space's measured advance (rtlTrailingSpace).
2002
+ const trail=rtl&&measuredLine&&fragment.kind!=='marker'?rtlTrailingSpace(fragment.text):'';
2003
+ let gap=0;
2004
+ if(trail){
2005
+ const measure=textWidthMeasurer(fragment.style,config.options.textMeasurement),full=measure(fragment.text,fragment.fontSize);
2006
+ gap=full>0?Math.min(fragment.width,Math.max(0,fragment.width*(1-measure(fragment.text.slice(0,-trail.length),fragment.fontSize)/full))):0;
2007
+ if(!asFlow)position.x=stableNumber(originX+fragmentX+gap);
2008
+ }
2009
+ const drawnText=trail?fragment.text.slice(0,-trail.length):fragment.text,drawnWidth=fragment.width-gap;
1962
2010
  // FA-13: an inline code run falls back to a monospace face, and a run with its own language declares it.
1963
2011
  const runLang=fragment.kind!=='tab'&&fragment.style.lang?(bound.scriptFontsFor?.(fragment.style.lang)?.profile.bcp47??fragment.style.lang):undefined;
1964
- const scripted=fragment.kind==='tab'?{content:escapeText(fragment.text)}:scriptLine(fragment.text,fragment.style,bound,run.code===true?'monospace':bound.design.fontScheme.type,{rtl:rtl&&!asFlow,
1965
- placement:fixedAdvance&&!asFlow?{x:originX+fragmentX,width:fragment.width,fontSize:fragment.fontSize}:undefined,fontSize:fragment.fontSize});
2012
+ const scripted=fragment.kind==='tab'?{content:escapeText(fragment.text)}:scriptLine(drawnText,fragment.style,bound,run.code===true?'monospace':bound.design.fontScheme.type,{rtl:rtl&&!asFlow,
2013
+ placement:fixedAdvance&&!asFlow?{x:originX+fragmentX+gap,width:drawnWidth,fontSize:fragment.fontSize}:undefined,
2014
+ runPlacement:measuredLine&&!fixedAdvance?{x:originX+fragmentX+gap,width:drawnWidth,fontSize:fragment.fontSize}:undefined,fontSize:fragment.fontSize});
1966
2015
  if(scripted.positioned)fixedAdvance=false;
1967
2016
  const content=`${edges.first?RIGHT_TO_LEFT_ISOLATE:''}${scripted.content}${edges.last?POP_DIRECTIONAL_ISOLATE:''}`;
1968
2017
  // RR-34: a citation/footnote marker is generated text (no source range): it is traced as a marker
1969
2018
  // segment without text offsets, so editors never read it as part of the run, and it is not linked.
1970
2019
  const marker=fragment.kind==='marker';
1971
- const rendered=tag(asFlow?'tspan':'text',{...(runLang?{lang:runLang,'xml:lang':runLang}:{}),...(config.options.trace?marker?{'data-opf-segment':'marker','data-opf-marker':fragment.text}:{'data-opf-text-start':runOffsets[fragment.runIndex]+fragment.start,'data-opf-text-end':runOffsets[fragment.runIndex]+fragment.end,'data-opf-segment':fragment.kind}:{}),...position,'xml:space':'preserve','text-rendering':asFlow?undefined:'geometricPrecision',textLength:fixedAdvance?stableNumber(fragment.width):undefined,lengthAdjust:fixedAdvance?'spacingAndGlyphs':undefined,'font-family':scripted.family??fontStack(fragment.style.fontFamily,run.code===true?'monospace':bound.design.fontScheme.type),'font-size':stableNumber(adjustedFontSize(fragment.fontSize,scripted.sizeAdjust)),'font-weight':fragment.style.fontWeight,'font-style':fragment.style.italic?'italic':asFlow?'normal':undefined,'text-decoration':marker?undefined:[run.underline||linked?'underline':'',run.strikethrough?'line-through':''].filter(Boolean).join(' ')||undefined,fill:runFill},content);
2020
+ const rendered=tag(asFlow?'tspan':'text',{...(runLang?{lang:runLang,'xml:lang':runLang}:{}),...(config.options.trace?marker?{'data-opf-segment':'marker','data-opf-marker':fragment.text}:{'data-opf-text-start':runOffsets[fragment.runIndex]+fragment.start,'data-opf-text-end':runOffsets[fragment.runIndex]+fragment.end-trail.length,'data-opf-segment':fragment.kind}:{}),...position,'xml:space':'preserve','text-rendering':asFlow?undefined:'geometricPrecision',textLength:fixedAdvance?stableNumber(drawnWidth):undefined,lengthAdjust:fixedAdvance?'spacingAndGlyphs':undefined,'font-family':scripted.family??fontStack(fragment.style.fontFamily,run.code===true?'monospace':bound.design.fontScheme.type),'font-size':stableNumber(adjustedFontSize(fragment.fontSize,scripted.sizeAdjust)),'font-weight':fragment.style.fontWeight,'font-style':fragment.style.italic?'italic':asFlow?'normal':undefined,'text-decoration':marker?undefined:[run.underline||linked?'underline':'',run.strikethrough?'line-through':''].filter(Boolean).join(' ')||undefined,fill:runFill},content);
1972
2021
  if(linked)return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
1973
2022
  return rendered;
1974
2023
  };