@openpresentation/opf-render 0.18.0 → 0.18.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
@@ -98,6 +98,31 @@ Version 0.8.0 resolves OpenType preferred-family groups, so a Roboto request at
98
98
 
99
99
  Code strings that [XML 1.0 cannot represent](https://www.w3.org/TR/xml/#charsets) reject rendering with `invalid-code-text`, the OPF field path and UTF-16 offset. Input JSON stays unchanged. Tabs, line endings and valid supplementary Unicode remain accepted; schema validity and XML serialization do not certify font coverage or native fidelity.
100
100
 
101
+ ## Pass `fonts`, otherwise the estimate
102
+
103
+ Text measurement comes from one place: the `fonts` handle that `loadFonts()` (from `/fonts-node` or `/fonts-browser`) returns. Pass the same handle to preview, validation, pagination and export, so that every step measures with the faces the preview draws:
104
+
105
+ ```js
106
+ import { loadFonts } from '@openpresentation/opf-render/fonts-node';
107
+ import { toSvg, toPng, toPdf } from '@openpresentation/opf-render';
108
+ import { validate } from '@openpresentation/opf';
109
+ import { paginate } from '@openpresentation/opf/pagination';
110
+ import { toPptx } from '@openpresentation/opf-pptx';
111
+
112
+ const fonts = await loadFonts({ pack: 'office' });
113
+ const report = validate(deck, { fonts }); // overflow findings use the real widths
114
+ const { presentation } = paginate(deck, { fonts }); // page breaks use the real widths
115
+ const svgs = toSvg(presentation, { fonts }); // line breaks use the real widths
116
+ const png = await toPng(presentation, 1, { fonts });
117
+ const pdf = await toPdf(presentation, { fonts });
118
+ const pptx = await toPptx(presentation, { fonts }); // the same lines in editable PowerPoint text
119
+ ```
120
+
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
+
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).
124
+
125
+
101
126
  ## Install: what to add for what (RR-63)
102
127
 
103
128
  `npm install @openpresentation/opf-render` brings the SVG renderer, layout and text measurement (`@openpresentation/opf`, `fontkit`, `bidi-js`, `pako`) and the vendored font files under `fonts/`. The heavy pieces are **optional peer dependencies**, so you install only what your output needs. npm 7+ and pnpm do not install optional peers; add them yourself:
@@ -130,6 +155,8 @@ Converter ranges: `@resvg/resvg-js@^2.6.2`, `sharp@^0.35.5`, `pdf-lib@^1.17.1`.
130
155
 
131
156
  There is no `/html` entry: it would only repeat `/element` (`toHtml`, `defineOpfDeck`) and `/player` (`present`) under a second name. A bundler that bundles Node code (a server framework) sees the converters' dynamic `import()` in the root and in `/png` and `/pdf`; an app that only draws SVG should import `/svg`, which has none, and an app that exports should list the converters as installed (or external) in its bundler.
132
157
 
158
+ A server bundle needs no bundler config for `/fonts-node` and `/preview-fonts-node` either: a Next.js app (Turbopack or webpack) that imports them builds and renders what plain Node renders (`npm run test:next-bundle` checks this against Next.js 16). They read harfbuzzjs's `.wasm` files, the installed `@expo-google-fonts/*` packages and this package's `fonts/` directory from disk at run time, through Node's own resolution, so a bundler does not trace them. A deployment that ships only traced files (Next.js `output: "standalone"`, serverless functions) must include those packages, for example with `outputFileTracingIncludes`. Without harfbuzzjs, `loadFonts` still loads the fonts and reports `harfbuzz-unavailable` through `onDiagnostic`: an SVG then embeds whole faces, and outlines are shaped with fontkit.
159
+
133
160
  ### A missing converter or font package
134
161
 
135
162
  An export that needs a converter that is not installed rejects with `OPFRenderError` code `converter-missing`. `details` holds `package`, `range`, `install` (the command), `purpose` and `installed` (`false` when the package is absent; `true` when it is installed but cannot load, for example a native binary missing for the platform):
@@ -166,7 +193,7 @@ Installed size, measured on Windows with `npm install --ignore-scripts`, unpacke
166
193
 
167
194
  `toSvg(deck)` returns one SVG string per slide (`skipHidden: true` leaves out slides marked `hidden`, the player's sequence, so the result is then shorter than the deck); `toSvg(deck, 3)` returns the SVG of the third slide (slides count from 1; out of range throws `slide-out-of-range`), and `toSvg(deck, "1-3")` or `toSvg(deck, [1, 3])` the selected slides, hidden or not (`invalid-slide-selection` for a malformed one). Every form checks the format at the boundary (core `validate(deck, { only: ['format'] })`; a failure throws `OPFRenderError` `invalid-opf` whose `findings` are the error findings), resolve references from the document's embedded records and the host's `catalogs` (core `Catalog[]`, never fetched), and emit byte-stable SVG for the same input. With a `fonts` handle, each SVG embeds as `@font-face` data only the faces of `embeddedFonts` that its own text draws (by family, weight and style; a face flagged `embed: "always"` goes into every SVG), so the handle is safe to pass to every preview: over the 127 core example decks, an office-pack slide carries 2.4 MB on average (4.1 MB at most) instead of every eager face (15 MB), a base-pack slide 0.53 MB instead of 1.76 MB. Each embedded face is also cut to the glyphs the slide draws (RR-65): the Node handle (and a browser handle given `subsetWasm`, the URL of `harfbuzzjs/dist/harfbuzz-subset.wasm` the host serves, or its bytes or compiled module) subsets each face with HarfBuzz's own subsetter (hb-subset from the pinned harfbuzzjs, MIT), keeping every layout feature, so the browser shapes and draws it exactly like the whole face (147 example slides with 384 embedded faces are pixel-identical in a browser) and the bytes are the same for the same slide. Over the 127 core example decks an office-pack slide falls from 2.4 MB to 59 KB on average (2.85 MB to 59 KB at the median, 1.95 GB to 48 MB for all 807 slides), a base-pack slide from 530 KB to 73 KB. Only a face whose license allows a modified version is cut: a bundled face (by sha256 against the pinned manifest) under a permitted license whose family and file names do not contain a Reserved Font Name of its package (a subset is a modified version, owner decision 2026-09-29) and whose OS/2 fsType allows subsetting; Carlito, Raleway, Lora, Playfair Display, PT Serif, Merriweather Sans, Source Sans 3 and every face a host supplies are embedded whole. `subsetFonts: false` embeds whole faces (for an SVG whose text will be edited outside the renderer). A face whose OS/2 fsType forbids embedding (Restricted License 0x0002, or bitmap-only 0x0200, since an SVG carries outlines) is never embedded (RR-76): the SVG names its family without the data, so a viewer without the font draws the generic family, and a `font-embedding-restricted` diagnostic lists the faces left out. `renderDeckHtml` with `fontMode: "shared"` embeds each face once for the page, cut to the characters all of its slides draw (a three-slide Roboto page: 53 KB, against 101 KB with per-slide subsets and 442 KB with whole shared faces). `text: "system"` writes no `@font-face` data at all (the handle still measures), for a browser host whose `loadFonts` handle already added the faces to the page; keep the default where an SVG must stand alone (a file, the browser PDF export). Slide background colours (solid, gradient stops, pattern colours) accept the same colour references as text and table colours: a hex value, a `var:` variable, or a colour scheme slot or role such as `accent2` or `primary`.
168
195
 
169
- Accessibility of the SVG (FA-30): the slide root is a labelled container, `role="group"` with `aria-roledescription="slide"` and `aria-label` set to the slide title, never `role="img"`, which would make every text node inside presentational. Titles, body text, list entries, quotes, metrics, table cells, captions, footnotes and header or footer text stay live text a screen reader reaches, in the order the renderer paints them. Purely decorative drawing (the background and its overlay, card frames, table cell fills and borders, dividers, timeline markers, watermarks, and an image whose `alt` is `""`) carries `aria-hidden="true"`. A picture with `alt` is `role="img"` with that label (a header or footer picture without `alt` is hidden); a chart with `alt` is a `role="img"` group, and one with `alt: ""` is hidden. `<opf-deck>`, the player and `toHtml` remove the root's `role`, `aria-roledescription` and `aria-label` (the slide section or figure around the drawing carries the one name), so a slide is announced once.
196
+ Accessibility of the SVG (FA-30): the slide root is a labelled container, `role="group"` with `aria-roledescription="slide"` and `aria-label` set to the slide title, never `role="img"`, which would make every text node inside presentational. Titles, body text, list entries, quotes, metrics, table cells, captions, footnotes and header or footer text stay live text a screen reader reaches, in the order the renderer paints them. Purely decorative drawing (the background and its overlay, card frames, table cell fills and borders, dividers, timeline markers, watermarks, and an image whose `alt` is `""`) carries `aria-hidden="true"`. A picture with `alt` is `role="img"` with that label (a header or footer picture without `alt` is hidden); a chart with `alt` is a `role="img"` group, and one with `alt: ""` is hidden. `<opf-deck>`, the player and `toHtml` remove the root's `role`, `aria-roledescription` and `aria-label` (the slide section or figure around the drawing carries the one name), so a slide is announced once (not tested with assistive technology; see Accessibility under `<opf-deck>`).
170
197
 
171
198
  Text as outlines (RR-64): `toSvg(deck, { fonts, text: "paths" })` draws every `<text>` as glyph outlines, so the SVG needs no font and looks the same in every browser, image viewer and design tool. Each glyph is a `<use>` of an outline kept once per slide in `<defs>` (in font units, under an id made of the face's content hash and the glyph id, so SVGs inlined on one page never mix up glyphs); positions and widths are the vector PDF's layout of the renderer's pinned lines, and the glyphs are shaped by HarfBuzz, the shaper browsers use (the Node handle always; a browser handle given `shapeWasm`, the host's copy of `harfbuzzjs/dist/harfbuzz.wasm`; fontkit otherwise). Over the 30 script corpora every outlined glyph has HarfBuzz's id and position (fontkit, which the layout measures with, picks other glyphs on 10 samples: Bengali, Thai, Lao, Khmer, Myanmar, Syriac and Japanese combining marks); a run inside a pinned width is scaled to it, as the browser scales text. Over the core example decks resvg draws an outlined slide like its text (mean channel difference at most 0.006 of 255) and an outlined slide is about 40 KB at the median (60 KB at p90, 106 KB at most) against 0.5 to 4 MB with whole embedded faces. It needs the fonts handle `loadFonts()` returns, which carries the outline engine as `fonts.outlines` (`text-as-paths-needs-fonts` otherwise); the handle's faces are parsed for outlining once, on the first outlined slide. Each outlined element is a group that keeps the element's attributes and `data-opf-*` trace (a tspan's trace stays on a nested group); its glyphs are hidden from assistive technology and each outlined line is also real text, invisible (`fill="none"`, the generic family, so no face is embedded for it) and pinned to the drawn width, in reading order: a screen reader reads the words as text (the FA-30 tree, checked with axe), and selection, copy and find-in-page work. Links stay `<a>`; underline and line-through are rectangles from the font's own metrics. A run stays text where outlines would not be faithful, reported as `text-as-paths-fallback` with its `reason`: a colour or bitmap font (`colour-font`, emoji), a face whose fsType restricts embedding (`restricted`), or a run with no pinned width on which HarfBuzz and the layout's measurement disagree by more than 0.1 px (`shaping`); the SVG embeds just those runs' faces. The trade-offs, which make it the choice for thumbnails, previews and portable SVG files and not for an editing surface: the drawn text is not editable (a caret and an input method need live text in its own font); a vector PDF made from an outlined SVG has paths instead of selectable text, so `toPdf` should get the `<text>` SVG; browsers draw outlines without the hinting they give text, so small text looks a little lighter than browser text (resvg already draws text as outlines); and in `/fonts-browser` the outline engine adds about 85 KB (minified) to the bundle. Plan and later phases: [opf-render#164](https://github.com/OpenPresentation/opf-render/issues/164).
172
199
 
@@ -220,7 +247,7 @@ const pdf = await toPdf(svgs, { fonts });
220
247
  `toPdf(svgs)` converts the same SVG the preview draws, without a second layout pass: every line, position and width comes from the SVG.
221
248
 
