@openpresentation/opf-render 0.18.1 → 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 CHANGED
@@ -278,7 +278,7 @@ The default changed from raster to vector in the release that added this section
278
278
  - `signal` (an `AbortSignal`, checked between pages) and `onProgress({ page, pages })` serve large decks; the same two options also work in the Node `toPdf`. The browser export yields to the page between slides.
279
279
 
280
280
  Verified in Chromium by `test/export-browser.mjs` (`npm run test:export-browser`), which also checks the text pdf.js extracts, the eight JPEG orientations, offline operation and determinism. Safari and Firefox are not exercised by this repository's CI.
281
- Complex scripts in raster output: resvg draws a HarfBuzz cluster with the advance of its widest glyph, so Indic, Thai, Lao, Khmer and Myanmar text lost the advance of vowel signs and ran together, and it ignores the SVG `lang` (Korean spacing). The raster path therefore rewrites its private copy of such text cluster by cluster at fontkit's positions (`src/raster-text.js`): Devanagari, Gujarati, Oriya, Tamil, Kannada and Sinhala as fontkit glyph outlines, the other scripts and Korean as single clusters resvg shapes in isolation. The emitted SVG is unchanged, text in other scripts is rasterized exactly as before, and `test/script-corpora-raster.mjs` holds every script to a HarfBuzz outline reference (the fixture's `rasterLimits` records what still differs: the KOR punctuation forms).
281
+ Complex scripts in raster output: resvg draws a HarfBuzz cluster with the advance of its widest glyph, so Indic, Thai, Lao, Khmer and Myanmar text lost the advance of vowel signs and ran together, and it ignores the SVG `lang` (Korean spacing). The raster path therefore rewrites its private copy of such text cluster by cluster at HarfBuzz's positions when the fonts handle has its shaper (`fonts.outlines.shape`; the Node handle and the bundled faces always do), fontkit's otherwise (`src/raster-text.js`): Devanagari, Gujarati, Oriya, Tamil, Kannada and Sinhala as fontkit glyph outlines, the other scripts and Korean as single clusters resvg shapes in isolation. The emitted SVG is unchanged, text in other scripts is rasterized exactly as before, and `test/script-corpora-raster.mjs` holds every script to a HarfBuzz outline reference (the fixture's `rasterLimits` records what still differs: the KOR punctuation forms).
282
282
 
283
283
  ## Browser preview and fonts
284
284
 
@@ -16,6 +16,7 @@ import { fsTypeOf, subsettingAllowed } from "./font-fstype.js";
16
16
  const SUBSET_LICENSES = new Set(["OFL-1.1", "Apache-2.0", "MIT", "UFL-1.0"]);
17
17
  const HB_MEMORY_MODE_READONLY = 1;
18
18
  const HB_SUBSET_SETS_LAYOUT_FEATURE_TAG = 6;
19
+ const HB_SUBSET_FLAGS_GLYPH_NAMES = 0x80;
19
20
  const CACHE_LIMIT = 256;
20
21
 
21
22
  /** The manifest faces that may be subset, by sha256: permissive license, no Reserved Font Name in the family or file name. */
@@ -56,7 +57,7 @@ export function createSubsetter(wasm) {
56
57
  const exports = instance.exports;
57
58
  const required = ["memory", "malloc", "free", "hb_blob_create", "hb_blob_destroy", "hb_blob_get_length", "hb_blob_get_data", "hb_face_create", "hb_face_destroy",
58
59
  "hb_face_reference_blob", "hb_set_add", "hb_set_clear", "hb_set_invert", "hb_subset_input_create_or_fail", "hb_subset_input_destroy", "hb_subset_input_unicode_set",
59
- "hb_subset_input_set", "hb_subset_or_fail"];
60
+ "hb_subset_input_set", "hb_subset_input_set_flags", "hb_subset_or_fail"];
60
61
  for (const name of required) if (!(name in exports)) throw new Error(`harfbuzz-subset.wasm has no export ${name}.`);
61
62
  return {
62
63
  subset(data, codePoints) {
@@ -69,7 +70,12 @@ export function createSubsetter(wasm) {
69
70
  let result = 0, output = null;
70
71
  try {
71
72
  if (!input) return null;
73
+ // macOS Chromium small-size rasterization changes when named X/o are absent, even when the text does not draw them.
74
+ // Keeping names alone or the outlines alone is insufficient: see the exact-pixel subset browser regression.
75
+ exports.hb_subset_input_set_flags(input, HB_SUBSET_FLAGS_GLYPH_NAMES);
72
76
  const unicodes = exports.hb_subset_input_unicode_set(input);
77
+ exports.hb_set_add(unicodes, 0x58); // X
78
+ exports.hb_set_add(unicodes, 0x6f); // o
73
79
  for (const codePoint of codePoints) exports.hb_set_add(unicodes, codePoint);
74
80
  // Every layout feature, not only HarfBuzz's default list: the browser shapes the subset like the whole face whatever
75
81
  // font-feature-settings the SVG asks for.
package/dist/fonts.d.ts CHANGED
@@ -61,7 +61,8 @@ export interface TextOutlines {
61
61
  outlineSlideText(content: string[], options: object): { content: string[]; defs: string };
62
62
  /**
63
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; fontkit otherwise.
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.
65
66
  */
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 }[];
67
68
  }
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; }
@@ -37,6 +37,7 @@ const AUDIENCE_CSS = `
37
37
  .msg { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; text-align: center; }
38
38
  @media (forced-colors: active) {
39
39
  .hud button { border-color: ButtonText; }
40
+ .hud button:focus-visible { outline-color: Highlight; }
40
41
  .hud button[aria-disabled="true"] { opacity: 1; color: GrayText; border-color: GrayText; }
41
42
  }
42
43
  `;
@@ -14,7 +14,9 @@
14
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
15
15
  // (fontdb's family, style and weight matching over the same font files, with the same glyph fallback order), the OpenType language
16
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
17
- // 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;
18
20
  // - a cluster of a script whose fontkit glyph runs equal HarfBuzz's in the corpus (Devanagari, Gujarati, Oriya, Tamil, Kannada,
19
21
  // Sinhala) is drawn as fontkit's glyph outlines (<path> per glyph), so a conjunct under a pre-base vowel is the browser's;
20
22
  // - any other cluster (Bengali, Gurmukhi, Telugu, Malayalam, Thai, Lao, Khmer and Myanmar, where fontkit orders marks differently
@@ -31,6 +33,7 @@ import { create } from "fontkit";
31
33
  import { disabledFeaturesFor } from "./font-compatibility.js";
32
34
  import { pinGlyphCodePoints } from "./font-registry.js";
33
35
  import { openTypeLanguage } from "./script-fonts.js";
36
+ import { sha256Bytes } from "./sha256.js";
34
37
 
35
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;
36
39
  // Scripts whose fontkit glyph runs equal HarfBuzz's for every corpus sample (glyph ids, order and advances, no retry): drawn as outlines.
@@ -56,8 +59,11 @@ const PAINT = new Set(["fill", "fill-opacity", "fill-rule", "opacity", "stroke",
56
59
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
57
60
  const faceCache = new Map();
58
61
 
59
- /** Rewrites complex-script text of `svg` into per-cluster positioned pieces for resvg (see the file comment). */
60
- 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 } = {}) {
61
67
  if (!AFFECTED.test(svg) && !HANGUL.test(svg)) return svg;
62
68
  // resvg-js names for the generic families (its own defaults where rasterizeSvg sets none) and for text without a font-family.
63
69
  const generics = {
@@ -81,7 +87,7 @@ export async function pinScriptClusters(svg, { fontFiles = [], defaultFontFamily
81
87
  const inner = svg.slice(tokens.lastIndex, closeIndex);
82
88
  tokens.lastIndex = closeIndex + "</text>".length;
83
89
  if (!needsRewrite(inner, props)) continue;
84
- 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);
85
91
  if (rewritten === null) continue;
86
92
  output += svg.slice(end, match.index) + rewritten;
87
93
  end = tokens.lastIndex;
@@ -218,7 +224,7 @@ export function clusterPieces(text, pinAll = false) {
218
224
  return pieces;
219
225
  }
220
226
 
221
- function rewriteText(text, db, generics) {
227
+ function rewriteText(text, db, generics, shape) {
222
228
  const root = parseInner(text.inner);
223
229
  if (!root) return null;
224
230
  const rootProps = text.props;
@@ -263,7 +269,7 @@ function rewriteText(text, db, generics) {
263
269
  const base = selectFace(db, leaf.props, generics);
264
270
  if (!base) return null;
265
271
  leaf.runs = faceRuns(db, base, leaf.value);
266
- 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);
267
273
  if (!widths) return null;
268
274
  let previous = 0;
269
275
  for (const [index, piece] of leaf.pieces.entries()) { piece.width = widths[index] - previous; previous = widths[index]; }
@@ -362,7 +368,7 @@ async function readFace(file) {
362
368
  const names = Object.values(records.preferredFamily ?? {}).length ? Object.values(records.preferredFamily) : Object.values(records.fontFamily ?? {});
363
369
  const os2 = font["OS/2"], selection = os2?.fsSelection;
364
370
  return {
365
- 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(),
366
372
  weight: os2?.usWeightClass ?? 400, stretch: os2?.usWidthClass ?? 5,
367
373
  style: selection?.italic || (!os2 && font.head?.macStyle?.italic) ? "italic" : selection?.oblique ? "oblique" : "normal"
368
374
  };
@@ -428,8 +434,8 @@ function shapeRun(face, value, props) {
428
434
  catch { return { run: layout({ abvm: false, blwm: false, mark: false, mkmk: false }), retry: true }; }
429
435
  }
430
436
 
431
- /** Advance widths (px) of the prefixes of the runs' text ending at each offset of `ends`. */
432
- 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) {
433
439
  const width = (face, value) => {
434
440
  if (!value) return 0;
435
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.
@@ -441,13 +447,23 @@ function measurePrefixes(runs, ends, props) {
441
447
  const pens = new Map();
442
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
443
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
+ }
444
457
  const prefix = (run, length) => {
458
+ const shaped = harfBuzz.get(run)?.get(length);
459
+ if (shaped !== undefined) return shaped;
445
460
  if (!pens.has(run)) pens.set(run, contextPens(run.face, run.text, props));
446
461
  return pens.get(run)?.get(length) ?? width(run.face, run.text.slice(0, length));
447
462
  };
463
+ const whole = run => harfBuzz.get(run)?.get(run.text.length) ?? width(run.face, run.text);
448
464
  let runIndex = 0, before = 0;
449
465
  for (const end of ends) {
450
- 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++; }
451
467
  const run = runs[runIndex];
452
468
  widths.push(run && end > run.start ? before + prefix(run, end - run.start) : before);
453
469
  }
@@ -474,6 +490,49 @@ function contextPens(face, value, props) {
474
490
  return consumed === value.length ? pens : null;
475
491
  }
476
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
+
477
536
  /** The glyph outlines of one cluster, or null when fontkit needed the mark retry (its glyph placement is then not the browser's). */
478
537
  function shapeOutlines(face, value, props) {
479
538
  let shaped;
package/dist/raster.js CHANGED
@@ -151,9 +151,13 @@ async function rasterizeSvg(svgInput, options) {
151
151
 
152
152
  if (options.dpi !== undefined) renderOptions.dpi = positiveNumber(options.dpi, "dpi", 96);
153
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 };
154
158
  // An SVG used as an image draws its text with these fonts: resvg gives the nested document none.
155
159
  const nestedSvg = async text => {
156
- const pinned = await pinScriptClusters(text, font);
160
+ const pinned = await pinScriptClusters(text, pinning);
157
161
  const probe = new Resvg(pinned, { font, logLevel: "off" });
158
162
  const zoom = Math.min(4, Math.max(1, 2048 / Math.max(probe.width, probe.height)));
159
163
  return new Resvg(pinned, { ...renderOptions, fitTo: { mode: "zoom", value: zoom }, background: "rgba(0, 0, 0, 0)" }).render().asPng();
@@ -161,7 +165,7 @@ async function rasterizeSvg(svgInput, options) {
161
165
  // FF-31: resvg ignores the SVG's ligature properties, so separate the letters a Gelasio ligature would join.
162
166
  // FF-45: colour families (Noto Color Emoji) are drawn with their monochrome stand-in, which resvg can draw.
163
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).
164
- 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);
165
169
 
166
170
  try {
167
171
  const image = new Resvg(svg, renderOptions).render();
@@ -615,6 +615,13 @@ export function createScriptFonts(profile = {}, measurement, options = {}) {
615
615
  const push = (text, family, stack, sizeAdjust, ascent) => {
616
616
  const own = family === style.fontFamily && (!stack || stack.length === 1);
617
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
+ }
618
625
  if (last && last.family === family && last.sizeAdjust === sizeAdjust && last.lineAscent === ascent?.lineAscent && JSON.stringify(last.stack) === JSON.stringify(stack)) last.text += text;
619
626
  else out.push({text, family, own, ...(stack && !own ? {stack} : {}), ...(sizeAdjust ? {sizeAdjust} : {}), ...(ascent ?? {})});
620
627
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-render",
3
- "version": "0.18.1",
3
+ "version": "0.18.2",
4
4
  "description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -142,7 +142,7 @@
142
142
  "test:forced-colors-browser": "node test/forced-colors-browser.mjs artifacts/forced-colors"
143
143
  },
144
144
  "dependencies": {
145
- "@openpresentation/opf": "^0.18.3",
145
+ "@openpresentation/opf": "^0.18.4",
146
146
  "bidi-js": "1.1.0",
147
147
  "fontkit": "^2.0.4",
148
148
  "harfbuzzjs": "1.6.3",