@openpresentation/opf-render 0.18.0 → 0.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -7
- package/dist/element.js +14 -7
- package/dist/export-browser.js +2 -0
- package/dist/font-manifest.js +17 -17
- package/dist/font-registry.js +31 -0
- package/dist/font-subset.js +7 -1
- package/dist/fonts-browser.d.ts +3 -2
- package/dist/fonts-node.d.ts +6 -2
- package/dist/fonts-node.js +20 -17
- package/dist/fonts.d.ts +9 -1
- package/dist/hb-shape.js +16 -6
- package/dist/node-resolve.js +24 -0
- package/dist/pdf-text.js +4 -4
- package/dist/pdf-vector.js +13 -3
- package/dist/pdf-writer.js +5 -2
- package/dist/player.js +10 -3
- package/dist/preview-fonts-node.js +3 -6
- package/dist/raster-text.js +102 -12
- package/dist/raster.js +20 -12
- package/dist/script-fonts.js +11 -2
- package/dist/svg.js +5 -1
- package/dist/text-paths.js +13 -1
- package/package.json +23 -20
package/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
|
|
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.
|
|
@@ -251,7 +278,7 @@ The default changed from raster to vector in the release that added this section
|
|
|
251
278
|
- `signal` (an `AbortSignal`, checked between pages) and `onProgress({ page, pages })` serve large decks; the same two options also work in the Node `toPdf`. The browser export yields to the page between slides.
|
|
252
279
|
|
|
253
280
|
Verified in Chromium by `test/export-browser.mjs` (`npm run test:export-browser`), which also checks the text pdf.js extracts, the eight JPEG orientations, offline operation and determinism. Safari and Firefox are not exercised by this repository's CI.
|
|
254
|
-
Complex scripts in raster output: resvg draws a HarfBuzz cluster with the advance of its widest glyph, so Indic, Thai, Lao, Khmer and Myanmar text lost the advance of vowel signs and ran together, and it ignores the SVG `lang` (Korean spacing). The raster path therefore rewrites its private copy of such text cluster by cluster at
|
|
281
|
+
Complex scripts in raster output: resvg draws a HarfBuzz cluster with the advance of its widest glyph, so Indic, Thai, Lao, Khmer and Myanmar text lost the advance of vowel signs and ran together, and it ignores the SVG `lang` (Korean spacing). The raster path therefore rewrites its private copy of such text cluster by cluster at HarfBuzz's positions when the fonts handle has its shaper (`fonts.outlines.shape`; the Node handle and the bundled faces always do), fontkit's otherwise (`src/raster-text.js`): Devanagari, Gujarati, Oriya, Tamil, Kannada and Sinhala as fontkit glyph outlines, the other scripts and Korean as single clusters resvg shapes in isolation. The emitted SVG is unchanged, text in other scripts is rasterized exactly as before, and `test/script-corpora-raster.mjs` holds every script to a HarfBuzz outline reference (the fixture's `rasterLimits` records what still differs: the KOR punctuation forms).
|
|
255
282
|
|
|
256
283
|
## Browser preview and fonts
|
|
257
284
|
|
|
@@ -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
|
|
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.
|
|
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), `
|
|
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
|
|
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
|
-
.
|
|
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) {
|
|
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
|
});
|
package/dist/export-browser.js
CHANGED
|
@@ -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",
|
package/dist/font-manifest.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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": "
|
|
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": "
|
|
1650
|
+
"sha256": "7c03e14d8a03e8f5181dbd130712d51e4ae01ac648cbd59442ae8b16c0951835"
|
|
1651
1651
|
}
|
|
1652
1652
|
]
|
|
1653
1653
|
},
|
|
1654
1654
|
{
|
|
1655
1655
|
"name": "@expo-google-fonts/noto-sans-sc",
|
|
1656
|
-
"version": "0.4.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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": "
|
|
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": "
|
|
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.
|
|
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.
|
|
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": "
|
|
2576
|
+
"sha256": "ad4a3478943b88660a194d1434793bc02c8007819f2d1e7da9ede289e78f9ca7"
|
|
2577
2577
|
}
|
|
2578
2578
|
]
|
|
2579
2579
|
},
|
|
2580
2580
|
{
|
|
2581
2581
|
"name": "@expo-google-fonts/noto-emoji",
|
|
2582
|
-
"version": "0.4.
|
|
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.
|
|
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",
|
package/dist/font-registry.js
CHANGED
|
@@ -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) => {
|
package/dist/font-subset.js
CHANGED
|
@@ -16,6 +16,7 @@ import { fsTypeOf, subsettingAllowed } from "./font-fstype.js";
|
|
|
16
16
|
const SUBSET_LICENSES = new Set(["OFL-1.1", "Apache-2.0", "MIT", "UFL-1.0"]);
|
|
17
17
|
const HB_MEMORY_MODE_READONLY = 1;
|
|
18
18
|
const HB_SUBSET_SETS_LAYOUT_FEATURE_TAG = 6;
|
|
19
|
+
const HB_SUBSET_FLAGS_GLYPH_NAMES = 0x80;
|
|
19
20
|
const CACHE_LIMIT = 256;
|
|
20
21
|
|
|
21
22
|
/** The manifest faces that may be subset, by sha256: permissive license, no Reserved Font Name in the family or file name. */
|
|
@@ -56,7 +57,7 @@ export function createSubsetter(wasm) {
|
|
|
56
57
|
const exports = instance.exports;
|
|
57
58
|
const required = ["memory", "malloc", "free", "hb_blob_create", "hb_blob_destroy", "hb_blob_get_length", "hb_blob_get_data", "hb_face_create", "hb_face_destroy",
|
|
58
59
|
"hb_face_reference_blob", "hb_set_add", "hb_set_clear", "hb_set_invert", "hb_subset_input_create_or_fail", "hb_subset_input_destroy", "hb_subset_input_unicode_set",
|
|
59
|
-
"hb_subset_input_set", "hb_subset_or_fail"];
|
|
60
|
+
"hb_subset_input_set", "hb_subset_input_set_flags", "hb_subset_or_fail"];
|
|
60
61
|
for (const name of required) if (!(name in exports)) throw new Error(`harfbuzz-subset.wasm has no export ${name}.`);
|
|
61
62
|
return {
|
|
62
63
|
subset(data, codePoints) {
|
|
@@ -69,7 +70,12 @@ export function createSubsetter(wasm) {
|
|
|
69
70
|
let result = 0, output = null;
|
|
70
71
|
try {
|
|
71
72
|
if (!input) return null;
|
|
73
|
+
// macOS Chromium small-size rasterization changes when named X/o are absent, even when the text does not draw them.
|
|
74
|
+
// Keeping names alone or the outlines alone is insufficient: see the exact-pixel subset browser regression.
|
|
75
|
+
exports.hb_subset_input_set_flags(input, HB_SUBSET_FLAGS_GLYPH_NAMES);
|
|
72
76
|
const unicodes = exports.hb_subset_input_unicode_set(input);
|
|
77
|
+
exports.hb_set_add(unicodes, 0x58); // X
|
|
78
|
+
exports.hb_set_add(unicodes, 0x6f); // o
|
|
73
79
|
for (const codePoint of codePoints) exports.hb_set_add(unicodes, codePoint);
|
|
74
80
|
// Every layout feature, not only HarfBuzz's default list: the browser shapes the subset like the whole face whatever
|
|
75
81
|
// font-feature-settings the SVG asks for.
|
package/dist/fonts-browser.d.ts
CHANGED
|
@@ -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
|
|
92
|
-
* HarfBuzz, as the browser shapes text; without it a browser handle shapes them with fontkit (the
|
|
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 };
|
package/dist/fonts-node.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
36
|
-
|
|
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 }
|
package/dist/fonts-node.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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([
|
|
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()); },
|