@openpresentation/opf-render 0.18.1 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  **OPF 0.15 (FA-23, unreleased; requires core 0.15).** Catalogs are registered by the host, and every picture is an image background or an image block. The breaking changes are listed in `changes/fa-23-opf-0-15.md` (the next CHANGELOG section); the [0.15 design](https://github.com/OpenPresentation/opf/blob/main/docs/programs/format-audit/0.15-design.md) explains them.
16
16
 
17
- - The renderer bundles, looks up and fetches no catalog. Every entry point (`toSvg`, `resolvePresentation`, the font loaders' `renderOptions`, `/player`, `<opf-deck>`) takes core's `catalogs: Catalog[]` and passes it unchanged to core resolution. Register the gallery snapshot yourself: `import { defaultCatalog } from '@openpresentation/opf/catalog'` and `toSvg(deck, { catalogs: [defaultCatalog] })`. `strictReferences: true` fails with `unresolved-reference` instead of falling back.
17
+ - The renderer bundles, looks up and fetches no catalog. Every entry point (`toSvg`, `resolvePresentation`, the font loaders' `renderOptions`, `/player`, `<opf-deck>`) takes core's `catalogs: Catalog[]` and passes it unchanged to core resolution. Register the gallery snapshot yourself: `import { gallery } from '@openpresentation/gallery'` and `toSvg(deck, { catalogs: [gallery] })`. `strictReferences: true` fails with `unresolved-reference` instead of falling back.
18
18
  - What resolves nowhere draws with core's engine defaults (`engineDefaults` is `{ theme, colorScheme, fontScheme, chartType }`: core's `ENGINE_DEFAULT_THEME`, `ENGINE_DEFAULT_COLOR_SCHEME`, `ENGINE_DEFAULT_FONT_SCHEME`) and is reported once per reference as `unresolved-reference`.
19
19
  - An image background (`design.background: { type: "image", src, alt, fit, focus, opacity, recolor, overlay }`, or an image source string) draws from core's `geometry.backgroundImage`: the canvas colour, the picture (cover, contain, stretch or tile; recolor and opacity on the pixels only), then the overlay (the whole slide or an edge band). `alt` is the picture's accessible name; without it the picture is decorative.
20
20
  - Every content picture is an image block drawn from `item.image`: frame, fit (`cover`, `contain`, `stretch`; default `design.imageFit`, else `cover`), focus, shape mask, border, opacity, recolor and overlay, and a placed block (`placement`) at its edge band.
@@ -120,7 +120,9 @@ const pptx = await toPptx(presentation, { fonts }); // the same lines in
120
120
 
121
121
  One handle carries the measurement for core's `validate`, `paginate` and slide context, for `toSvg`, `toPng` and `toPdf`, and for opf-pptx's `toPptx`. The same handle gives identical text geometry across those engines: every engine reads core's one composition, so the measurement decides the lines. [opf-pptx's `layout-parity` test](https://github.com/OpenPresentation/opf-pptx/blob/main/test/layout-parity.mjs) checks that the exported PowerPoint paragraphs agree with this renderer's SVG and with core's composed items, with and without `fonts`.
122
122
 
123
- A call without `fonts` is not an error. It uses core's built-in estimate instead of measured widths: 0.54 em per character, 0.62 em for capitals and digits, 0.32 em for a space, 1 em for CJK characters and zero for combining marks. The estimate is deterministic and needs no font files, but it is too narrow for some scripts (opf#566 tracks improving it), so line breaks, overflow findings and page breaks from an estimated run can differ from what the faces actually draw. Treat an estimated preview as a draft. Core's Node `convert` and the `opf` CLI prepare an office-pack handle for you; a library call to `toSvg`, `toPng` or `toPdf` never loads fonts on its own, so pass the handle you want. Compare [opf#364](https://github.com/OpenPresentation/opf/issues/364).
123
+ Node handles, and browser handles loaded with `shapeWasm`, measure advances and glyph ink with the same HarfBuzz shaper used by vector PDF and text outlines. Handles without shaping WASM retain fontkit measurement. This can change line wrapping for Myanmar and some Syriac text; [the reviewed corpus report](docs/evidence/harfbuzz-layout-measurement/README.md) records the differences and the unchanged example output.
124
+
125
+ A call without `fonts` is not an error. It uses core's deterministic estimate instead of measured font advances. Core applies script-specific widths, including the six script estimates improved in OPF 0.19; nonspacing and enclosing marks have no independent advance, while spacing combining marks retain width. The estimate needs no font files, but line breaks, overflow findings and page breaks can differ from what the selected faces actually draw. Treat an estimated preview as a draft. Core's Node `convert` and the `opf` CLI prepare an office-pack handle for you; a library call to `toSvg`, `toPng` or `toPdf` never loads fonts on its own, so pass the handle you want. Compare [opf#364](https://github.com/OpenPresentation/opf/issues/364).
124
126
 
125
127
 
126
128
  ## Install: what to add for what (RR-63)
@@ -210,6 +212,8 @@ console.log(fonts.substitutions);
210
212
 
211
213
  Set `trace: true` to stamp rendered SVG elements with `data-opf-path` values such as `slides.0.title`; omit it for smaller production SVG.
212
214
 
215
+ Layout templates (OPF 0.19, core `opf-layout/v2`: `areas`, `rows`, `columns` and `regions`) draw from core's composed geometry, as every slide does: the renderer adds no arrangement of its own. Core places each block in its region (`geometry.regions`, `item.region`); a card is the `item.frameBox` core composed in the block's cell (`design.contentBox`), and a region with `bleed` arrives with its outer edges on the slide edge and no card. A list core laid out in columns (`item.listColumns`: a lone list in a region with `listColumns: "auto"`, or a list payload with `columns`) draws each column's own fit in its own box, at one shared size, with numbering continuing across the columns; `item.text` (the whole list) is not drawn then. With `trace: true` everything an item of a region draws (card, payload, caption) is grouped in `<g data-opf-region="<name>">`, and each list column is a group with `data-opf-list-column` (its index), `data-opf-list-start` and `data-opf-list-end` (the half-open range of the payload's items it holds) and its `data-opf-box-*`. OPF 0.19 removes `opf-layout/v1` records with no alias or migration shim. Slides without a layout use core's automatic composition; only items bound to template regions carry these region attributes. `test/layout-template-goldens.mjs` pins the SVG and PNG of the 28 built-in templates with 1 to 6 short and long blocks, and of the pages core's `paginate` makes of the ones that overflow.
216
+
213
217
  The installed open font pack works offline. The Aptos family previews with the metric-compatible Intos even under the default metric policy; this example additionally permits visual-only look-alikes for families with no metric replacement yet (a documented fallback), and the substitution report identifies requested and resolved faces. A visual substitute is not a claim of metric or pixel equivalence, and it is a known layout-fidelity gap rather than the end state. Replacements apply to rendering only; the document keeps the selected font name. Omit `substitutionPolicy` to restrict the Office pack to its metric mappings, or supply your own licensed faces (`faces`). Missing fonts/glyphs fail explicitly. Without `fonts`, synchronous SVG APIs use estimated widths; named fonts alone do not make rich-run spacing reliable. Browser hosts should load the same bytes using the loader below.
214
218
 
215
219
  Unresolved images produce an `unresolved-asset` diagnostic through `onDiagnostic`, including the OPF path, reason and complete description. The fallback shows a bounded status label when it fits above the selected readability floor, otherwise an icon with the full accessible description. The label is `Image unavailable` over the description (alt, else title, else `Image`) in semibold at 20 px on a 1280 x 720 canvas, and opf-pptx exports the same placeholder as native shapes. It preserves authored opacity, including faint decorative watermarks; a missing image is still missing even when its fallback fits. Use `strictAssets: true` to reject unresolved images. Supply embedded raster data URIs or a synchronous host `imageResolver` to resolve them; the renderer does not fetch URLs or read local paths. Caller descriptions and other metadata override referenced asset metadata through alias chains. These diagnostics identify unresolved sources, not malformed image bytes or native PowerPoint compatibility.
@@ -278,7 +282,7 @@ The default changed from raster to vector in the release that added this section
278
282
  - `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
283
 
280
284
  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).
285
+ 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
286
 
283
287
  ## Browser preview and fonts
284
288
 
@@ -502,7 +506,7 @@ fonts.registry.scriptSelection; // { detected, scripts, unavailable, packages, n
502
506
  const browserFonts = await loadBrowserFonts({ faces: baseEntries, substitutionPolicy: 'visual', fallbackFamily: 'Roboto', scriptBaseUrl: '/script-fonts/' });
503
507
  if (browserFonts.pending(presentation).length) await browserFonts.ensure(presentation); // resolves { scripts, lazy, uncovered }
504
508
  // pending is synchronous: empty means nothing is missing, so render at once.
505
- // Text decides, so the analysis does not resolve layouts. Pass the render options (renderOptions: { catalogs: [defaultCatalog] }
509
+ // Text decides, so the analysis does not resolve layouts. Pass the render options (renderOptions: { catalogs: [gallery] }
506
510
  // to loadFonts, or per call) so a font scheme only the host's catalogs have and that names a script font (Yu Gothic) counts.
507
511
  ```
508
512
 
@@ -577,7 +581,7 @@ The package runtime must stay local and deterministic:
577
581
 
578
582
  Dependency policy:
579
583
 
580
- - `@openpresentation/opf` is the compatibility source for schemas, validation, resolution and composition. The renderer imports no catalog data; hosts register catalogs (`@openpresentation/opf/catalog` holds the gallery snapshot).
584
+ - `@openpresentation/opf` is the compatibility source for schemas, validation, resolution and composition. The renderer imports no catalog data; hosts register catalogs (`@openpresentation/gallery` holds the gallery snapshot).
581
585
  - `@resvg/resvg-js` (optional peer, RR-63) is used only for local SVG rasterization in Node; it makes no network calls and does not require a browser. `sharp` (optional peer) decodes WebP, rotated JPEG and the pictures of a vector PDF. Both load on first use; see [Install](#install-what-to-add-for-what-rr-63).
582
586
  - `pdf-lib` (optional peer, RR-63) assembles raster-mode PDF bytes locally. Metadata timestamps are disabled so repeated PDF output is byte-stable for the same SVG input and options.
583
587
  - Vector PDF output is written by a small deterministic PDF writer in this package, with `pako` for Flate compression (pure JavaScript, so independent of the platform zlib build), HarfBuzz (the fonts handle's `harfbuzz.wasm` of the pinned harfbuzzjs) for shaping, `fontkit` for font metrics (and for shaping without a shaper) and `bidi-js` for the Unicode bidirectional algorithm. No hosted service, network call or system font is involved.
@@ -1,3 +1,5 @@
1
+ import { sha256Bytes } from "./sha256.js";
2
+ import bidiFactory from "bidi-js";
1
3
  import { create } from "fontkit";
2
4
  import { FONT_COMPATIBILITY, disabledFeaturesFor } from "./font-compatibility.js";
3
5
  import { adjustedFontSize, createScriptFonts, openTypeLanguage, scriptFontAliases, sizeAdjustFor } from "./script-fonts.js";
@@ -11,6 +13,7 @@ import { SYMBOL_SCRIPT, isSymbolEncodedFamily, symbolEncodingFamily, symbolPrevi
11
13
  export class OPFFontError extends Error {
12
14
  constructor(code, message, details = {}) { super(message); this.name = "OPFFontError"; this.code = code; this.details = details; }
13
15
  }
16
+ const bidi = bidiFactory();
14
17
  const key = (family, weight, italic) => `${family.toLowerCase()}:${weight}:${!!italic}`;
15
18
  function base64(data) {
16
19
  let binary = "";
@@ -190,8 +193,15 @@ export function createFontRegistry(entries, options = {}) {
190
193
  if (entry.embed !== undefined && entry.embed !== "always" && entry.embed !== "used") throw new OPFFontError("invalid-font-embed", "Font embed must be 'always' or 'used'.");
191
194
  // A fallback-only face (the default Noto Sans) serves glyph fallback and explicit requests by its own name,
192
195
  // but never stands in for another family, so loading it does not change what any other font previews with.
193
- return {family,familyGroup,fontFace,weight,italic,font,data,format,license:entry.license,embed:entry.embed,scripts,fallbackOnly:entry.fallbackOnly===true,cache:new Map()};
196
+ return {family,familyGroup,fontFace,weight,italic,font,data,format,
197
+ // The Node shaper is shared across handles: reuse its immutable font by content, never by a new byte-array identity.
198
+ get hash() {
199
+ const hash = Array.from(sha256Bytes(data), byte => byte.toString(16).padStart(2, "0")).join("").toUpperCase();
200
+ Object.defineProperty(this, "hash", {value: hash});
201
+ return hash;
202
+ },license:entry.license,embed:entry.embed,scripts,fallbackOnly:entry.fallbackOnly===true,cache:new Map()};
194
203
  };
204
+ let shaper;
195
205
  const faces = [], duplicates = new Set(), familySlots = new Map();
196
206
  // Adds faces atomically: every check runs before any face is registered.
197
207
  const register = added => {
@@ -265,7 +275,9 @@ export function createFontRegistry(entries, options = {}) {
265
275
  const {face,resolution}=resolve(style);
266
276
  // A BCP-47 `lang` selects the font's OpenType language system, as a browser
267
277
  // does for an element's lang (FF-19). Without it, shaping is unchanged.
268
- const language=openTypeLanguage(style.lang),key=language?`${language}\u0000${text}`:text;
278
+ const language=openTypeLanguage(style.lang);
279
+ const direction = style.direction ?? (bidi.getEmbeddingLevels(text).paragraphs[0]?.level % 2 ? "rtl" : "ltr");
280
+ const key = `${direction}\u0000${style.lang ?? ""}\u0000${text}`;
269
281
  let value=face.cache.get(key);
270
282
  if (value===undefined) {
271
283
  if (options.strictGlyphs!==false) for(const character of text) {
@@ -284,7 +296,27 @@ export function createFontRegistry(entries, options = {}) {
284
296
  const off=disabledFeaturesFor(face.family),policyFeatures=off&&Object.fromEntries(off.map(tag=>[tag,false]));
285
297
  const shape=extra=>{const features=policyFeatures||extra?{...policyFeatures,...extra}:undefined;return language?face.font.layout(text,features,undefined,language):features?face.font.layout(text,features):face.font.layout(text);};
286
298
  let run;
287
- try { run=shape(); }
299
+ if (shaper) {
300
+ try {
301
+ const glyphs = shaper.shape(face, text, {direction, lang: style.lang, features: policyFeatures});
302
+ let pen = 0, bounds = null;
303
+ for (const glyph of glyphs) {
304
+ if (includeOutline) {
305
+ // Colour/bitmap glyphs may have no fontkit outline (the em-box fallback below still applies).
306
+ let box;
307
+ try { box = face.font.getGlyph(glyph.gid).bbox; } catch { box = null; }
308
+ if (box && [box.minX, box.minY, box.maxX, box.maxY].every(Number.isFinite) && box.width > 0 && box.height > 0) {
309
+ const next = {minX: pen + glyph.xOffset + box.minX, maxX: pen + glyph.xOffset + box.maxX, minY: glyph.yOffset + box.minY, maxY: glyph.yOffset + box.maxY};
310
+ bounds = bounds ? {minX: Math.min(bounds.minX, next.minX), maxX: Math.max(bounds.maxX, next.maxX), minY: Math.min(bounds.minY, next.minY), maxY: Math.max(bounds.maxY, next.maxY)} : next;
311
+ }
312
+ }
313
+ pen += glyph.advance;
314
+ }
315
+ run = {positions: [{xAdvance: pen}], bbox: bounds && {...bounds, width: bounds.maxX - bounds.minX, height: bounds.maxY - bounds.minY}};
316
+ } catch (error) {
317
+ throw new OPFFontError("font-shaping-failed", `Font '${face.family}' cannot shape this text with HarfBuzz.`, {path:style.path,fontFamily:face.family,cause:error?.message});
318
+ }
319
+ } else try { run=shape(); }
288
320
  catch (error) {
289
321
  // A lookup fontkit cannot decode (GSUB type 8 in Noto Sans Mongolian) applies nothing; shape again with the rest (FF-44).
290
322
  if (skipUndecodableLookups(face.font)) { try { run=shape(); } catch { /* the mark retry below */ } }
@@ -326,6 +358,11 @@ export function createFontRegistry(entries, options = {}) {
326
358
  textMeasurement.forScripts=profile=>createScriptFonts(profile,textMeasurement).textMeasurement;
327
359
  return {
328
360
  textMeasurement,
361
+ // Attach the handle's outline shaper after its WASM loads. Earlier measurements must not survive the engine switch.
362
+ setShaper(engine) {
363
+ shaper = engine;
364
+ for (const face of faces) face.cache.clear();
365
+ },
329
366
  resolveFont(style) { return resolve(style).resolution; },
330
367
  clearSubstitutions() { substitutions.clear(); },
331
368
  get substitutions() { return [...substitutions.values()]; },
@@ -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/index.d.ts CHANGED
@@ -106,12 +106,17 @@ export interface ToSvgOptions {
106
106
  * never reads a clock; without it a current date is reported as unresolved content.
107
107
  */
108
108
  date?: string;
109
+ /**
110
+ * Stamp drawn elements with their OPF path (`data-opf-path`) and the other `data-opf-*` trace attributes. On a slide with an
111
+ * OPF 0.19 layout template, everything an item of a region draws is grouped in `<g data-opf-region="<name>">`, and each
112
+ * column of a list laid out in columns is a group with `data-opf-list-column`, `data-opf-list-start` and `data-opf-list-end`.
113
+ */
109
114
  trace?: boolean;
110
115
  /**
111
116
  * Catalogs the host registered (core `Catalog[]`), passed unchanged to core resolution: a reference the document does not
112
117
  * embed resolves against the catalog whose `source` its group names, and the first entry is the default catalog for bare ids
113
118
  * when the document omits `catalogs.default`. The renderer bundles and fetches no catalog; register the gallery snapshot with
114
- * `import { defaultCatalog } from "@openpresentation/opf/catalog"` and `catalogs: [defaultCatalog]`. Omitted: only embedded
119
+ * `import { gallery } from "@openpresentation/gallery"` and `catalogs: [gallery]`. Omitted: only embedded
115
120
  * records resolve, and anything else draws with core's engine defaults (reported as `unresolved-reference`).
116
121
  * A malformed value throws core's `OPFCatalogsOptionError` (`code: "invalid-catalogs"`); a reference with an undeclared prefix
117
122
  * (`foo:id` without `catalogs.foo`) fails the boundary check (`invalid-opf`, finding `opf/undeclared-catalog`).
@@ -257,7 +262,6 @@ export interface ResolvedSlide {
257
262
  colors: Record<string, string>;
258
263
  fonts: Record<"heading" | "body" | "code", string>;
259
264
  };
260
- titleBindings: RenderBinding[];
261
265
  contentItems: RenderBinding[];
262
266
  blocks: RenderBinding[];
263
267
  }
@@ -265,10 +269,8 @@ export interface ResolvedSlide {
265
269
  export interface RenderBinding {
266
270
  type: string;
267
271
  field?: string;
268
- slot?: string;
269
272
  value: unknown;
270
273
  path: string;
271
- placeholderIndex?: number;
272
274
  regionKey?: string;
273
275
  }
274
276
 
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/dist/svg.d.ts CHANGED
@@ -107,12 +107,17 @@ export interface ToSvgOptions {
107
107
  * never reads a clock; without it a current date is reported as unresolved content.
108
108
  */
109
109
  date?: string;
110
+ /**
111
+ * Stamp drawn elements with their OPF path (`data-opf-path`) and the other `data-opf-*` trace attributes. On a slide with an
112
+ * OPF 0.19 layout template, everything an item of a region draws is grouped in `<g data-opf-region="<name>">`, and each
113
+ * column of a list laid out in columns is a group with `data-opf-list-column`, `data-opf-list-start` and `data-opf-list-end`.
114
+ */
110
115
  trace?: boolean;
111
116
  /**
112
117
  * Catalogs the host registered (core `Catalog[]`), passed unchanged to core resolution: a reference the document does not
113
118
  * embed resolves against the catalog whose `source` its group names, and the first entry is the default catalog for bare ids
114
119
  * when the document omits `catalogs.default`. The renderer bundles and fetches no catalog; register the gallery snapshot with
115
- * `import { defaultCatalog } from "@openpresentation/opf/catalog"` and `catalogs: [defaultCatalog]`. Omitted: only embedded
120
+ * `import { gallery } from "@openpresentation/gallery"` and `catalogs: [gallery]`. Omitted: only embedded
116
121
  * records resolve, and anything else draws with core's engine defaults (reported as `unresolved-reference`).
117
122
  * A malformed value throws core's `OPFCatalogsOptionError` (`code: "invalid-catalogs"`); a reference with an undeclared prefix
118
123
  * (`foo:id` without `catalogs.foo`) fails the boundary check (`invalid-opf`, finding `opf/undeclared-catalog`).
@@ -152,7 +157,6 @@ export interface ResolvedSlide {
152
157
  colors: Record<string, string>;
153
158
  fonts: Record<"heading" | "body" | "code", string>;
154
159
  };
155
- titleBindings: RenderBinding[];
156
160
  contentItems: RenderBinding[];
157
161
  blocks: RenderBinding[];
158
162
  }
@@ -160,10 +164,8 @@ export interface ResolvedSlide {
160
164
  export interface RenderBinding {
161
165
  type: string;
162
166
  field?: string;
163
- slot?: string;
164
167
  value: unknown;
165
168
  path: string;
166
- placeholderIndex?: number;
167
169
  regionKey?: string;
168
170
  }
169
171
 
package/dist/svg.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  ENGINE_DEFAULT_THEME, ENGINE_DEFAULT_COLOR_SCHEME, ENGINE_DEFAULT_FONT_SCHEME, ENGINE_DEFAULT_CHART_TYPES
10
10
  } from "@openpresentation/opf/composition";
11
11
  // The renderer is a library: it never imports a catalog. Hosts register theirs (`options.catalogs`, core `Catalog[]`, for
12
- // example `defaultCatalog` from `@openpresentation/opf/catalog`) and core resolves every reference against them.
12
+ // example `defaultCatalog` from `@openpresentation/gallery`) and core resolves every reference against them.
13
13
  import {
14
14
  hasContentVariables,
15
15
  isTemplate,
@@ -132,7 +132,6 @@ const PROMOTED_REGION_KEYS = [
132
132
  "top+middle+bottom:left+center+right"
133
133
  ];
134
134
 
135
- const TITLE_PLACEHOLDERS = new Set(["title", "subtitle", "tag"]);
136
135
 
137
136
  const FIELD_TYPE = Object.freeze({
138
137
  text: "text",
@@ -457,9 +456,9 @@ function reportGlyphFallback(context, note) {
457
456
  });
458
457
  }
459
458
 
460
- function contentPayloadFromHost(host, path, slot) {
459
+ function contentPayloadFromHost(host, path) {
461
460
  if (!isPlainObject(host)) {
462
- return { type: "text", field: "text", value: host, slot: slot ?? "text", path };
461
+ return { type: "text", field: "text", value: host, path };
463
462
  }
464
463
 
465
464
  for (const field of ROOT_PAYLOAD_FIELDS) {
@@ -468,7 +467,6 @@ function contentPayloadFromHost(host, path, slot) {
468
467
  type: host.type ?? FIELD_TYPE[field],
469
468
  field,
470
469
  value: host[field],
471
- slot: host.slot ?? slot ?? host.type ?? FIELD_TYPE[field],
472
470
  path: `${path}.${field}`
473
471
  };
474
472
  }
@@ -478,7 +476,6 @@ function contentPayloadFromHost(host, path, slot) {
478
476
  type: "text",
479
477
  field: "text",
480
478
  value: stableJson(host),
481
- slot: slot ?? "text",
482
479
  path
483
480
  };
484
481
  }
@@ -491,7 +488,6 @@ function slideRootContent(slide, slidePath) {
491
488
  type: slide.type ?? FIELD_TYPE[field],
492
489
  field,
493
490
  value: slide[field],
494
- slot: slide.type ?? FIELD_TYPE[field],
495
491
  path: `${slidePath}.${field}`
496
492
  });
497
493
  }
@@ -503,8 +499,8 @@ function promotedRegionContent(slide, slidePath) {
503
499
  const items = [];
504
500
  for (const key of PROMOTED_REGION_KEYS) {
505
501
  if (slide[key] !== undefined) {
506
- const payload = contentPayloadFromHost(slide[key], `${slidePath}.${key}`, key);
507
- items.push({ ...payload, slot: key, regionKey: key });
502
+ const payload = contentPayloadFromHost(slide[key], `${slidePath}.${key}`);
503
+ items.push({ ...payload, regionKey: key });
508
504
  }
509
505
  }
510
506
  return items;
@@ -512,15 +508,7 @@ function promotedRegionContent(slide, slidePath) {
512
508
 
513
509
  function blockContent(slide, slidePath) {
514
510
  if (!Array.isArray(slide.blocks)) return [];
515
- return slide.blocks.map((block, index) => contentPayloadFromHost(block, `${slidePath}.blocks.${index}`, "body"));
516
- }
517
-
518
- function normalizeFutureContent(slide, slidePath) {
519
- if (!Array.isArray(slide.content)) return [];
520
- return slide.content.map((item, index) => {
521
- const payload = contentPayloadFromHost(item, `${slidePath}.content.${index}`, item.slot);
522
- return { ...payload, slot: item.slot ?? payload.slot, path: `${slidePath}.content.${index}` };
523
- });
511
+ return slide.blocks.map((block, index) => contentPayloadFromHost(block, `${slidePath}.blocks.${index}`));
524
512
  }
525
513
 
526
514
  // The core catalog options every resolution takes: the host's catalogs, unchanged, and its strict reference policy.
@@ -556,27 +544,7 @@ function bindSlide(presentation, index, context) {
556
544
  // {{deck.slideCount}} hold this slide's number, its section and the deck's slide count, the same values the furniture draws. Core resolved them.
557
545
  const slide = slideContext.slide;
558
546
  const layout = slideContext.options.layout;
559
- const placeholders = Array.isArray(layout?.placeholders) ? layout.placeholders : [];
560
- const titleBindings = [];
561
-
562
- for (let i = 0; i < placeholders.length; i += 1) {
563
- const type = placeholders[i]?.type;
564
- if (!TITLE_PLACEHOLDERS.has(type)) continue;
565
- const value = slide[type] ?? presentation[type] ?? "";
566
- if (value) {
567
- titleBindings.push({
568
- type: "text",
569
- field: type,
570
- slot: type,
571
- value,
572
- placeholderIndex: i,
573
- path: `${slidePath}.${type}`
574
- });
575
- }
576
- }
577
-
578
547
  const contentItems = [
579
- ...normalizeFutureContent(slide, slidePath),
580
548
  ...slideRootContent(slide, slidePath),
581
549
  ...promotedRegionContent(slide, slidePath)
582
550
  ];
@@ -638,7 +606,6 @@ function bindSlide(presentation, index, context) {
638
606
  slide,
639
607
  layout,
640
608
  design,
641
- titleBindings,
642
609
  contentItems,
643
610
  blocks
644
611
  };
@@ -911,10 +878,15 @@ function imageRecolorMatrix(recolor, bound) {
911
878
  function renderSlideContent(bound, width, height, options) {
912
879
  for (const diagnostic of [...bound.design.diagnostics, ...bound.geometry.diagnostics]) reportDiagnostic(diagnostic, options);
913
880
  return bound.geometry.items.map(item => {
881
+ // Cards come from core's geometry: item.frameBox is the card core allocated in the item's cell (OPF 0.19: a template
882
+ // region's flow cell; core never gives an item of a bled region a card).
914
883
  const frame=item.frameBox;
915
884
  const surface=frame ? tag('rect',{...HIDDEN,x:frame.x,y:frame.y,width:frame.width,height:frame.height,rx:8*Math.min(width,height)/720,fill:bound.design.colors.surface,stroke:bound.design.colors.border,...traceAttrs(options,item.path)}) : '';
916
885
  // RR-34: a captioned item draws its media in item.box and its caption band after it (src/annotations.js).
917
- return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options)+(item.caption?renderCaption(item,{ ...bound, composition: item.composition },options,drawHelpers):'');
886
+ const drawn=surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options)+(item.caption?renderCaption(item,{ ...bound, composition: item.composition },options,drawHelpers):'');
887
+ // OPF 0.19 (RR-81): with trace, everything an item of a layout template region draws (card, payload, caption) is
888
+ // grouped under data-opf-region, the region's name (ComposedItem.region). Items outside a template are unchanged.
889
+ return options.trace && item.region !== undefined && drawn ? tag('g',{'data-opf-region':item.region},drawn) : drawn;
918
890
  });
919
891
  }
920
892
  // Draw helpers injected into src/annotations.js (captions and footnote areas).
@@ -985,8 +957,26 @@ function renderTextPayload(item, box, bound, options) {
985
957
  }
986
958
 
987
959
  function renderList(item, box, bound, options) {
960
+ // OPF 0.19 (RR-81): a list core laid out in columns (item.listColumns) draws every column's own fit in its own box, and
961
+ // only those: item.text is then the whole list's fit and is never drawn, or the list would draw twice. Core renumbers each
962
+ // column's entries (indexes, paths and markers), so numbering continues across columns. With trace, each column is a
963
+ // group carrying its index, its box and the half-open item range it holds.
964
+ if (Array.isArray(item.listColumns) && item.listColumns.length > 1) {
965
+ const columns=item.listColumns.map((column,index)=>{
966
+ const entries=listEntriesSvg(item,column.text,bound,options).join('\n');
967
+ return options.trace?tag('g',{'data-opf-list-column':index,'data-opf-list-start':column.start,'data-opf-list-end':column.end,
968
+ 'data-opf-box-x':column.box.x,'data-opf-box-y':column.box.y,'data-opf-box-width':column.box.width,'data-opf-box-height':column.box.height,
969
+ ...(column.text.overflow?{'data-opf-overflow':'true'}:{})},entries):entries;
970
+ });
971
+ return tag('g',{...traceAttrs(options,item.path),...(item.listColumns.some(column=>column.text.overflow)?{'data-opf-overflow':'true'}:{})},columns.join('\n'));
972
+ }
988
973
  const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
989
974
  const fit=item.text?.listEntries?item.text:fitList(item.value,box,25*scale,((bound.composition??bound.geometry.composition).minFontSize??16)*scale,{style:{fontFamily:bound.design.fonts.body,fontWeight:400,path:item.path},...(bound.design.fonts.code?{codeFontFamily:bound.design.fonts.code}:{}),textMeasurement:options.textMeasurement,...(item.payload?.numbering!==undefined?{numbering:item.payload.numbering}:{})});
975
+ return tag('g',{...traceAttrs(options,item.path),...(fit.overflow?{'data-opf-overflow':'true'}:{})},listEntriesSvg(item,fit,bound,options).join('\n'));
976
+ }
977
+
978
+ // The markers, item text and descriptions of one list fit: a whole list, or one column of a list in columns.
979
+ function listEntriesSvg(item, fit, bound, options) {
990
980
  const children=[];
991
981
  // design.listBullet=image: core attaches the icon logo as item.bulletImage and its box (entry.bulletBox: 0.65 em, as PowerPoint draws a:buBlip). An icon that cannot be drawn keeps the glyph marker.
992
982
  const bullet=item.bulletImage?resolveBulletImage(item.bulletImage,bound,options):undefined;
@@ -997,7 +987,7 @@ function renderList(item, box, bound, options) {
997
987
  children.push(renderRichLines(typeof entry.value==='string'?[entry.value]:entry.value,entry.text,entry.textBox,bound,config));
998
988
  if(entry.description)children.push(renderRichLines(typeof entry.descriptionValue==='string'?[entry.descriptionValue]:entry.descriptionValue,entry.description,entry.descriptionBox,bound,{...config,path:entry.descriptionPath,rich:Array.isArray(entry.descriptionValue),fill:bound.design.colors.mutedText}));
999
989
  }
1000
- return tag('g',{...traceAttrs(options,item.path),...(fit.overflow?{'data-opf-overflow':'true'}:{})},children.join('\n'));
990
+ return children;
1001
991
  }
1002
992
 
1003
993
  // A numbered list's marker (numbering) draws the number with the weight and slant core measured it at: PowerPoint draws an
@@ -320,6 +320,8 @@ function precise(value) { return String(Number(Number(value).toPrecision(6))); }
320
320
  * slide's text with it. The faces are read from the handle when a slide is outlined, so faces it loads later are used too.
321
321
  */
322
322
  export function textOutlines(fonts) {
323
+ const shape = fonts.shaper ? harfBuzzShape(fonts.shaper, { clusterLevel: 2 }) : undefined;
324
+ fonts.registry?.setShaper?.(shape ? { shape } : undefined);
323
325
  return Object.freeze({
324
326
  outlineSlideText: (content, options) => {
325
327
  const library = outlineLibrary(fonts);
@@ -329,7 +331,7 @@ export function textOutlines(fonts) {
329
331
  // opf-render#188: the vector PDF shapes its runs with the same HarfBuzz. Each character stays with the glyph it became (cluster
330
332
  // 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
333
  // the glyphs and positions are the outlines'.
332
- ...(fonts.shaper ? { shape: harfBuzzShape(fonts.shaper, { clusterLevel: 2 }) } : {}),
334
+ ...(shape ? { shape } : {}),
333
335
  });
334
336
  }
335
337
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-render",
3
- "version": "0.18.1",
3
+ "version": "0.19.1",
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.19.0",
146
146
  "bidi-js": "1.1.0",
147
147
  "fontkit": "^2.0.4",
148
148
  "harfbuzzjs": "1.6.3",
@@ -341,6 +341,7 @@
341
341
  }
342
342
  },
343
343
  "devDependencies": {
344
+ "@openpresentation/gallery": "^2.0.0",
344
345
  "@expo-google-fonts/arimo": "0.4.3",
345
346
  "@expo-google-fonts/caladea": "0.4.2",
346
347
  "@expo-google-fonts/cousine": "0.4.3",