222
249
  - Shapes, lines, polylines, paths and rounded rectangles are PDF paths; linear and radial gradients are PDF shadings and hatch/tile `<pattern>`s are tiling patterns; `clip-path`, stroke dashes, group `opacity` (a transparency-group form) and per-element opacity are native. Pictures are image XObjects (PNG with alpha as an SMask, unoriented JPEG passed through, WebP and oriented JPEG decoded as in raster mode; identical pictures are stored once).
223
- - Text is real text: TrueType subsets (the font program, a `ToUnicode` map and a `CIDToGIDMap`) of the same font files the PNG preview draws with, positioned glyph by glyph (kerning, ligatures, combining marks and mixed scripts as fontkit shapes them), with the SVG's `textLength` and `text-anchor`, underline and strike-through. The Unicode bidirectional algorithm orders right-to-left and mixed-direction lines. Text is selectable, searchable and copies as the authored text. The encoding is the one Chromium writes, so PDFium (Chrome, Edge), poppler and pdf.js read it back the way they read a Chrome-printed page: runs are drawn in visual order, right-to-left runs are marked `/ReversedChars`, and a glyph or cluster the glyph map cannot give (an Arabic ligature or mirrored bracket, a reordered Indic or Khmer syllable, a no-break space) carries its logical text as `/ActualText`, one span per glyph or cluster. A mark the font split off a letter (Arabic dots) is drawn as a filled outline, so no extractor sees an extra character. There is no hidden text layer and no outline-only text. Known reader limits, shared with Chrome's own PDFs: PDFium duplicates some Thai and Burmese marks, PDFium reorders the runs of a mixed-direction line, and pdf.js ignores `/ActualText` (it reports reordered Indic and Khmer clusters in drawing order).
250
+ - Text is real text: TrueType subsets (the font program, a `ToUnicode` map and a `CIDToGIDMap`) of the same font files the PNG preview draws with, positioned glyph by glyph (kerning, ligatures, combining marks and mixed scripts as HarfBuzz, the browser's shaper, shapes them: with the fonts handle `loadFonts()` returns, which carries the shaper as `fonts.outlines.shape`, or, without a handle, the bundled one the conversion loads; a browser handle needs `shapeWasm`; fontkit shapes otherwise. Over the 30 script corpora every glyph the PDF shows has HarfBuzz's id and position (opf-render#188), where fontkit drew other glyphs on 10 samples: Bengali, Thai, Lao, Khmer, Myanmar, Syriac and Japanese combining marks), with the SVG's `textLength` and `text-anchor`, underline and strike-through. The Unicode bidirectional algorithm orders right-to-left and mixed-direction lines. Text is selectable, searchable and copies as the authored text. The encoding is the one Chromium writes, so PDFium (Chrome, Edge), poppler and pdf.js read it back the way they read a Chrome-printed page: runs are drawn in visual order, right-to-left runs are marked `/ReversedChars`, and a glyph or cluster the glyph map cannot give (an Arabic ligature or mirrored bracket, a reordered Indic or Khmer syllable, a no-break space) carries its logical text as `/ActualText`, one span per glyph or cluster. A mark the font split off a letter (Arabic dots) is drawn as a filled outline, so no extractor sees an extra character. There is no hidden text layer and no outline-only text. Known reader limits, shared with Chrome's own PDFs: PDFium duplicates some Thai and Burmese marks, PDFium reorders the runs of a mixed-direction line, and pdf.js ignores `/ActualText` (it reports reordered Indic and Khmer clusters in drawing order).
224
251
  - Fonts: only the bundled pack (unless the handle says `useBundledFonts: false`), the handle's `fontFiles` (or the font folders given as `fonts`) and the SVG's own `@font-face` data are used; a handle with `loadSystemFonts: true` is rejected for a vector PDF because system fonts could be proprietary. A face whose OS/2 `fsType` forbids embedding is never embedded (a diagnostic says so); one that forbids subsetting is embedded whole. A requested family that has no face is drawn with the family the PNG preview draws (`defaultFontFamily`, `sansSerifFamily`, `monospaceFamily`, `serifFamily`) and reported.
225
252
  - Document metadata: `metadata: { title, author, subject, keywords, language, creator, creationDate }`. The language defaults to the first SVG's `lang`. No date is written unless you supply one, there is no random data, and the file identifier is a hash of the content, so identical input gives identical bytes on every machine.
226
253
  - Accessibility basics (not a conformance claim): the catalog carries `/Lang`, `/MarkInfo`, `/DisplayDocTitle` (with a title) and XMP metadata; with `tagged` (default) the structure tree lists, per slide in source order, headings (the `.title` placeholder), paragraphs, figures with their `aria-label` as `/Alt` and link elements bound to their link annotations, and decoration (backgrounds, rules, bullets) is marked as an artifact. Not done: PDF/UA or PDF/A claims, list and table structure, per-span languages; text inside a translucent group is not in the structure tree.
@@ -275,6 +302,20 @@ const fonts = await loadFonts({ faces: fontFileEntries, subsetWasm: '/vendor/har
275
302
  const standalone = toSvg(presentation, 1, { fonts }); // each @font-face is a glyph subset
276
303
  ```
277
304
 
305
+ ### Safari and WebKit: no kerning in SVG text (known limitation)
306
+
307
+ WebKit (Safari, and Playwright's WebKit) applies no kerning to SVG `<text>`, and neither `font-kerning` nor `font-feature-settings` changes that. Its canvas and HTML text do kern, and they match the renderer's metrics.
308
+
309
+ The effect is small:
310
+
311
+ - **Natural width:** a line's natural width in WebKit is up to about 1% wider than the renderer's layout (8 px on a long Arabic line, about 3 px on a title). Chromium is within 0.015 px.
312
+ - **Line ends:** the renderer pins each line with `textLength`, so line ends stay where the layout put them. WebKit compresses the unkerned glyphs a little to fit, and one title ends about 3 px short.
313
+
314
+ Measured on 2026-10-02 on a Mac (opf-render#118, `docs/evidence/mac-checks-20261002/` in the core repository).
315
+
316
+ - **When this matters:** a host that needs WebKit to place every glyph exactly as Chromium and PowerPoint do should draw with `text: "paths"` (see [Text as outlines](#scope)). The renderer positions each glyph itself, with HarfBuzz's kerned advances, so no browser text layout is involved. The invisible text layer still serves selection, copy and screen readers (the structure; not tested with assistive technology). The editing canvas stays on real text.
317
+ - **Default output:** the renderer does not write per-glyph `x` or `dx` positions into `<text>` to work around WebKit. It would make every SVG larger and change the selection and editing paths of every browser, for a difference `textLength` already holds to the line.
318
+
278
319
  ## Player and `<opf-deck>` (RR-28)
279
320
 
280
321
  A slideshow player and an embeddable web component, both built on `toSvg`: the slide a page shows is the slide the preview, the editor and the PDF show, with no second layout engine. They are plain ES modules, typed, framework-free and tree-shakeable, and importing them touches no DOM, so they are safe in Next.js and other server renderers.
@@ -323,7 +364,9 @@ Events: `ready`, `slidechange` (the same detail as `currentSlide`; bubbles and i
323
364
 
324
365
  Hidden slides (`hidden: true`) are skipped everywhere, so the counter, the `slide` attribute and the player's number-then-Enter all count the sequence that plays; `include-hidden` plays them all. Navigation: the buttons, ArrowLeft, ArrowRight, Page Up, Page Down, Home and End while the slide has focus (Up, Down and Space are left to the page), a horizontal swipe on touch, and the thumbnails. A deck whose `language` is right to left (and a page that sets `dir` or CSS `direction`) turns the controls and the Left and Right keys around.
325
366
 
326
- Accessibility: the element is a labelled region (the deck name) holding a focusable group named `Slide 3 of 8: Revenue grew`; slide text is live SVG text in the order the renderer paints it (tag, title, content, footnotes, then header and footer), not an image, so a screen reader reads it; slide changes made by keyboard or thumbnail are announced through a polite live region; thumbnails are a list of buttons (one tab stop, arrow keys inside, `aria-current`) whose drawings are hidden from assistive technology; the buttons keep their focus ring and `forced-colors` support; and animation is limited to a hover colour that `prefers-reduced-motion: reduce` removes. The test suite runs axe-core (WCAG 2.0 to 2.2 A and AA plus best practice) over the element, the player and the speaker view with no violations. There are no transitions or builds (deferred, opf#250).
367
+ Accessibility: the element is a labelled region (the deck name) holding a focusable group named `Slide 3 of 8: Revenue grew`; slide text is live SVG text in the order the renderer paints it (tag, title, content, footnotes, then header and footer), not an image, so a screen reader can reach it (the DOM structure; see the testing statement below); slide changes made by keyboard or thumbnail are announced through a polite live region; thumbnails are a list of buttons (one tab stop, arrow keys inside, `aria-current`) whose drawings are hidden from assistive technology; the buttons keep their focus ring and have `forced-colors` styles; and animation is limited to a hover colour that `prefers-reduced-motion: reduce` removes. The test suite runs axe-core (WCAG 2.0 to 2.2 A and AA plus best practice) over the element, the player and the speaker view with no violations. There are no transitions or builds (deferred, opf#250).
368
+
369
+ What has and has not been tested (opf-render#170). **Not tested with assistive technology:** no screen reader (NVDA, JAWS, VoiceOver, TalkBack) has been run against `<opf-deck>`, the player or the SVG, so the announcements, reading order and focus behaviour described above are the documented DOM and ARIA structure, checked with axe-core, the browser's accessibility tree and focus-order assertions, not a recorded screen-reader pass. **Forced colors, verified with Chromium's emulation only:** `test/forced-colors-browser.mjs` runs the element (the website-starter embed styled with the site's `--opf-deck-*` tokens, and a bare `<opf-deck>`) and the slideshow with `page.emulateMedia({ forcedColors: 'active' })` in both the light and the dark colour scheme, and asserts as computed style and as screenshot pixels that the slide boundary, the Previous, Next and Present buttons, the thumbnails and the current thumbnail, and the slideshow's and the speaker view's controls, notes and slide boxes (including Exit) keep a visible frame in a system colour, that every focused control draws a solid focus ring of at least 2 px in a system colour that differs from `Canvas`, and that a disabled control is `GrayText` rather than faded. It last passed on 2026-10-10 with opf-render 0.18.0, Playwright 1.63.0 and Microsoft Edge 154 (Chromium) on Windows 11, and runs in CI in the pinned Playwright Chromium. This is an emulation, not the Windows contrast theme: the real contrast themes (and macOS Increase Contrast) were not run, and no system setting was changed. The slide drawing is the SVG's own colours (forced colors restyle the HTML around it, not the SVG fills); only its frame is guaranteed visible.
327
370
 
328
371
  ### Server markup
329
372
 
@@ -434,7 +477,7 @@ The replacement faces are an optional, hash-pinned font pack: 70 static faces fr
434
477
  import { loadFonts } from '@openpresentation/opf-render/fonts-node';
435
478
  import { toSvg, toPng } from '@openpresentation/opf-render';
436
479
 
437
- // npm install @expo-google-fonts/noto-sans-jp@0.4.3 @expo-google-fonts/noto-naskh-arabic@0.4.5
480
+ // npm install @expo-google-fonts/noto-sans-jp@0.4.4 @expo-google-fonts/noto-naskh-arabic@0.4.5
438
481
  const fonts = await loadFonts({ pack: 'office', substitutionPolicy: 'visual', scripts: ['Jpan', 'Arab'] });
439
482
  const slides = toSvg(presentation, { fonts });
440
483
  const png = await toPng(slides[0], { fonts });
@@ -537,11 +580,11 @@ Dependency policy:
537
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).
538
581
  - `@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).
539
582
  - `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.
540
- - 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), `fontkit` for shaping and font metrics and `bidi-js` for the Unicode bidirectional algorithm. No hosted service, network call or system font is involved.
583
+ - 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.
541
584
  - Bundled OFL Roboto and Roboto Mono TTF files (the optional peers `@expo-google-fonts/roboto` and `roboto-mono`, which Node `loadFonts()` and the PNG/PDF default read) provide the default deterministic font fallback.
542
585
  - `toPng` and `toPdf` disable system-font loading by default. Hosts that require branded fonts should pass a handle with explicit `fontFiles` (or font folders as `fonts`); `fonts: { loadSystemFonts: true }` is an opt-in escape hatch for PNG and raster PDF output and can make it environment-dependent. A vector PDF rejects it (`pdf-system-fonts-unsupported`): it embeds only fonts you supply, so a system font can never be embedded by accident.
543
586
 
544
- Browser support boundary: `toSvg`, `toSvg`, and `resolvePresentation` are browser-importable pure JavaScript APIs. The root `toPng` and `toPdf` (also `/png` and `/pdf`) are Node APIs because they depend on the Node builds of resvg and sharp, which are optional peers loaded on first use (`converter-missing` when absent). For a page, `@openpresentation/opf-render/export-browser` has the same two names (see below).
587
+ Browser support boundary: `toSvg` and `resolvePresentation` are browser-importable pure JavaScript APIs. The root `toPng` and `toPdf` (also `/png` and `/pdf`) are Node APIs because they depend on the Node builds of resvg and sharp, which are optional peers loaded on first use (`converter-missing` when absent). For a page, `@openpresentation/opf-render/export-browser` has the same two names (see below).
545
588
 
546
589
  Chartex chart previews (FF-22b) draw the constructs opf-pptx exports as Office 2016 chartex parts: `treemap` (squarified tiles of the first series, one colour per tile, category labels), `histogram` (a lone value column binned like PowerPoint with Scott's rule count and right-closed bins, or one column per category), `pareto` (columns sorted descending with the cumulative-percentage line on a 0-100% axis), `box-and-whisker` (rows grouped by category, one box per series, exclusive quartiles, whiskers within 1.5 IQR, mean markers, outlier points), `waterfall` (floating bars from the running total, increases and decreases in the first two palette colours, connector lines) and `funnel` (centred bars with value labels). `world` is an honest non-geographic preview: one tile per region shaded by value with its name and value. No geography data is shipped; PowerPoint draws the real map from Bing geodata it fetches itself, so the preview and the native map agree on labels, values and the series colour, not on shapes. Each chartex kind also accepts a lone value column (row numbers as categories; histogram and pareto bin the values). Every mark and label keeps a `data-opf-path` (bins and boxes trace to their value column).
547
590
 
@@ -557,6 +600,41 @@ Classic chart previews keep finite axis coordinates for subnormal values and val
557
600
 
558
601
  Category-axis labels follow PowerPoint's automatic labelling instead of wrapping inside a band (column, bar, line and area charts, including stacked and 100%, and the chartex histogram, pareto, waterfall, funnel and box-and-whisker previews). A label is never broken inside a word: it stays on one line, or splits at its spaces onto two lines when a band is too narrow, so `Category 1` cannot become `Ca` over `t 1`. When the labels do not fit horizontally they turn to -45 degrees, then -90 degrees (the plot area shrinks to make room, by at most 40% of the chart height, and a slanted first label must stay inside the chart), and when even that collides the axis draws every n-th label (Office's automatic `tickLblSkip`): n is the smallest interval that leaves no two drawn labels overlapping, the first label is always drawn, and ties go to the less rotated arrangement. In horizontal bar charts and funnels, where the category axis is vertical, rows shorter than a line of text are skipped the same way, and the label gutter grows with the longest label up to 30% of the chart width. Only a label wider than that gutter (or wider than the whole chart on a horizontal axis) is ellipsized, and then the renderer reports one `chart-label-truncated` diagnostic per chart with the affected label paths in `labels`; nothing is shortened silently, and no arrangement raises `text-overflow` for an axis label. Radar spoke labels use the same one-line, two-line or ellipsis rule and leave out a spoke label that would collide with one already drawn. Rotated labels are wrapped in a `<g transform="rotate(...)">` around their usual text group, so the traced `data-opf-box-*` attributes describe the unrotated box. PowerPoint applies its own automatic rotation and skip to the exported chart: opf-pptx writes an automatic axis text body (`<a:bodyPr/>`, no `rot`) and no `tickLblSkip` for these axes, so nothing forces a wrap, and the preview and the export make the same kind of choice, not identical ones (the export uses 9 pt labels, the preview the readability floor).
559
602
 
603
+ ## Long-lived workers: measured memory and latency (opf-render#171)
604
+
605
+ A render or export service keeps one fonts handle per worker process and reuses it ("load fonts once per worker"). `npm run bench:soak` (`scripts/soak.mjs`; manual or scheduled, never part of the default CI gate) measures that pattern. Each worker process holds one `loadFonts({ pack: 'office', substitutionPolicy: 'visual', scripts: 'auto' })` handle for its whole life, blocks the network (a guard counts and refuses any attempt) and repeats jobs: `fonts.ensure(deck)`, `toSvg`, `toPng` of those SVGs and `toPptx` (opf-pptx 0.18.0, which is not a dependency of this package: `npm install --no-save @openpresentation/opf-pptx@0.18.0` and run with `--link-renderer`, since opf-pptx imports the renderer as an optional peer; without opf-pptx the PPTX leg is skipped). The jobs take the 127 core example decks in rotation (5 jobs in 6) and a 30-slide script deck built from the script corpora (every sixth job; one slide per script from Latin to Tibetan, with CJK, Arabic and Indic text, so `ensure` loads the script faces), 500 jobs per worker at concurrency 1 and 4. Memory is sampled after every job following a forced garbage collection (`heapUsed`, the retained heap, and `rss`).
606
+
607
+ Measured 2026-10-10 on an AMD Ryzen 7 9700X (8 cores, 16 threads), 31.6 GiB RAM, Windows 11 (10.0.26200), Node 24.21.0, opf-render 0.18.0 and opf-pptx 0.18.0, on a desktop that other programs also use (an earlier concurrency 1 run overlapped with other load, took twice as long per job and gave the same memory and determinism results; the numbers below are its rerun on a quieter machine). These are measurements, not an SLO; Linux and other hardware will differ.
608
+
609
+ | | Concurrency 1 | Concurrency 4 (500 jobs per worker) |
610
+ | --- | ---: | ---: |
611
+ | Jobs / slides | 500 / 5,191 | 2,000 / 20,583 |
612
+ | Wall time | 13.2 min | 19.0 min |
613
+ | Throughput | 0.63 jobs/s, 6.6 slides/s | 1.75 jobs/s, 18.0 slides/s |
614
+ | `toSvg` per job, p50 / p95 | 0.90 s / 1.11 s | 1.28 s / 1.85 s |
615
+ | `toPng` of the SVGs per job, p50 / p95 | 0.37 s / 1.41 s | 0.48 s / 2.30 s |
616
+ | `toPptx` per job, p50 / p95 | 0.09 s / 0.24 s | 0.15 s / 0.39 s |
617
+ | Whole job, p50 / p95 / max | 1.36 s / 2.72 s / 6.1 s | 1.93 s / 4.34 s / 8.4 s |
618
+ | RSS after warm-up, median per worker | 1,030 MiB | 1,021 to 1,035 MiB |
619
+ | RSS after warm-up, highest sample | 1,774 MiB | 1,766 to 1,785 MiB |
620
+ | Peak RSS of the process | 1,895 MiB | 1,892 to 1,901 MiB |
621
+ | Retained heap after GC, median / highest | 230 / 233 MiB | 230 to 231 / 232 to 233 MiB |
622
+ | Fonts handle load (once per worker) | 0.10 s | 0.21 to 0.25 s |
623
+
624
+ - **Latency.** A job is one whole deck (a core example has at most 10 slides, the script deck 30), so the p95 is the script deck and the largest examples. Per slide, the steady-state p50 at concurrency 1 is 0.15 s for `toSvg` (which embeds a HarfBuzz subset of every face the slide draws), 0.04 s for the PNG and 0.01 s for the PPTX; the script deck takes about 2.7 s a job against 1.3 s for an example. Four workers on 16 threads give 2.8 times the throughput of one (70 % scaling); each worker is about 1.4 times slower than alone. Latency does not drift upward: each job's time over the median of its own deck moves by +6 % at most across the steady-state window (the criterion flags 10 %; it settles by job 250), and falls about 30 % in the four-worker run as V8 tiers up.
625
+ - **Memory.** The heap grows while the process warms up (104 MiB after the first job, 215 MiB after 25 jobs, 227 MiB after 125) and plateaus at 230 to 231 MiB from job 200 on, in every worker. RSS has no trend: it swings between about 0.8 and 1.8 GiB with the PNG and script-deck jobs, because the allocator returns and re-takes large buffers. Over the steady state (jobs 125 to 500) the Theil-Sen slope of RSS is 4 to 15 MiB per 100 jobs with a Mann-Kendall z of 0.9 to 2.3 (the test needs z of 3.29 and a rise of at least 5 % of the median or 16 MiB; the largest rise was 55 MiB of 1,035 MiB), and the retained heap rises 2.3 to 2.5 MiB while it settles (z is high, the rise is far below the threshold). So **no worker shows monotonic growth of RSS or of the retained heap** after the 125-job warm-up, and the data give no reason for a recycle-after-N. Provision for the peak, not the median: **about 2 GiB of RSS per worker** when script-heavy decks are in the mix (the median is about 1 GiB; every handle held 190 font files at the end, because the script faces, once loaded, stay until the handle is dropped). An operator who wants a safety net can recycle a worker after a few thousand jobs; the cost is the 0.1 to 0.25 s font load plus the warm-up.
626
+ - **Determinism.** Every deck came round about four times per worker. All 384 (deck, operation) outputs (the SVG and PNG of every slide, and the PPTX) were byte-identical on every repeat: 1,116 repeat observations at concurrency 1, 5,616 across the four workers at concurrency 4 (workers meet each deck at different points of their own history), and the two concurrency levels produce the same hashes. No network call was made. This covers the 127 example decks and the script deck; for them, no output depended on earlier renders in the same process (the measured-width dependence reported in #125 does not appear).
627
+
628
+ The report (JSON with the per-job records and memory series) and a Markdown summary with the Node version, CPU and RAM go to the git-ignored `artifacts/soak/`; the criterion for monotonic growth is defined in the header of the script.
629
+
630
+ ```sh
631
+ npm ci
632
+ npm install --no-save @openpresentation/opf-pptx@0.18.0 # optional: without it the PPTX leg is skipped
633
+ npm run build
634
+ npm run bench:soak -- --link-renderer # 500 jobs per worker at concurrency 1 and 4 (about 35 minutes)
635
+ npm run bench:soak -- --iterations 20 --concurrency 1,2 --decks 6 --script-every 0 # smoke run
636
+ ```
637
+
560
638
  ## Development
561
639
 
562
640
  ```sh
package/dist/element.js CHANGED
@@ -37,17 +37,22 @@ button[aria-disabled="true"] { opacity: 0.45; cursor: default; }
37
37
  .counter { min-width: 4.5em; text-align: center; font-variant-numeric: tabular-nums; }
38
38
  .thumbs-wrap { min-width: 0; }
39
39
  .thumbs { position: relative; display: flex; gap: 8px; margin: 0; padding: 4px 2px; list-style: none; overflow-x: auto; scroll-snap-type: x proximity; }
40
- .thumbs li { flex: 0 0 auto; width: 132px; scroll-snap-align: center; }
41
- .thumb { display: block; width: 100%; padding: 0; min-height: 0; overflow: hidden; background: var(--opf-deck-stage, #f1f1f1); }
40
+ .thumbs li { position: relative; flex: 0 0 auto; width: 132px; scroll-snap-align: center; }
41
+ .thumbs .frame { display: block; box-sizing: content-box; aspect-ratio: var(--ratio, 16 / 9); overflow: hidden; border: 1px solid transparent; border-radius: var(--opf-deck-radius, 6px); background: var(--opf-deck-stage, #f1f1f1); }
42
+ .thumbs .frame svg.opf-slide { display: block; width: 100%; height: 100%; pointer-events: none; }
43
+ .thumb, .thumbs .thumb:hover { position: absolute; inset: 0; width: 100%; height: 100%; padding: 0; min-height: 0; background: transparent; }
42
44
  .thumb[aria-current="true"] { outline: 3px solid var(--opf-deck-accent, Highlight); outline-offset: 0; }
43
- .thumb .frame { display: block; width: 100%; aspect-ratio: var(--ratio, 16 / 9); }
44
- .thumb svg.opf-slide { display: block; width: 100%; height: 100%; pointer-events: none; }
45
45
  .sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
46
46
  .flip { display: inline-block; }
47
47
  .rtl .flip { transform: scaleX(-1); }
48
48
  .rtl .bar, .rtl .thumbs { direction: rtl; }
49
49
  @media (prefers-reduced-motion: no-preference) { button { transition: background-color 120ms; } }
50
- @media (forced-colors: active) { .viewport, button, .thumb[aria-current="true"] { border-color: CanvasText; } .thumb[aria-current="true"] { outline-color: Highlight; } }
50
+ @media (forced-colors: active) {
51
+ .viewport, button, .thumb[aria-current="true"] { border-color: CanvasText; }
52
+ .thumb[aria-current="true"] { outline-color: Highlight; }
53
+ /* A disabled control is drawn in GrayText at full opacity: fading would blend its frame and its focus ring into Canvas. */
54
+ button[aria-disabled="true"] { opacity: 1; color: GrayText; border-color: GrayText; }
55
+ }
51
56
  `;
52
57
 
53
58
  const supportsCustomElements = () => typeof customElements !== "undefined" && typeof HTMLElement !== "undefined";
@@ -456,13 +461,15 @@ function createElementClass() {
456
461
  button.setAttribute("part", "thumbnail");
457
462
  button.setAttribute("aria-label", `Go to ${slideLabel(info, position + 1, sequence.length)}`);
458
463
  button.tabIndex = position === cursor.position ? 0 : -1;
464
+ // The drawing is a sibling under the button, not its content: the slide's own text is not the button's visible
465
+ // label (axe label-content-name-mismatch, WCAG 2.5.3), and the button's name is the slide's label. The button
466
+ // stays the first child (#markThumbnail) and covers the drawing (CSS), so it takes every click.
459
467
  const frame = doc.createElement("span");
460
468
  frame.className = "frame";
461
469
  frame.style.setProperty("--ratio", `${first.width} / ${first.height}`);
462
470
  frame.setAttribute("aria-hidden", "true");
463
- button.append(frame);
464
471
  button.addEventListener("click", () => { this.#announce = true; this.#go(() => this.#cursor?.goto(position + 1)); });
465
- li.append(button);
472
+ li.append(button, frame);
466
473
  li._frame = frame; li._index = index;
467
474
  return li;
468
475
  });
@@ -63,6 +63,8 @@ export async function toPdf(source, slides, options) {
63
63
  return svgsToVectorPdf(inputs, {
64
64
  fontFiles: [], fontDirs: [],
65
65
  fontData: [...(options.fonts?.registry?.exportFaces?.() ?? []), ...(Array.isArray(options.fontData) ? options.fontData : [])],
66
+ // opf-render#188: shaped by HarfBuzz when the handle has the shaper (`shapeWasm`); fontkit otherwise.
67
+ shape: options.fonts?.outlines?.shape,
66
68
  imageCodec: canvasImageCodec(),
67
69
  defaultFontFamily: options.defaultFontFamily ?? "Roboto",
68
70
  sansSerifFamily: options.sansSerifFamily ?? options.defaultFontFamily ?? "Roboto",
@@ -1620,12 +1620,12 @@ export const BUNDLED_FONT_MANIFEST=freeze({
1620
1620
  },
1621
1621
  {
1622
1622
  "name": "@expo-google-fonts/noto-sans-jp",
1623
- "version": "0.4.3",
1623
+ "version": "0.4.4",
1624
1624
  "pack": "scripts",
1625
1625
  "scripts": [
1626
1626
  "Jpan"
1627
1627
  ],
1628
- "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-jp/v/0.4.3",
1628
+ "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-jp/v/0.4.4",
1629
1629
  "license": "OFL-1.1",
1630
1630
  "licenseFile": "LICENSE_FONT",
1631
1631
  "licenseSha256": "1c05c68c34f9708415aada51f17e1b0092d2cea709bf4a94cd38114f9e73d7d9",
@@ -1640,25 +1640,25 @@ export const BUNDLED_FONT_MANIFEST=freeze({
1640
1640
  "family": "Noto Sans JP",
1641
1641
  "weight": 400,
1642
1642
  "italic": false,
1643
- "sha256": "d930d5d52d15231c283089760f84584272ad5e37e14607ba0d19c798e7a9caec"
1643
+ "sha256": "6255ea4692e97ef27330304fb96fd650f9111f868cd585fcf2802d42823930e5"
1644
1644
  },
1645
1645
  {
1646
1646
  "file": "700Bold/NotoSansJP_700Bold.ttf",
1647
1647
  "family": "Noto Sans JP",
1648
1648
  "weight": 700,
1649
1649
  "italic": false,
1650
- "sha256": "c5b7b9d6a6eb682b0d4e6bbb38509575fd2759a28f147daa74714d1359a7909e"
1650
+ "sha256": "7c03e14d8a03e8f5181dbd130712d51e4ae01ac648cbd59442ae8b16c0951835"
1651
1651
  }
1652
1652
  ]
1653
1653
  },
1654
1654
  {
1655
1655
  "name": "@expo-google-fonts/noto-sans-sc",
1656
- "version": "0.4.3",
1656
+ "version": "0.4.4",
1657
1657
  "pack": "scripts",
1658
1658
  "scripts": [
1659
1659
  "Hans"
1660
1660
  ],
1661
- "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-sc/v/0.4.3",
1661
+ "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-sc/v/0.4.4",
1662
1662
  "license": "OFL-1.1",
1663
1663
  "licenseFile": "LICENSE_FONT",
1664
1664
  "licenseSha256": "1c05c68c34f9708415aada51f17e1b0092d2cea709bf4a94cd38114f9e73d7d9",
@@ -1686,12 +1686,12 @@ export const BUNDLED_FONT_MANIFEST=freeze({
1686
1686
  },
1687
1687
  {
1688
1688
  "name": "@expo-google-fonts/noto-sans-tc",
1689
- "version": "0.4.3",
1689
+ "version": "0.4.4",
1690
1690
  "pack": "scripts",
1691
1691
  "scripts": [
1692
1692
  "Hant"
1693
1693
  ],
1694
- "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-tc/v/0.4.3",
1694
+ "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-tc/v/0.4.4",
1695
1695
  "license": "OFL-1.1",
1696
1696
  "licenseFile": "LICENSE_FONT",
1697
1697
  "licenseSha256": "1c05c68c34f9708415aada51f17e1b0092d2cea709bf4a94cd38114f9e73d7d9",
@@ -1719,12 +1719,12 @@ export const BUNDLED_FONT_MANIFEST=freeze({
1719
1719
  },
1720
1720
  {
1721
1721
  "name": "@expo-google-fonts/noto-sans-kr",
1722
- "version": "0.4.3",
1722
+ "version": "0.4.4",
1723
1723
  "pack": "scripts",
1724
1724
  "scripts": [
1725
1725
  "Kore"
1726
1726
  ],
1727
- "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-kr/v/0.4.3",
1727
+ "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-sans-kr/v/0.4.4",
1728
1728
  "license": "OFL-1.1",
1729
1729
  "licenseFile": "LICENSE_FONT",
1730
1730
  "licenseSha256": "1c05c68c34f9708415aada51f17e1b0092d2cea709bf4a94cd38114f9e73d7d9",
@@ -1739,14 +1739,14 @@ export const BUNDLED_FONT_MANIFEST=freeze({
1739
1739
  "family": "Noto Sans KR",
1740
1740
  "weight": 400,
1741
1741
  "italic": false,
1742
- "sha256": "8cbc9b353bb9ce848fd69bb6a507319dfacc659cf5fd643db5d88f3c4970e1dd"
1742
+ "sha256": "b4a747e78805bc25b610dcfc3003cb5fb2e2ec034763774b24216ed15889eef4"
1743
1743
  },
1744
1744
  {
1745
1745
  "file": "700Bold/NotoSansKR_700Bold.ttf",
1746
1746
  "family": "Noto Sans KR",
1747
1747
  "weight": 700,
1748
1748
  "italic": false,
1749
- "sha256": "4ffa20c272ae6d689f7c8d34ff9c8039a326e9c334d9fc8852b2a7396227d077"
1749
+ "sha256": "c76f806dc46b8e7981410cb083801f7f124b7f8bc0d181cdae80e78f16d0e9cc"
1750
1750
  }
1751
1751
  ]
1752
1752
  },
@@ -2551,7 +2551,7 @@ export const BUNDLED_FONT_MANIFEST=freeze({
2551
2551
  },
2552
2552
  {
2553
2553
  "name": "@expo-google-fonts/noto-color-emoji",
2554
- "version": "0.4.6",
2554
+ "version": "0.4.7",
2555
2555
  "pack": "scripts",
2556
2556
  "scripts": [
2557
2557
  "Zsye"
@@ -2560,7 +2560,7 @@ export const BUNDLED_FONT_MANIFEST=freeze({
2560
2560
  "format": "COLRv1, SVG",
2561
2561
  "rasterFamily": "Noto Emoji"
2562
2562
  },
2563
- "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-color-emoji/v/0.4.6",
2563
+ "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-color-emoji/v/0.4.7",
2564
2564
  "license": "OFL-1.1",
2565
2565
  "licenseFile": "LICENSE_FONT",
2566
2566
  "licenseSha256": "ac564676d10054a8445923dfc2dfb13c042d97888bd27c1b6ec6dfe89a9d8d62",
@@ -2573,18 +2573,18 @@ export const BUNDLED_FONT_MANIFEST=freeze({
2573
2573
  "family": "Noto Color Emoji",
2574
2574
  "weight": 400,
2575
2575
  "italic": false,
2576
- "sha256": "7fb39738ab18f10612d6f4595e2e8e47a0afdf34738460442d99cd0c344a4d90"
2576
+ "sha256": "ad4a3478943b88660a194d1434793bc02c8007819f2d1e7da9ede289e78f9ca7"
2577
2577
  }
2578
2578
  ]
2579
2579
  },
2580
2580
  {
2581
2581
  "name": "@expo-google-fonts/noto-emoji",
2582
- "version": "0.4.7",
2582
+ "version": "0.4.8",
2583
2583
  "pack": "scripts",
2584
2584
  "scripts": [
2585
2585
  "Zsye"
2586
2586
  ],
2587
- "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-emoji/v/0.4.7",
2587
+ "source": "https://www.npmjs.com/package/@expo-google-fonts/noto-emoji/v/0.4.8",
2588
2588
  "license": "OFL-1.1",
2589
2589
  "licenseFile": "LICENSE_FONT",
2590
2590
  "licenseSha256": "500bb1ccf43df7bbb522112f9133a52b16e1c35e809632f5d8609b179152de5b",
@@ -68,6 +68,32 @@ export function skipUndecodableLookups(font) {
68
68
  return guarded;
69
69
  }
70
70
 
71
+ // opf-render#125: fontkit 2.0.4 caches one Glyph per glyph id (font._glyphs) with the code points of the call that created it, and
72
+ // its layout reads the input's code points back from that cache (glyphsForString -> getGlyph(id, [codePoint]) -> GlyphInfo). A glyph
73
+ // first reached another way keeps other code points: a composite outline (run.bbox for outlineBounds, glyph.path) reaches its
74
+ // components with none, and a substitution's output carries its input's. Noto Sans Bengali draws ra (U+09B0) with the nukta glyph
75
+ // (U+09BC) as a component, so once any outline with ra was bounded, a later run with a nukta shaped the nukta as an unknown
76
+ // character, the Indic shaper broke its cluster and inserted a dotted circle (0.51 em each): the measured width of a string
77
+ // depended on what the process had measured before. Every request for a glyph with code points now gets a glyph that carries
78
+ // exactly those (a view of the cached glyph when they differ), so shaping is a function of the text alone. The PDF writer's own
79
+ // faces do the same (pdf-fonts.js parseFace).
80
+ const codePointsPinned = new WeakSet();
81
+ export function pinGlyphCodePoints(font) {
82
+ if (codePointsPinned.has(font) || typeof font?.getGlyph !== "function") return font;
83
+ codePointsPinned.add(font);
84
+ const getGlyph = font.getGlyph.bind(font);
85
+ font.getGlyph = (id, codePoints = []) => {
86
+ const glyph = getGlyph(id, codePoints);
87
+ if (!glyph || !codePoints.length) return glyph;
88
+ const cached = glyph.codePoints;
89
+ if (cached?.length === codePoints.length && codePoints.every((codePoint, index) => cached[index] === codePoint)) return glyph;
90
+ const view = Object.create(glyph);
91
+ view.codePoints = [...codePoints];
92
+ return view;
93
+ };
94
+ return font;
95
+ }
96
+
71
97
  /**
72
98
  * The face a registry draws for `family` (a validated concrete family: theme tokens are already resolved) in `style`, chosen
73
99
  * from `faces` ({family, familyGroup, weight, italic, fallbackOnly?, scripts?}) under the substitution policy, aliases and
@@ -143,6 +169,7 @@ export function createFontRegistry(entries, options = {}) {
143
169
  let font;
144
170
  try { font = create(data, entry.postscriptName); } catch (error) { throw new OPFFontError("invalid-font-data", error.message); }
145
171
  if (!font?.layout || !font.unitsPerEm) throw new OPFFontError("font-collection", "Select one font from a collection with postscriptName.");
172
+ pinGlyphCodePoints(font);
146
173
  const family = validFamily(entry.family ?? font.familyName);
147
174
  // OpenType groups Medium/SemiBold/ExtraBold under a preferred family, while
148
175
  // retaining separate legacy families for four-style native font selectors.
@@ -227,6 +254,10 @@ export function createFontRegistry(entries, options = {}) {
227
254
  if(face.fontFace) resolved.fontFace={...face.fontFace};
228
255
  // FF-45: the planner maps a symbol-encoded family's codes (symbol-fonts.js); the face only lends its glyphs.
229
256
  if(resolution.symbolEncoding) resolved.symbolEncoding=resolution.symbolEncoding;
257
+ // render#220: resolving a resolved style again keeps its encoding. Core composes each list paragraph from its block's resolved
258
+ // style, so the item asks for the open symbol face (Noto Sans Symbols 2) by name; that face is the encoding's own, so the codes
259
+ // still map. Any other family drops a stale encoding.
260
+ else if(style.symbolEncoding && symbolPreviewFaces(style.symbolEncoding).some(name=>name.toLowerCase()===face.family.toLowerCase())) resolved.symbolEncoding=symbolEncodingFamily(style.symbolEncoding);
230
261
  return resolved;
231
262
  };
232
263
  const metrics = (text,size,style,includeOutline=false) => {
@@ -88,8 +88,9 @@ export declare function loadFonts(
88
88
  */
89
89
  subsetWasm?: string | URL | ArrayBuffer | ArrayBufferView | WebAssembly.Module;
90
90
  /**
91
- * RR-64: harfbuzzjs's `harfbuzz.wasm` (its URL, bytes or a compiled WebAssembly.Module). With it `text: "paths"` shapes outlines with
92
- * HarfBuzz, as the browser shapes text; without it a browser handle shapes them with fontkit (the Node handle always uses HarfBuzz).
91
+ * RR-64: harfbuzzjs's `harfbuzz.wasm` (its URL, bytes or a compiled WebAssembly.Module). With it `text: "paths"` shapes outlines, and
92
+ * `toPdf` the vector PDF's text, with HarfBuzz, as the browser shapes text; without it a browser handle shapes them with fontkit (the
93
+ * Node handle always uses HarfBuzz).
93
94
  */
94
95
  shapeWasm?: string | URL | ArrayBuffer | ArrayBufferView | WebAssembly.Module;
95
96
  crypto?: { subtle: SubtleCrypto };
@@ -32,8 +32,12 @@ export interface ScriptPackOptions {
32
32
  presentation?: unknown;
33
33
  /** The `toSvg` options the presentation resolves with (`catalogs`, ...), for the font schemes `scripts: "auto"` reads (FF-41). */
34
34
  renderOptions?: Partial<ToSvgOptions>;
35
- /** Receives `script-font-unavailable` and `script-font-not-installed` for `scripts: "auto"`. */
36
- onDiagnostic?: (diagnostic: {code: string; message: string; script?: string; package?: string; scripts?: string[]}) => void;
35
+ /**
36
+ * Receives `script-font-unavailable` and `script-font-not-installed` for `scripts: "auto"`, and from `loadFonts`
37
+ * `harfbuzz-unavailable` when a harfbuzzjs .wasm (`file`) cannot be loaded, for example in a bundled server that did not ship it:
38
+ * the handle then has no `subsets`, or shapes outlines with fontkit.
39
+ */
40
+ onDiagnostic?: (diagnostic: {code: string; message: string; script?: string; package?: string; scripts?: string[]; file?: string}) => void;
37
41
  }
38
42
  /** A vendored face that an SVG embeds only when its text names the family (the open families and Intos), and that browser hosts load on demand from a separate hash-pinned file. */
39
43
  export interface LazyFont { readonly package: string; readonly family: string; readonly weight: number; readonly italic: boolean; /** Path relative to the package root, for example fonts/intos/Intos-Regular.ttf. */ readonly file: string; readonly sha256: string; readonly license: string; readonly renamedFrom?: string; /** Set on a host's extra lazy face (`extraLazyFonts`, `package: "host"`): where the host serves the file from. */ readonly url?: string }
@@ -1,6 +1,4 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { createRequire } from "node:module";
3
- import { fileURLToPath } from "node:url";
4
2
  import path from "node:path";
5
3
  import { createHash } from "node:crypto";
6
4
  import { createFontRegistry, OPFFontError } from "./font-registry.js";
@@ -9,12 +7,12 @@ import { lazyFontList } from "./lazy-font-list.js";
9
7
  import { textOutlines } from "./text-paths.js";
10
8
  import { createSubsetter, fontSubsets } from "./font-subset.js";
11
9
  import { createShaper } from "./hb-shape.js";
10
+ import { packageRoot, resolveInstalled } from "./node-resolve.js";
12
11
  export { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
13
12
  import { analyzePresentationScripts, autoScriptSelection, nextFallbackPackage, scriptFontPackages, scriptSelectionOf, uncoveredCjkCharacters } from "./script-font-pack.js";
14
13
  export { autoScriptSelection, detectPresentationScripts, scriptFontPackages } from "./script-font-pack.js";
15
14
  // Script faces are embedded in a standalone SVG only when the slide's text uses their family.
16
15
  const embedUsed = entries => entries.map(entry => ({...entry, embed: "used"}));
17
- const require = createRequire(import.meta.url);
18
16
 
19
17
  async function verifiedFile(file, expected, details) {
20
18
  let bytes;
@@ -33,7 +31,7 @@ const FALLBACK_PACKAGE = "@expo-google-fonts/noto-sans";
33
31
  function requireInstalled(packages, what) {
34
32
  const missing = packages.filter(pkg => {
35
33
  if (pkg.vendored) return false;
36
- try { require.resolve(`${pkg.name}/package.json`); return false; } catch { return true; }
34
+ try { resolveInstalled(`${pkg.name}/package.json`); return false; } catch { return true; }
37
35
  });
38
36
  if (!missing.length) return;
39
37
  const install = `npm install ${missing.map(pkg => `${pkg.name}@${pkg.version}`).join(" ")}`;
@@ -50,7 +48,7 @@ async function loadPackages(packages, skipped, {fallbackOnly = false, what = "Th
50
48
  if (pkg.vendored) {
51
49
  // FF-31: vendored faces ship inside this package (pkg.vendored, for example fonts/carlito), hash-pinned like the npm packs. The open pack is
52
50
  // embedded in an SVG only when the slide's text names the family (embed "used"); raster output reads the files.
53
- const directory = fileURLToPath(new URL(`../${pkg.vendored}/`, import.meta.url));
51
+ const directory = path.join(packageRoot, pkg.vendored);
54
52
  let license = (await verifiedFile(path.join(directory, pkg.licenseFile), pkg.licenseSha256, {package:pkg.name, file:pkg.licenseFile})).toString("utf8");
55
53
  // A notice file carries provenance and upstream copyright lines that the upstream license file omits.
56
54
  if (pkg.noticeFile) license += "\n\n" + (await verifiedFile(path.join(directory, pkg.noticeFile), pkg.noticeSha256, {package:pkg.name, file:pkg.noticeFile})).toString("utf8");
@@ -66,7 +64,7 @@ async function loadPackages(packages, skipped, {fallbackOnly = false, what = "Th
66
64
  }
67
65
  let manifestPath, installed;
68
66
  try {
69
- manifestPath = require.resolve(`${pkg.name}/package.json`);
67
+ manifestPath = resolveInstalled(`${pkg.name}/package.json`);
70
68
  installed = JSON.parse(await readFile(manifestPath, "utf8"));
71
69
  } catch (error) {
72
70
  if (skipped) { skipped.push({package: pkg.name, version: pkg.version, scripts: [...pkg.scripts]}); continue; }
@@ -252,18 +250,19 @@ async function ensureFonts(registry, loaded, presentation, {onDiagnostic, render
252
250
  // RR-65: hb-subset from the pinned harfbuzzjs package (MIT), compiled once per process. The module has no imports, so it is
253
251
  // instantiated synchronously and toSvg stays synchronous. A copy that cannot be loaded leaves the handle without `subsets`
254
252
  // (whole faces, as before RR-65): a byte saving never stops fonts from loading.
255
- let subsetterPromise;
256
- function nodeSubsetter() {
257
- subsetterPromise ??= readFile(require.resolve("harfbuzzjs/dist/harfbuzz-subset.wasm")).then(createSubsetter, () => null).catch(() => null);
258
- return subsetterPromise;
259
- }
260
-
261
253
  // RR-64 phase 2: HarfBuzz's shaper (harfbuzz.wasm of the same package) for text drawn as outlines, so the outlines are shaped as
262
254
  // a browser shapes the text. Without it outlines are shaped with fontkit.
263
- let shaperPromise;
264
- function nodeShaper() {
265
- shaperPromise ??= readFile(require.resolve("harfbuzzjs/dist/harfbuzz.wasm")).then(createShaper, () => null).catch(() => null);
266
- return shaperPromise;
255
+ // render#199: a file that cannot be found (a bundled server that did not ship it) loads as null too, and `loadFonts` reports it
256
+ // through `onDiagnostic` (`harfbuzz-unavailable`).
257
+ const HARFBUZZ = {
258
+ subsets: {file: "harfbuzzjs/dist/harfbuzz-subset.wasm", create: createSubsetter, without: "standalone SVG embeds whole faces instead of subsets"},
259
+ shaper: {file: "harfbuzzjs/dist/harfbuzz.wasm", create: createShaper, without: "text drawn as outlines is shaped with fontkit instead of HarfBuzz"},
260
+ };
261
+ const harfbuzzEngines = {};
262
+ function nodeHarfbuzz(kind) {
263
+ const {file, create} = HARFBUZZ[kind];
264
+ harfbuzzEngines[kind] ??= Promise.resolve().then(() => readFile(resolveInstalled(file))).then(create).catch(() => null);
265
+ return harfbuzzEngines[kind];
267
266
  }
268
267
 
269
268
  /**
@@ -285,7 +284,11 @@ export async function loadFonts({pack = "base", embedScriptFonts = false, ...opt
285
284
  // The embedded list is base64 of every face: computed on first use, and again after `ensure` added faces.
286
285
  let embeddedFonts, fontFiles = [...registry.fontFiles];
287
286
  const refresh = () => { embeddedFonts = undefined; fontFiles = [...registry.fontFiles]; };
288
- const [subsetter, shaper] = await Promise.all([nodeSubsetter(), nodeShaper()]);
287
+ const [subsetter, shaper] = await Promise.all([nodeHarfbuzz("subsets"), nodeHarfbuzz("shaper")]);
288
+ for (const [kind, engine] of [["subsets", subsetter], ["shaper", shaper]]) if (!engine) {
289
+ const {file, without} = HARFBUZZ[kind];
290
+ options.onDiagnostic?.({code: "harfbuzz-unavailable", package: "harfbuzzjs", file, message: `${file} could not be loaded, so ${without}. Ship the harfbuzzjs package with the server.`});
291
+ }
289
292
  return {
290
293
  textMeasurement: registry.textMeasurement,
291
294
  get embeddedFonts() { return (embeddedFonts ??= selectEmbedded()); },
package/dist/fonts.d.ts CHANGED
@@ -57,7 +57,14 @@ export interface RenderFonts {
57
57
  /** Cuts an embedded face to code points (RR-65), with hb-subset; a face whose license, Reserved Font Name or fsType does not allow it keeps its own data URL. */
58
58
  export interface FontSubsets { subsetDataUrl(font: EmbeddedFont, codePoints: Iterable<number>): string }
59
59
  /** Draws a slide's text as glyph outlines (RR-64). Read by `toSvg`; hosts pass the handle and `text: "paths"`. */
60
- export interface TextOutlines { outlineSlideText(content: string[], options: object): { content: string[]; defs: string } }
60
+ export interface TextOutlines {
61
+ outlineSlideText(content: string[], options: object): { content: string[]; defs: string };
62
+ /**
63
+ * HarfBuzz shaping of one run of a face (opf-render#188), present when the handle has the shaper (the Node handle always, a browser
64
+ * handle with `shapeWasm`). Read by `toPdf`, which then shapes the vector PDF's text as the browser shapes it; fontkit otherwise.
65
+ */
66
+ 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
+ }
61
68
  /** What `fonts.ensure(presentation)` loaded: the script packages and vendored faces that were missing, and drawn CJK characters no face covers. */
62
69
  export interface EnsureResult { scripts: string[]; lazy: LazyFont[]; uncovered: string[] }
63
70
  /** The fonts handle `loadFonts()` returns from `/fonts-node` and `/fonts-browser`: pass it as `{ fonts }` to every deck-level function. */
package/dist/hb-shape.js CHANGED
@@ -70,8 +70,13 @@ function shaperOver(instance, state) {
70
70
  return font;
71
71
  };
72
72
  return {
73
- /** Shape `text` with the face (`key` names it, `data` is its bytes); `features` is an object of OpenType feature booleans. */
74
- shape(key, data, text, { direction = "ltr", language, features } = {}) {
73
+ /**
74
+ * Shape `text` with the face (`key` names it, `data` is its bytes); `features` is an object of OpenType feature booleans.
75
+ * `clusterLevel` is HarfBuzz's cluster level (0 by default: a mark joins its base's cluster and a reordered syllable is one
76
+ * cluster; 2 keeps each character with the glyph it became). It changes only which glyph carries which characters, never the
77
+ * glyphs or their positions.
78
+ */
79
+ shape(key, data, text, { direction = "ltr", language, features, clusterLevel } = {}) {
75
80
  const font = fontFor(key, data);
76
81
  const buffer = hb.hb_buffer_create();
77
82
  const units = text.length, textPointer = hb.malloc(units * 2 + 2);
@@ -81,6 +86,7 @@ function shaperOver(instance, state) {
81
86
  try {
82
87
  hb.hb_buffer_add_utf16(buffer, textPointer, units, 0, units);
83
88
  hb.hb_buffer_set_direction(buffer, HB_DIRECTION[direction] ?? HB_DIRECTION.ltr);
89
+ if (clusterLevel) hb.hb_buffer_set_cluster_level(buffer, clusterLevel);
84
90
  if (language) { const tag = string(language); allocated.push(tag.pointer); hb.hb_buffer_set_language(buffer, hb.hb_language_from_string(tag.pointer, tag.length)); }
85
91
  hb.hb_buffer_guess_segment_properties(buffer);
86
92
  const entries = Object.entries(features ?? {});
@@ -102,12 +108,16 @@ function shaperOver(instance, state) {
102
108
  const info = infos + index * INFO_SIZE, position = positions + index * POSITION_SIZE;
103
109
  glyphs.push({ gid: data32.getUint32(info, true), cluster: data32.getUint32(info + 8, true), advance: data32.getInt32(position, true), xOffset: data32.getInt32(position + 8, true), yOffset: data32.getInt32(position + 12, true) });
104
110
  }
105
- // The characters of each cluster go to its first glyph in output order; later glyphs of the cluster carry none.
111
+ // The characters of each cluster go to one of its glyphs: the first in logical order (output order, reversed right to left)
112
+ // that advances the pen, else the first; its other glyphs carry none (a mark or dot the font split off a letter).
106
113
  const starts = [...new Set(glyphs.map((glyph) => glyph.cluster))].sort((a, b) => a - b);
107
- const seen = new Set();
114
+ const carriers = new Map();
115
+ for (const glyph of direction === "rtl" ? [...glyphs].reverse() : glyphs) {
116
+ const carrier = carriers.get(glyph.cluster);
117
+ if (!carrier || (carrier.advance === 0 && glyph.advance !== 0)) carriers.set(glyph.cluster, glyph);
118
+ }
108
119
  for (const glyph of glyphs) {
109
- if (seen.has(glyph.cluster)) { glyph.codePoints = []; continue; }
110
- seen.add(glyph.cluster);
120
+ if (carriers.get(glyph.cluster) !== glyph) { glyph.codePoints = []; continue; }
111
121
  const end = starts[starts.indexOf(glyph.cluster) + 1] ?? units;
112
122
  glyph.codePoints = [...text.slice(glyph.cluster, end)].map((character) => character.codePointAt(0));
113
123
  }
@@ -0,0 +1,24 @@
1
+ // render#199: the files the Node entries read from disk (harfbuzzjs's .wasm, the installed `@expo-google-fonts/*` packages and this
2
+ // package's vendored `fonts/` directory) are located at run time, by Node's own resolution from this file, in a form bundlers leave
3
+ // alone. A bundler (Next.js Turbopack, webpack) treats `require.resolve` on `createRequire(import.meta.url)` as a module import: a
4
+ // literal .wasm specifier is compiled as a WebAssembly module (which fails `next build`), a computed one (`${name}/package.json`)
5
+ // becomes a stub that always throws, and `new URL(`../${dir}/`, import.meta.url)` becomes the URL of an unrelated emitted asset.
6
+ // Here `createRequire` is reached through the module namespace and the specifier is a variable behind the bundlers' ignore comments,
7
+ // so neither is traced, and the package root is computed from this file's path. Plain Node resolves exactly as before.
8
+ import * as nodeModule from "node:module";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ /** This package's root directory (the parent of `dist/`). */
13
+ export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
14
+
15
+ const requireHere = nodeModule.createRequire(import.meta.url);
16
+
17
+ /**
18
+ * The absolute path of an installed file (`harfbuzzjs/dist/harfbuzz.wasm`, `@expo-google-fonts/roboto/package.json`), resolved as
19
+ * `require.resolve` resolves it from this package, or from `directory` when given. Throws `MODULE_NOT_FOUND` like `require.resolve`.
20
+ */
21
+ export function resolveInstalled(specifier, directory) {
22
+ const resolver = directory === undefined ? requireHere : nodeModule.createRequire(path.join(directory, "package.json"));
23
+ return resolver.resolve(/* webpackIgnore: true */ /* turbopackIgnore: true */ specifier);
24
+ }
package/dist/pdf-text.js CHANGED
@@ -7,8 +7,8 @@ import { attributesOf, inheritStyle, parseFontFamilies, parseFontWeight, parseLe
7
7
  // SVG text layout for the vector PDF export (RR-12): the renderer has already decided every line, position and
8
8
  // width (x, y, text-anchor, textLength), so this module only turns that into positioned glyph runs: text chunks,
9
9
  // the Unicode bidirectional algorithm (the SVG carries directional isolates around right-to-left paragraphs),
10
- // per-character font fallback, OpenType shaping with fontkit and textLength adjustment. It never wraps, shrinks
11
- // or truncates text.
10
+ // per-character font fallback, OpenType shaping (HarfBuzz through `env.shape` when the fonts handle has it, fontkit
11
+ // otherwise) and textLength adjustment. It never wraps, shrinks or truncates text.
12
12
 
13
13
  const bidi = bidiFactory();
14
14
  const IGNORED = /[⁦-⁩‎‏‪-‮؜]/u;
@@ -245,8 +245,8 @@ function shapeRun(run, env) {
245
245
  const size = parseFloat(style["font-size"]) || 16;
246
246
  const direction = run.level % 2 === 1 ? "rtl" : "ltr";
247
247
  const language = openTypeLanguage(style.lang ?? style["xml:lang"]);
248
- // RR-64: outlined text is shaped by HarfBuzz (env.shape), the browser's shaper; the vector PDF keeps fontkit.
249
- // HarfBuzz takes the BCP 47 tag (`lang`); fontkit the OpenType language system (`language`).
248
+ // RR-64, opf-render#188: outlined text and the vector PDF are shaped by HarfBuzz (env.shape), the browser's shaper, when the
249
+ // fonts handle carries it; fontkit otherwise. HarfBuzz takes the BCP 47 tag (`lang`); fontkit the OpenType language system (`language`).
250
250
  const glyphs = (env.shape ?? shape)(run.face, run.text, { features: featuresOf(style), language, lang: style.lang ?? style["xml:lang"], direction });
251
251
  const scale = size / run.face.upem;
252
252
  const letterSpacing = parseLength(style["letter-spacing"], { fontSize: size }) ?? 0;
@@ -81,6 +81,8 @@ class Converter {
81
81
  this.env = {
82
82
  fonts: this.fonts,
83
83
  defaults: this.defaults,
84
+ // opf-render#188: HarfBuzz, the browser's shaper, from the fonts handle (`fonts.outlines.shape`); fontkit without one.
85
+ ...(typeof options.shape === "function" ? { shape: options.shape } : {}),
84
86
  diagnostic: (d) => this.diagnostic(d),
85
87
  // A feature the PDF cannot give: reported once per character and family, fatal under `strict`.
86
88
  missing: (d) => {
@@ -797,16 +799,24 @@ class Converter {
797
799
  // The text object starts at the glyph's natural position (PDFium merges an /ActualText span across adjacent objects only),
798
800
  // and the font's horizontal placement is a leading adjustment inside it.
799
801
  const shift = glyph.xOffset ? [num(-glyph.xOffset * 1000 / upem, 3)] : [];
800
- operations.push({ at: [run.x + run.scaleX * penBefore, run.y - glyph.yOffset * scale], items: [...shift, `<${code}>`] });
802
+ // A glyph shown alone only because it follows a placed one keeps the baseline (the glyphs after it continue on its line).
803
+ operations.push({ at: [run.x + run.scaleX * penBefore, run.y - (placed ? glyph.yOffset * scale : 0)], items: [...shift, `<${code}>`] });
801
804
  current = null;
802
- pending = 0;
805
+ // After a glyph that is not itself placed, the text continues from it: what its shaped advance (and its placement)
806
+ // differs from the font's width moves the next glyph (opf-render#188: a zero-width joiner after a mark put the rest of
807
+ // the run its nominal width off). After a placed glyph the next one is placed by its own Td.
808
+ pending = placed ? 0 : nominal - shaped + glyph.xOffset * 1000 / upem;
803
809
  placedBefore = placed;
804
810
  return;
805
811
  }
806
812
  if (current === null) { current = { items: [] }; operations.push(current); }
813
+ // A small horizontal placement (at most the threshold above) moves the glyph inside the TJ array and back after it
814
+ // (opf-render#188: it was dropped, which put an Arabic or Telugu glyph up to 0.025 em off).
815
+ const shift = glyph.xOffset * 1000 / upem;
816
+ pending -= shift;
807
817
  addNumber();
808
818
  hex += code;
809
- pending += nominal - shaped;
819
+ pending += nominal - shaped + shift;
810
820
  };
811
821
  // Each unit is a glyph, or for a left-to-right run the glyph map cannot give back (reordered Indic or Khmer clusters, a
812
822
  // no-break space) a cluster of glyphs. A unit whose text the map cannot give carries it as /ActualText, one span per
@@ -1,4 +1,4 @@
1
- import pako from "pako";
1
+ import { deflate } from "pako";
2
2
  import { sha256Bytes } from "./sha256.js";
3
3
 
4
4
  // A small PDF object writer for the vector export (RR-12). Everything it emits is a pure function of the objects
@@ -62,8 +62,11 @@ export function ref(number) {
62
62
  return `${number} 0 R`;
63
63
  }
64
64
 
65
+ // legacyHash keeps the classic zlib hash (canonical zlib bytes, as pako 1 wrote them). pako 3 defaults to the faster hash
66
+ // of Chromium zlib (Node), which writes different and about 1% larger streams: the inflated content is the same, the
67
+ // PDF bytes and their hashes are not.
65
68
  export function flate(bytes) {
66
- return pako.deflate(bytes, { level: 9 });
69
+ return deflate(bytes, { level: 9, legacyHash: true });
67
70
  }
68
71
 
69
72
  export function sha256(bytes) {
package/dist/player.js CHANGED
@@ -35,7 +35,10 @@ const AUDIENCE_CSS = `
35
35
  .goto[hidden] { display: none; }
36
36
  .sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
37
37
  .msg { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; text-align: center; }
38
- @media (forced-colors: active) { .hud button { border-color: ButtonText; } }
38
+ @media (forced-colors: active) {
39
+ .hud button { border-color: ButtonText; }
40
+ .hud button[aria-disabled="true"] { opacity: 1; color: GrayText; border-color: GrayText; }
41
+ }
39
42
  `;
40
43
 
41
44
  const PRESENTER_CSS = `
@@ -63,7 +66,10 @@ button[aria-pressed="true"] { background: #3d5a99; }
63
66
  button[aria-disabled="true"] { opacity: 0.45; cursor: default; }
64
67
  .sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
65
68
  @media (max-width: 760px) { main { grid-template-columns: 1fr; overflow: auto; } }
66
- @media (forced-colors: active) { button, .notes, .slidebox { border-color: ButtonText; } }
69
+ @media (forced-colors: active) {
70
+ button, .notes, .slidebox { border-color: ButtonText; }
71
+ button[aria-disabled="true"] { opacity: 1; color: GrayText; border-color: GrayText; }
72
+ }
67
73
  `;
68
74
 
69
75
  /** Document indexes in the presentation order, from the options. */
@@ -5,16 +5,14 @@
5
5
  import crypto from "node:crypto";
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
- import { createRequire } from "node:module";
9
- import { fileURLToPath } from "node:url";
10
8
  import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
11
9
  import { previewFontLayout } from "./preview-fonts.js";
12
10
  import { scriptFontPackages } from "./script-font-pack.js";
11
+ import { packageRoot as rendererRoot, resolveInstalled } from "./node-resolve.js";
13
12
 
14
13
  const ALLOWED_LICENSES = ["OFL-1.1", "Apache-2.0", "MIT", "UFL-1.0"];
15
14
  const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
16
15
  const safeFile = /^[A-Za-z0-9_./-]+$/;
17
- const rendererRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
18
16
 
19
17
  function within(directory, file) {
20
18
  const resolved = path.resolve(directory, file);
@@ -23,9 +21,8 @@ function within(directory, file) {
23
21
  }
24
22
 
25
23
  function resolvePackageDirectory(name, from) {
26
- const searched = [createRequire(path.join(rendererRoot, "package.json")), createRequire(path.join(from ?? process.cwd(), "package.json"))];
27
- for (const requireFrom of searched) {
28
- try { return path.dirname(requireFrom.resolve(`${name}/package.json`)); } catch { /* try the next location */ }
24
+ for (const directory of [rendererRoot, from ?? process.cwd()]) {
25
+ try { return path.dirname(resolveInstalled(`${name}/package.json`, directory)); } catch { /* try the next location */ }
29
26
  }
30
27
  return undefined;
31
28
  }
@@ -9,7 +9,9 @@
9
9
  // For the SVG about to be rasterized (never the emitted SVG), every <text> whose content has characters of an affected script (the
10
10
  // Indic scripts, Thai, Lao, Khmer, Myanmar), or Hangul in a KOR-language text, becomes a <g> that carries the text's attributes and
11
11
  // draws its content cluster by cluster at absolute positions:
12
- // - the x of each cluster is the fontkit advance of the prefix of its run, measured with the face resvg will pick for the run
12
+ // - the x of each cluster is the fontkit pen at its first character in its run shaped as a whole (opf-render#189: a prefix shaped
13
+ // alone loses the context of the next character, such as a Thai leading vowel kerned to its consonant; the prefix's advance is used
14
+ // where the run's glyphs do not map back to its characters, or the run has a kinzi), measured with the face resvg will pick for the run
13
15
  // (fontdb's family, style and weight matching over the same font files, with the same glyph fallback order), the OpenType language
14
16
  // of the SVG's lang and the policy features, scaled to the run's textLength when it has one; text-anchor middle and end are
15
17
  // resolved into absolute starts, baseline-shift into the y;
@@ -27,6 +29,7 @@
27
29
  import { readFile } from "node:fs/promises";
28
30
  import { create } from "fontkit";
29
31
  import { disabledFeaturesFor } from "./font-compatibility.js";
32
+ import { pinGlyphCodePoints } from "./font-registry.js";
30
33
  import { openTypeLanguage } from "./script-fonts.js";
31
34
 
32
35
  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;
@@ -353,6 +356,8 @@ async function readFace(file) {
353
356
  const data = new Uint8Array(await readFile(file));
354
357
  const font = create(data);
355
358
  if (!font?.layout || !font.unitsPerEm) return null;
359
+ // opf-render#125: an outline drawn for one cluster (ऱ, whose component is the nukta glyph) must not change how a later nukta shapes.
360
+ pinGlyphCodePoints(font);
356
361
  const records = font.name?.records ?? {};
357
362
  const names = Object.values(records.preferredFamily ?? {}).length ? Object.values(records.preferredFamily) : Object.values(records.fontFamily ?? {});
358
363
  const os2 = font["OS/2"], selection = os2?.fsSelection;
@@ -433,16 +438,42 @@ function measurePrefixes(runs, ends, props) {
433
438
  };
434
439
  const widths = [];
435
440
  try {
441
+ const pens = new Map();
442
+ // 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
+ // changes (a Thai leading vowel kerned to its consonant) is the one the browser draws; a prefix shaped alone ends without it.
444
+ const prefix = (run, length) => {
445
+ if (!pens.has(run)) pens.set(run, contextPens(run.face, run.text, props));
446
+ return pens.get(run)?.get(length) ?? width(run.face, run.text.slice(0, length));
447
+ };
436
448
  let runIndex = 0, before = 0;
437
449
  for (const end of ends) {
438
450
  while (runIndex < runs.length && runs[runIndex].start + runs[runIndex].text.length <= end) { before += width(runs[runIndex].face, runs[runIndex].text); runIndex++; }
439
451
  const run = runs[runIndex];
440
- widths.push(run && end > run.start ? before + width(run.face, run.text.slice(0, end - run.start)) : before);
452
+ widths.push(run && end > run.start ? before + prefix(run, end - run.start) : before);
441
453
  }
442
454
  } catch { return null; }
443
455
  return widths;
444
456
  }
445
457
 
458
+ /**
459
+ * The pen (px) at each character offset of `value` where a glyph carrying characters starts, with `value` shaped as one run: the sum
460
+ * of the advances of the glyphs before it. Null for text with a kinzi (measured without it, so its offsets are not the text's).
461
+ */
462
+ function contextPens(face, value, props) {
463
+ if (value.includes(KINZI)) return null;
464
+ const { run } = shapeRun(face, value, props);
465
+ const pens = new Map(), scale = props.fontSize / face.font.unitsPerEm;
466
+ let consumed = 0, pen = 0;
467
+ for (const [index, glyph] of run.glyphs.entries()) {
468
+ if (glyph.codePoints?.length) {
469
+ if (!pens.has(consumed)) pens.set(consumed, pen * scale);
470
+ for (const point of glyph.codePoints) consumed += point > 0xffff ? 2 : 1;
471
+ }
472
+ pen += run.positions[index].xAdvance;
473
+ }
474
+ return consumed === value.length ? pens : null;
475
+ }
476
+
446
477
  /** The glyph outlines of one cluster, or null when fontkit needed the mark retry (its glyph placement is then not the browser's). */
447
478
  function shapeOutlines(face, value, props) {
448
479
  let shaped;
package/dist/raster.js CHANGED
@@ -8,7 +8,7 @@ import {loadConverter} from './converters.js';
8
8
  const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
9
9
  const DEFAULT_RASTER_SCALE = 1;
10
10
  const DEFAULT_RASTER_BACKGROUND = "#FFFFFF";
11
- let bundledFontFilesCache=null;
11
+ let bundledFontsCache=null;
12
12
 
13
13
  // The faces a raster or PDF conversion draws with (RR-74: one `fonts`). The fonts handle (`loadFonts()` from `/fonts-node`) gives its
14
14
  // `fontFiles`, whether the bundled base faces are added (`useBundledFonts`, default true; a Node handle already holds its own, so it
@@ -82,12 +82,16 @@ async function vectorPdf(inputs, options) {
82
82
  ...(settings.useBundledFonts ? await bundledFontFiles() : []),
83
83
  ...settings.fontFiles
84
84
  ];
85
+ // opf-render#188: the runs are shaped by HarfBuzz, as the browser shapes them, through the fonts handle's shaper (or that of
86
+ // the bundled handle the conversion draws with); fontkit shapes them when neither has one.
87
+ const shape = options.fonts?.outlines?.shape ?? (settings.useBundledFonts ? (await bundledFonts()).outlines?.shape : undefined);
85
88
  const svgs = [];
86
89
  for (const input of inputs) svgs.push(await prepareRasterImages(normalizeSvgInput(input)));
87
90
  const {svgsToVectorPdf} = await import("./pdf-vector.js");
88
91
  return svgsToVectorPdf(svgs, {
89
92
  fontFiles,
90
93
  fontDirs: settings.fontDirs,
94
+ shape,
91
95
  defaultFontFamily: options.defaultFontFamily ?? "Roboto",
92
96
  sansSerifFamily: options.sansSerifFamily ?? options.defaultFontFamily ?? "Roboto",
93
97
  monospaceFamily: options.monospaceFamily ?? "Roboto Mono",
@@ -177,18 +181,18 @@ const loadResvg = () => loadConverter("@resvg/resvg-js");
177
181
  const loadPdfLib = () => loadConverter("pdf-lib");
178
182
 
179
183
  async function bundledFontFiles() {
180
- if (!bundledFontFilesCache) {
181
- bundledFontFilesCache = resolveBundledFontFiles().catch(error => {
182
- bundledFontFilesCache = null;
184
+ return (await bundledFonts()).fontFiles;
185
+ }
186
+
187
+ // The bundled base handle (`loadFonts()`), loaded once: its font files, and its shaper for the vector PDF.
188
+ function bundledFonts() {
189
+ if (!bundledFontsCache) {
190
+ bundledFontsCache = import('./fonts-node.js').then(({loadFonts}) => loadFonts()).catch(error => {
191
+ bundledFontsCache = null;
183
192
  throw error;
184
193
  });
185
194
  }
186
- return bundledFontFilesCache;
187
- }
188
-
189
- async function resolveBundledFontFiles() {
190
- const {loadFonts} = await import('./fonts-node.js');
191
- return (await loadFonts()).fontFiles;
195
+ return bundledFontsCache;
192
196
  }
193
197
 
194
198
  function normalizeSvgInput(value) {
@@ -46,7 +46,9 @@ export const SCRIPT_FONT_FAMILIES = freeze({
46
46
  // text, not scripts a character carries: emoji and math characters are Common, so they stay in their
47
47
  // neighbours' run and reach these faces through the glyph fallback chain (emoji-presentation clusters
48
48
  // take the emoji face first). The keys are last so the chain tries every real script face before them.
49
- Zsye: {sans: "Noto Color Emoji"},
49
+ // `fallback` is a further designated face, tried after the colour face: a host that installed only the monochrome Noto Emoji package
50
+ // still previews and exports emoji. The colour face stays first wherever it is loaded.
51
+ Zsye: {sans: "Noto Color Emoji", fallback: "Noto Emoji"},
50
52
  Zmth: {serif: "STIX Two Math", sans: "Noto Sans Math"},
51
53
  });
52
54
 
@@ -315,7 +317,7 @@ export function detectScripts(value, profile, { includeLanguage = true, ignoreKe
315
317
  export function designatedFamilies(script, serif = false) {
316
318
  const entry = SCRIPT_FONT_FAMILIES[script];
317
319
  if (!entry) return [];
318
- return (serif ? [entry.serif, entry.sans] : [entry.sans, entry.serif]).filter(Boolean);
320
+ return (serif ? [entry.serif, entry.sans, entry.fallback] : [entry.sans, entry.serif, entry.fallback]).filter(Boolean);
319
321
  }
320
322
 
321
323
  const hanKeys = ["Jpan", "Hans", "Hant", "Kore"];
package/dist/svg.js CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  } from "@openpresentation/opf";
20
20
  import { adjustedFontSize, baselineShift, createScriptFonts } from "./script-fonts.js";
21
21
  import { disabledFeaturesStyle } from "./font-compatibility.js";
22
+ import { isSymbolEncodedFamily } from "./symbol-fonts.js";
22
23
  import { fontPolicyFor } from "@openpresentation/opf/font-policy";
23
24
  import { isDatasetChart, isDatasetTable, renderCatalogChart } from "./charts.js";
24
25
  import { renderCaption, renderFootnotes } from "./annotations.js";
@@ -616,9 +617,12 @@ function bindSlide(presentation, index, context) {
616
617
  // RR-17 (FF-41): a family that names its weight (Arial Black, Segoe UI Semibold and Light) keeps its own name through composition, so each run
617
618
  // resolves it again and draws the replacement's encoded weight (Montserrat 900, Red Hat Display 600 and 300); resolving the role to the
618
619
  // replacement family first would draw every run at 400 or 700 and measure it that way. Every other family resolves once, here.
620
+ // A symbol-encoded family (Symbol, Wingdings, Webdings; FF-45) keeps its name too: each run resolves it again and the script-font planner
621
+ // maps the run's private-use codes through the symbol table (symbol-fonts.js), as it does for a symbol run. Resolving the role to the open
622
+ // symbol face here would drop the encoding, and the codes would reach a face with no glyph for them (missing-glyph).
619
623
  for (const role of ["heading","body","code","accent"]) if (design.fonts[role] !== undefined) {
620
624
  const named = design.fonts[role], resolved = resolveTextStyle({fontFamily:named,fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
621
- design.fonts[role] = fontPolicyFor(named)?.replacement?.weight !== undefined && resolved.toLowerCase() !== named.toLowerCase() ? named : resolved;
625
+ design.fonts[role] = isSymbolEncodedFamily(named) || fontPolicyFor(named)?.replacement?.weight !== undefined && resolved.toLowerCase() !== named.toLowerCase() ? named : resolved;
622
626
  }
623
627
  const geometry = composeSlide(slide, { ...slideContext.options, fontFamilies: design.fonts, textRasterPadding:context.options.textRasterPadding, textMeasurement });
624
628
  return {
@@ -96,7 +96,7 @@ export function outlineSlideText(content, { library, shaper, rootStyle = {}, rep
96
96
  defaults: [],
97
97
  splitByOwner: true,
98
98
  // HarfBuzz, the browser's shaper, when the handle carries one (RR-64 phase 2); fontkit otherwise.
99
- ...(shaper ? { shape: (face, text, options) => shaper.shape(face.hash, face.data, text, { ...options, language: options.lang }) } : {}),
99
+ ...(shaper ? { shape: harfBuzzShape(shaper) } : {}),
100
100
  // The renderer reports its own font substitutions and missing glyphs while it lays the slide out.
101
101
  diagnostic: () => {},
102
102
  missing: () => {},
@@ -326,5 +326,17 @@ export function textOutlines(fonts) {
326
326
  if (!library) throw options.fail("text-as-paths-needs-fonts", 'text: "paths" needs font faces to outline: pass the handle loadFonts() returns as `fonts`.');
327
327
  return outlineSlideText(content, { ...options, library, shaper: fonts.shaper });
328
328
  },
329
+ // opf-render#188: the vector PDF shapes its runs with the same HarfBuzz. Each character stays with the glyph it became (cluster
330
+ // level 2: a mark, a reordered vowel sign), so the PDF's glyph-to-Unicode map and /ActualText spans give the text as before;
331
+ // the glyphs and positions are the outlines'.
332
+ ...(fonts.shaper ? { shape: harfBuzzShape(fonts.shaper, { clusterLevel: 2 }) } : {}),
329
333
  });
330
334
  }
335
+
336
+ /**
337
+ * A layoutText `env.shape` over a HarfBuzz shaper (hb-shape.js), for faces of a FontLibrary (keyed by their content hash).
338
+ * HarfBuzz takes the BCP 47 tag (`lang`); fontkit the OpenType language system (`language`).
339
+ */
340
+ function harfBuzzShape(shaper, { clusterLevel } = {}) {
341
+ return (face, text, options) => shaper.shape(face.hash, face.data, text, { ...options, language: options.lang, clusterLevel });
342
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-render",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
4
  "description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -100,9 +100,11 @@
100
100
  "check:font-hotlinks": "node --test scripts/check-font-hotlinks.test.mjs && node scripts/check-font-hotlinks.mjs",
101
101
  "test:golden": "npm run build && node test/golden.mjs",
102
102
  "test:packed": "node test/packed-install.mjs",
103
+ "test:next-bundle": "node scripts/check-next-bundle.mjs",
103
104
  "test:browser": "npm run build:browser-check && node test/browser-check.mjs && node test/rich-tab-browser.mjs && node test/rich-tab-estimated-browser.mjs",
104
105
  "golden:update": "npm run build && node test/golden.mjs --update",
105
106
  "golden:promote": "node scripts/golden-promote.mjs",
107
+ "bench:soak": "node scripts/soak.mjs",
106
108
  "prepack": "npm run build",
107
109
  "build:browser-check": "node scripts/build-browser-check.mjs",
108
110
  "test:styled-table": "node test/styled-table.mjs",
@@ -136,22 +138,23 @@
136
138
  "test:emoji-math-browser": "node test/emoji-math-browser.mjs artifacts/emoji-math-browser.json",
137
139
  "test:player": "node test/player-unit.mjs",
138
140
  "test:player-browser": "node test/player-browser.mjs artifacts/player",
139
- "test:preview-a11y-browser": "node test/preview-a11y-browser.mjs"
141
+ "test:preview-a11y-browser": "node test/preview-a11y-browser.mjs",
142
+ "test:forced-colors-browser": "node test/forced-colors-browser.mjs artifacts/forced-colors"
140
143
  },
141
144
  "dependencies": {
142
- "@openpresentation/opf": "^0.18.1",
145
+ "@openpresentation/opf": "^0.18.3",
143
146
  "bidi-js": "1.1.0",
144
147
  "fontkit": "^2.0.4",
145
- "harfbuzzjs": "1.6.2",
146
- "pako": "1.0.11"
148
+ "harfbuzzjs": "1.6.3",
149
+ "pako": "3.0.2"
147
150
  },
148
151
  "peerDependencies": {
149
152
  "@expo-google-fonts/arimo": "0.4.3",
150
153
  "@expo-google-fonts/caladea": "0.4.2",
151
154
  "@expo-google-fonts/cousine": "0.4.3",
152
155
  "@expo-google-fonts/gelasio": "0.4.1",
153
- "@expo-google-fonts/noto-color-emoji": "0.4.6",
154
- "@expo-google-fonts/noto-emoji": "0.4.7",
156
+ "@expo-google-fonts/noto-color-emoji": "0.4.7",
157
+ "@expo-google-fonts/noto-emoji": "0.4.8",
155
158
  "@expo-google-fonts/noto-naskh-arabic": "0.4.5",
156
159
  "@expo-google-fonts/noto-nastaliq-urdu": "0.4.2",
157
160
  "@expo-google-fonts/noto-sans": "0.4.2",
@@ -164,23 +167,23 @@
164
167
  "@expo-google-fonts/noto-sans-gujarati": "0.4.2",
165
168
  "@expo-google-fonts/noto-sans-gurmukhi": "0.4.1",
166
169
  "@expo-google-fonts/noto-sans-hebrew": "0.4.1",
167
- "@expo-google-fonts/noto-sans-jp": "0.4.3",
170
+ "@expo-google-fonts/noto-sans-jp": "0.4.4",
168
171
  "@expo-google-fonts/noto-sans-kannada": "0.4.3",
169
172
  "@expo-google-fonts/noto-sans-khmer": "0.4.3",
170
- "@expo-google-fonts/noto-sans-kr": "0.4.3",
173
+ "@expo-google-fonts/noto-sans-kr": "0.4.4",
171
174
  "@expo-google-fonts/noto-sans-lao": "0.4.2",
172
175
  "@expo-google-fonts/noto-sans-malayalam": "0.4.2",
173
176
  "@expo-google-fonts/noto-sans-math": "0.4.2",
174
177
  "@expo-google-fonts/noto-sans-mongolian": "0.4.1",
175
178
  "@expo-google-fonts/noto-sans-myanmar": "0.4.2",
176
179
  "@expo-google-fonts/noto-sans-oriya": "0.4.2",
177
- "@expo-google-fonts/noto-sans-sc": "0.4.3",
180
+ "@expo-google-fonts/noto-sans-sc": "0.4.4",
178
181
  "@expo-google-fonts/noto-sans-sinhala": "0.4.2",
179
182
  "@expo-google-fonts/noto-sans-symbols": "0.4.1",
180
183
  "@expo-google-fonts/noto-sans-symbols-2": "0.4.1",
181
184
  "@expo-google-fonts/noto-sans-syriac": "0.4.2",
182
185
  "@expo-google-fonts/noto-sans-tamil": "0.4.3",
183
- "@expo-google-fonts/noto-sans-tc": "0.4.3",
186
+ "@expo-google-fonts/noto-sans-tc": "0.4.4",
184
187
  "@expo-google-fonts/noto-sans-telugu": "0.4.2",
185
188
  "@expo-google-fonts/noto-sans-thaana": "0.4.2",
186
189
  "@expo-google-fonts/noto-sans-thai": "0.4.2",
@@ -342,8 +345,8 @@
342
345
  "@expo-google-fonts/caladea": "0.4.2",
343
346
  "@expo-google-fonts/cousine": "0.4.3",
344
347
  "@expo-google-fonts/gelasio": "0.4.1",
345
- "@expo-google-fonts/noto-color-emoji": "0.4.6",
346
- "@expo-google-fonts/noto-emoji": "0.4.7",
348
+ "@expo-google-fonts/noto-color-emoji": "0.4.7",
349
+ "@expo-google-fonts/noto-emoji": "0.4.8",
347
350
  "@expo-google-fonts/noto-naskh-arabic": "0.4.5",
348
351
  "@expo-google-fonts/noto-nastaliq-urdu": "0.4.2",
349
352
  "@expo-google-fonts/noto-sans": "0.4.2",
@@ -356,23 +359,23 @@
356
359
  "@expo-google-fonts/noto-sans-gujarati": "0.4.2",
357
360
  "@expo-google-fonts/noto-sans-gurmukhi": "0.4.1",
358
361
  "@expo-google-fonts/noto-sans-hebrew": "0.4.1",
359
- "@expo-google-fonts/noto-sans-jp": "0.4.3",
362
+ "@expo-google-fonts/noto-sans-jp": "0.4.4",
360
363
  "@expo-google-fonts/noto-sans-kannada": "0.4.3",
361
364
  "@expo-google-fonts/noto-sans-khmer": "0.4.3",
362
- "@expo-google-fonts/noto-sans-kr": "0.4.3",
365
+ "@expo-google-fonts/noto-sans-kr": "0.4.4",
363
366
  "@expo-google-fonts/noto-sans-lao": "0.4.2",
364
367
  "@expo-google-fonts/noto-sans-malayalam": "0.4.2",
365
368
  "@expo-google-fonts/noto-sans-math": "0.4.2",
366
369
  "@expo-google-fonts/noto-sans-mongolian": "0.4.1",
367
370
  "@expo-google-fonts/noto-sans-myanmar": "0.4.2",
368
371
  "@expo-google-fonts/noto-sans-oriya": "0.4.2",
369
- "@expo-google-fonts/noto-sans-sc": "0.4.3",
372
+ "@expo-google-fonts/noto-sans-sc": "0.4.4",
370
373
  "@expo-google-fonts/noto-sans-sinhala": "0.4.2",
371
374
  "@expo-google-fonts/noto-sans-symbols": "0.4.1",
372
375
  "@expo-google-fonts/noto-sans-symbols-2": "0.4.1",
373
376
  "@expo-google-fonts/noto-sans-syriac": "0.4.2",
374
377
  "@expo-google-fonts/noto-sans-tamil": "0.4.3",
375
- "@expo-google-fonts/noto-sans-tc": "0.4.3",
378
+ "@expo-google-fonts/noto-sans-tc": "0.4.4",
376
379
  "@expo-google-fonts/noto-sans-telugu": "0.4.2",
377
380
  "@expo-google-fonts/noto-sans-thaana": "0.4.2",
378
381
  "@expo-google-fonts/noto-sans-thai": "0.4.2",
@@ -384,12 +387,12 @@
384
387
  "@expo-google-fonts/tinos": "0.4.2",
385
388
  "@hyzyla/pdfium": "2.1.13",
386
389
  "@jspawn/qpdf-wasm": "0.0.2",
387
- "@napi-rs/canvas": "1.0.9",
390
+ "@napi-rs/canvas": "1.0.10",
388
391
  "@resvg/resvg-js": "2.6.2",
389
- "axe-core": "4.13.0",
392
+ "axe-core": "4.14.0",
390
393
  "esbuild": "0.28.2",
391
394
  "pdf-lib": "1.17.1",
392
- "pdfjs-dist": "6.3.289",
395
+ "pdfjs-dist": "6.4.299",
393
396
  "playwright": "1.63.0",
394
397
  "sharp": "0.35.5"
395
398
  },