@openpresentation/opf-render 0.10.0 → 0.11.0

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.
Files changed (98) hide show
  1. package/README.md +59 -7
  2. package/dist/font-compatibility.js +40 -1
  3. package/dist/font-manifest.js +869 -12
  4. package/dist/font-policy.js +86 -31
  5. package/dist/fonts-browser.d.ts +56 -2
  6. package/dist/fonts-browser.js +194 -22
  7. package/dist/fonts-node.d.ts +53 -9
  8. package/dist/fonts-node.js +121 -23
  9. package/dist/fonts.d.ts +20 -5
  10. package/dist/fonts.js +63 -27
  11. package/dist/index.d.ts +11 -0
  12. package/dist/lazy-font-list.js +23 -0
  13. package/dist/lazy-fonts.js +52 -0
  14. package/dist/raster.js +3 -1
  15. package/dist/script-font-pack.js +140 -1
  16. package/dist/script-fonts.js +161 -24
  17. package/dist/svg.d.ts +11 -0
  18. package/dist/svg.js +97 -5
  19. package/fonts/bebas-neue/BebasNeue_400Regular.ttf +0 -0
  20. package/fonts/bebas-neue/OFL.txt +93 -0
  21. package/fonts/bebas-neue/PROVENANCE.json +23 -0
  22. package/fonts/carlito/Carlito-Bold.ttf +0 -0
  23. package/fonts/carlito/Carlito-BoldItalic.ttf +0 -0
  24. package/fonts/carlito/Carlito-Italic.ttf +0 -0
  25. package/fonts/carlito/Carlito-Regular.ttf +0 -0
  26. package/fonts/carlito/OFL.txt +94 -0
  27. package/fonts/intos/Intos-Bold.ttf +0 -0
  28. package/fonts/intos/Intos-BoldItalic.ttf +0 -0
  29. package/fonts/intos/Intos-Italic.ttf +0 -0
  30. package/fonts/intos/Intos-Regular.ttf +0 -0
  31. package/fonts/intos/IntosDisplay-Bold.ttf +0 -0
  32. package/fonts/intos/IntosDisplay-BoldItalic.ttf +0 -0
  33. package/fonts/intos/IntosDisplay-Italic.ttf +0 -0
  34. package/fonts/intos/IntosDisplay-Regular.ttf +0 -0
  35. package/fonts/intos/IntosNarrow-Bold.ttf +0 -0
  36. package/fonts/intos/IntosNarrow-BoldItalic.ttf +0 -0
  37. package/fonts/intos/IntosNarrow-Italic.ttf +0 -0
  38. package/fonts/intos/IntosNarrow-Regular.ttf +0 -0
  39. package/fonts/intos/IntosSerif-Bold.ttf +0 -0
  40. package/fonts/intos/IntosSerif-BoldItalic.ttf +0 -0
  41. package/fonts/intos/IntosSerif-Italic.ttf +0 -0
  42. package/fonts/intos/IntosSerif-Regular.ttf +0 -0
  43. package/fonts/intos/NOTICE.md +18 -0
  44. package/fonts/intos/OFL.txt +94 -0
  45. package/fonts/intos/PROVENANCE.json +144 -0
  46. package/fonts/lora/Lora-Bold.ttf +0 -0
  47. package/fonts/lora/Lora-BoldItalic.ttf +0 -0
  48. package/fonts/lora/Lora-Italic.ttf +0 -0
  49. package/fonts/lora/Lora-Regular.ttf +0 -0
  50. package/fonts/lora/OFL.txt +93 -0
  51. package/fonts/lora/PROVENANCE.json +48 -0
  52. package/fonts/merriweather-sans/MerriweatherSans-Bold.ttf +0 -0
  53. package/fonts/merriweather-sans/MerriweatherSans-BoldItalic.ttf +0 -0
  54. package/fonts/merriweather-sans/MerriweatherSans-Italic.ttf +0 -0
  55. package/fonts/merriweather-sans/MerriweatherSans-Regular.ttf +0 -0
  56. package/fonts/merriweather-sans/OFL.txt +96 -0
  57. package/fonts/merriweather-sans/PROVENANCE.json +48 -0
  58. package/fonts/montserrat/Montserrat_400Regular.ttf +0 -0
  59. package/fonts/montserrat/Montserrat_400Regular_Italic.ttf +0 -0
  60. package/fonts/montserrat/Montserrat_700Bold.ttf +0 -0
  61. package/fonts/montserrat/Montserrat_700Bold_Italic.ttf +0 -0
  62. package/fonts/montserrat/Montserrat_900Black.ttf +0 -0
  63. package/fonts/montserrat/OFL.txt +93 -0
  64. package/fonts/montserrat/PROVENANCE.json +55 -0
  65. package/fonts/open-sans/OFL.txt +92 -0
  66. package/fonts/open-sans/OpenSans_400Regular.ttf +0 -0
  67. package/fonts/open-sans/OpenSans_400Regular_Italic.ttf +0 -0
  68. package/fonts/open-sans/OpenSans_700Bold.ttf +0 -0
  69. package/fonts/open-sans/OpenSans_700Bold_Italic.ttf +0 -0
  70. package/fonts/open-sans/PROVENANCE.json +47 -0
  71. package/fonts/poppins/OFL.txt +93 -0
  72. package/fonts/poppins/PROVENANCE.json +47 -0
  73. package/fonts/poppins/Poppins_400Regular.ttf +0 -0
  74. package/fonts/poppins/Poppins_400Regular_Italic.ttf +0 -0
  75. package/fonts/poppins/Poppins_700Bold.ttf +0 -0
  76. package/fonts/poppins/Poppins_700Bold_Italic.ttf +0 -0
  77. package/fonts/pt-serif/OFL.txt +93 -0
  78. package/fonts/pt-serif/PROVENANCE.json +50 -0
  79. package/fonts/pt-serif/PT_Serif-Web-Bold.ttf +0 -0
  80. package/fonts/pt-serif/PT_Serif-Web-BoldItalic.ttf +0 -0
  81. package/fonts/pt-serif/PT_Serif-Web-Italic.ttf +0 -0
  82. package/fonts/pt-serif/PT_Serif-Web-Regular.ttf +0 -0
  83. package/fonts/red-hat-display/OFL.txt +93 -0
  84. package/fonts/red-hat-display/PROVENANCE.json +38 -0
  85. package/fonts/red-hat-display/RedHatDisplay-Bold.ttf +0 -0
  86. package/fonts/red-hat-display/RedHatDisplay-Light.ttf +0 -0
  87. package/fonts/red-hat-display/RedHatDisplay-Regular.ttf +0 -0
  88. package/fonts/red-hat-text/OFL.txt +93 -0
  89. package/fonts/red-hat-text/PROVENANCE.json +30 -0
  90. package/fonts/red-hat-text/RedHatText-Bold.ttf +0 -0
  91. package/fonts/red-hat-text/RedHatText-Regular.ttf +0 -0
  92. package/fonts/source-sans-3/OFL.txt +93 -0
  93. package/fonts/source-sans-3/PROVENANCE.json +49 -0
  94. package/fonts/source-sans-3/SourceSans3-Bold.ttf +0 -0
  95. package/fonts/source-sans-3/SourceSans3-BoldIt.ttf +0 -0
  96. package/fonts/source-sans-3/SourceSans3-It.ttf +0 -0
  97. package/fonts/source-sans-3/SourceSans3-Regular.ttf +0 -0
  98. package/package.json +11 -10
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # OPF Render
2
2
 
3
- Version 0.10.0 requires `@openpresentation/opf` ^0.11.1 (font policy table, script fonts and right-to-left paragraphs, socials, slide-image treatments, formatted slide-number and date furniture with the host `date` option, per-item alignment). See the changelog for the intentional preview-output changes.
3
+ **Aptos previews with Intos.** The Office pack includes Intos, Intos Display, Intos Narrow and Intos Serif (16 faces, about 12 MB, vendored under `fonts/intos/`, SIL OFL 1.1, pinned to upstream commit fef9315c14da9e4b23b4c3cac8e718998d4e4736 and per-file SHA-256, unmodified from the upstream files). They are the metric-compatible previews for Aptos, Aptos Display, Aptos Narrow and Aptos Serif, the default `aptos` font scheme: 0.000% mean and maximum width difference against Aptos 2.01 in all four styles, with equal vertical metrics. The letter shapes are Intos's own, not Aptos's. The exported PPTX still names Aptos; no Aptos file is bundled or embedded. A registry built without these faces (the base pack) has no Aptos substitute: visual policy falls back to Roboto or Carlito. Like the open families, Intos is `embed: "used"`: it is not in `registry.embeddedFonts` (the 33 eager npm faces), `prepareNodeFonts().options.embeddedFonts` supplies it, and an SVG embeds only the Intos faces its text draws. `registry.lazyFonts` lists the 51 vendored faces (35 open, 16 Intos) with package-relative file and sha256, and `loadBrowserFontRegistry(faces, {lazyFontsBaseUrl})` loads them on demand: `await registry.ensureLazyFonts(presentation)` fetches and verifies only the files the document's font families resolve to (the default Aptos scheme: Intos and Intos Display, 8 files, 5.9 MB), then adds them to the document and the registry together so measurement and painting never disagree. Hosts copy the vendored directories next to the page.
4
+
5
+ Version 0.11.0 requires `@openpresentation/opf` ^0.11.2 (cover centering, Intos Aptos previews, lazy font loading, automatic script fonts and glyph fallback); use it with PPTX 0.11.0 and editor 0.10.0 so preview and export resolve the same core. Version 0.10.0 required `@openpresentation/opf` ^0.11.1 (font policy table, script fonts and right-to-left paragraphs, socials, slide-image treatments, formatted slide-number and date furniture with the host `date` option, per-item alignment). See the changelog for the intentional preview-output changes.
4
6
 
5
7
  Version 0.9.0 required `@openpresentation/opf` ^0.11.0 and resolved content ColorRef / `variables` through core `resolveColorRef()`. Authored `#RRGGBB` paint stays authored. It retains the existing rendering APIs.
6
8
 
@@ -20,7 +22,7 @@ In version 0.8.0, `design.contentBox` uses core's shared padded geometry. The ca
20
22
 
21
23
  Version 0.8.0 paints plain and rich text, titles, subtitles and tags using composition's accepted fits and resolved styles. Painting does not measure those payloads again. Supply a font registry during composition: reusing an estimated fit cannot correct spacing when the actual drawing font has different advances.
22
24
 
23
- Version 0.8.0 includes `prepareNodeFonts` in `/fonts-node`. It verifies all selected font files and license notices against the versioned `BUNDLED_FONT_MANIFEST`, including exact npm versions and SHA-256 hashes. The returned options configure layout, SVG, editing, PPTX, and Node raster output with the same font inputs:
25
+ Version 0.8.0 includes `prepareNodeFonts` in `/fonts-node`. It verifies all selected font files and license notices against the versioned `BUNDLED_FONT_MANIFEST`, including exact npm versions and SHA-256 hashes. Carlito is not an npm dependency: the package ships the unmodified google/fonts Carlito files and their OFL notice in `fonts/carlito`, pinned to a google/fonts commit and verified by the same hashes. The returned options configure layout, SVG, editing, PPTX, and Node raster output with the same font inputs:
24
26
 
25
27
  ```js
26
28
  import { prepareNodeFonts } from '@openpresentation/opf-render/fonts-node';
@@ -38,7 +40,7 @@ const pptx = await toPptx(presentation, options);
38
40
  console.log(registry.substitutions);
39
41
  ```
40
42
 
41
- The default `pack: 'base'` contains nine Roboto/Roboto Mono faces and suits a document using `design.fontScheme: 'roboto'`. The Office pack includes 24 additional faces and defaults to metric substitution policy; visual substitution remains explicit. Neither helper changes the document's authored font scheme or installs system fonts. The authored (selected) font is the source of truth. Licensed fonts are never bundled, so these packs supply open look-alikes for previews and SVG: metric-compatible where one exists (Carlito for Calibri, the goal), visual-only where none does yet (Aptos today; a documented fallback and known layout-fidelity gap). The PPTX exporter still writes the selected name; see the [OPF font policy](https://github.com/OpenPresentation/opf/blob/main/docs/font-fidelity.md#font-policy-ff-31). Missing resources, changed bytes/notices, and unexpected package versions fail with actionable font errors. Requested/resolved substitution records remain on `registry.substitutions`; source paths appear where the caller supplies them. Font coverage, variant naming, shaping, and native fidelity retain their documented limits.
43
+ The default `pack: 'base'` contains nine Roboto/Roboto Mono faces and suits a document using `design.fontScheme: 'roboto'`. The Office pack includes 24 Office-substitute faces plus 35 vendored faces of the open families that font schemes select (below) and the 16 vendored Intos faces that preview the Aptos family (below), and defaults to metric substitution policy. Its four Carlito faces are the byte-identical upstream release (OFL reserves the name "Carlito", so a subset or conversion could not keep it); visual substitution remains explicit. Neither helper changes the document's authored font scheme or installs system fonts. The authored (selected) font is the source of truth. Licensed fonts are never bundled, so these packs supply open look-alikes for previews and SVG: metric-compatible where one exists (Carlito for Calibri, Intos for Aptos), visual-only where none does yet (a documented fallback and known layout-fidelity gap). The PPTX exporter still writes the selected name; see the [OPF font policy](https://github.com/OpenPresentation/opf/blob/main/docs/font-fidelity.md#font-policy-ff-31). Missing resources, changed bytes/notices, and unexpected package versions fail with actionable font errors. Requested/resolved substitution records remain on `registry.substitutions`; source paths appear where the caller supplies them. Font coverage, variant naming, shaping, and native fidelity retain their documented limits.
42
44
 
43
45
  Default PNG/PDF raster loading now uses the same complete nine-face base pack, fixing omitted semibold and italic faces. Custom raster callers may still set `useBundledFonts: false`. Font files must stay available and unchanged for subsequent raster calls. `node scripts/update-font-manifest.mjs` is an explicit maintenance operation requiring review of font bytes, style metadata, licenses and raster changes; builds and installs never regenerate the expected hashes.
44
46
 
@@ -71,7 +73,7 @@ console.log(fonts.substitutions);
71
73
 
72
74
  Set `trace: true` to stamp rendered SVG elements with `data-opf-path` values such as `slides.0.title`; omit it for smaller production SVG.
73
75
 
74
- The installed open font pack works offline. This example explicitly permits visual-only look-alikes such as Roboto or Carlito for Aptos, which is a documented fallback until a metric-compatible replacement exists; the substitution report identifies requested and resolved faces. A visual substitute is not a claim of metric or pixel equivalence, and it is a known layout-fidelity gap rather than the end state. Replacements apply to rendering only; the document keeps the selected font name. Omit `substitutionPolicy` to restrict the Office loader to its metric mappings, or supply your own licensed faces. Missing fonts/glyphs fail explicitly. Without `textMeasurement`, synchronous SVG APIs use estimated widths; named fonts alone do not make rich-run spacing reliable. Browser hosts should load the same bytes using the loader below.
76
+ The installed open font pack works offline. The Aptos family previews with the metric-compatible Intos even under the default metric policy; this example additionally permits visual-only look-alikes for families with no metric replacement yet (a documented fallback), and the substitution report identifies requested and resolved faces. A visual substitute is not a claim of metric or pixel equivalence, and it is a known layout-fidelity gap rather than the end state. Replacements apply to rendering only; the document keeps the selected font name. Omit `substitutionPolicy` to restrict the Office loader to its metric mappings, or supply your own licensed faces. Missing fonts/glyphs fail explicitly. Without `textMeasurement`, synchronous SVG APIs use estimated widths; named fonts alone do not make rich-run spacing reliable. Browser hosts should load the same bytes using the loader below.
75
77
 
76
78
  Unresolved images produce an `unresolved-asset` diagnostic through `onDiagnostic`, including the OPF path, reason and complete description. The fallback shows a bounded status label when it fits above the selected readability floor, otherwise an icon with the full accessible description. The label is `Image unavailable` over the description (alt, else title, else `Image`) in semibold at 20 px on a 1280 x 720 canvas, and opf-pptx exports the same placeholder as native shapes. It preserves authored opacity, including faint decorative watermarks; a missing image is still missing even when its fallback fits. Use `strictAssets: true` to reject unresolved images. Supply embedded raster data URIs or a synchronous host `imageResolver` to resolve them; the renderer does not fetch URLs or read local paths. Caller descriptions and other metadata override referenced asset metadata through alias chains. These diagnostics identify unresolved sources, not malformed image bytes or native PowerPoint compatibility.
77
79
 
@@ -119,11 +121,38 @@ container.innerHTML = renderSvg(presentation, {
119
121
 
120
122
  Each entry contains `url` or `data: Uint8Array`, with optional `family`, `weight`, `italic` and `license`. The loader registers browser FontFaces using the same bytes used for measurement. It fetches only URLs supplied by the host, supports an AbortSignal and custom fetch, and awaits font loading. Use pinned static faces and retain their licenses. For standalone SVG export also pass `embeddedFonts: fonts.embeddedFonts`; embedding is unnecessary for each live draft after browser fonts are loaded.
121
123
 
124
+ ## Open font-scheme families (FF-31)
125
+
126
+ Font schemes select openly licensed Latin families that no Office substitute covers, and the font policy names two Red Hat families as replacements for Segoe UI and Tahoma. The office pack vendors them as static faces (`fonts/<family>/`: only the used faces, the upstream OFL notice as `OFL.txt` and a generated `PROVENANCE.json`), so `loadOfficeFontRegistry()` and `prepareNodeFonts({ pack: 'office' })` resolve each to its own face, and a strict registry no longer throws `font-unavailable` for them:
127
+
128
+ | Family | Vendored from | Faces |
129
+ |---|---|---|
130
+ | Open Sans | `@expo-google-fonts/open-sans@0.4.2` (instanced statics; no Reserved Font Name) | 400, 700, italics |
131
+ | Montserrat | `@expo-google-fonts/montserrat@0.4.2` (same) | 400, 700, italics, 900 (named by the Arial Black policy row) |
132
+ | Poppins | `@expo-google-fonts/poppins@0.4.1` (same) | 400, 700, italics |
133
+ | Bebas Neue | `@expo-google-fonts/bebas-neue@0.4.1` (same) | 400 (the family has no other style) |
134
+ | PT Serif | google/fonts `ofl/ptserif` at a pinned commit (ParaType's release, byte-identical) | 400, 700, italics |
135
+ | Lora | cyrealtype/Lora-Cyrillic v3.021, pinned commit, `fonts/ttf` | 400, 700, italics |
136
+ | Merriweather Sans | SorkinType/Merriweather-Sans, pinned commit, `fonts/ttf` | 400, 700, italics |
137
+ | Source Sans 3 (also answers to Source Sans Pro) | adobe-fonts/source-sans tag 3.052R, pinned commit, `TTF` | 400, 700, italics |
138
+ | Red Hat Display | RedHatOfficial/RedHatFont 5.0.0, pinned commit | 300, 400, 700 |
139
+ | Red Hat Text | RedHatOfficial/RedHatFont 5.0.0, pinned commit | 400, 700 |
140
+
141
+ All are SIL OFL 1.1, and each manifest entry records the SPDX id read from the shipped notice, its Reserved Font Names, the upstream project and copyright line. An OFL Reserved Font Name stops a modified version from using the reserved name, so the families that have one (PT Serif, Lora, Merriweather Sans, Source Sans 3) are the copyright holder's byte-identical files: every face records `upstreamFile { url, sha256 }` with a commit-pinned URL and a sha256 equal to the face's own, and `test/font-licenses.mjs` enforces it. The other four families have none; their files are the instanced statics of the `@expo-google-fonts` packages, whose notice and version `PROVENANCE.json` records.
142
+
143
+ Not bundled: **Raleway** and **Playfair Display**. Their upstream releases ship only variable fonts, and the Node raster engine (resvg 2.6.2) ignores the weight axis: weights 400 and 700 rendered byte-identically, and Raleway's default instance is Thin. Bundling the instanced statics would modify them under their Reserved Font Names. They keep failing with "no face" until the raster engine supports variable weights or upstream publishes statics.
144
+
145
+ Source Sans Pro was renamed Source Sans 3. A request for the old name draws the Source Sans 3 faces through a built-in alias (`renamedFrom` in the manifest) and is reported `visual`, in any substitution policy, because the releases differ (bold advances by about 0.7%). Source Sans 3 itself resolves exact and is also the declared replacement for Candara, Corbel, Gill Sans MT and Seaford. Red Hat Display and Text are the declared replacements for Segoe UI and Tahoma. Their italic files are left out because resvg ranks by the OS/2 italic bit, which upstream's regular italic does not set (it would paint bold italic), and no 600 face is vendored because upstream's SemiBold declares OS/2 weight 707, which resvg would rank above Bold. Italic falls back to upright (`styleFallback`) and Segoe UI Semibold to Bold, both reported visual. A style a family lacks is not synthesized: Bebas Neue bold draws the regular face and reports `visual`, and an italic request throws `font-style-unavailable`, as for Roboto Mono. A test renders every vendored face through resvg and checks that exactly that face paints.
146
+
147
+ The 35 vendored faces are 7.4 MiB and are the only bytes added; the published tarball grows from 79 kB to 3.9 MB, and there are no new dependencies. Every face and notice is checked against its manifest sha256 when loaded (`font-integrity-mismatch`, `font-resource-unavailable`), and `npm run test:packed` verifies the packed copies. `node scripts/update-font-manifest.mjs --vendor` re-downloads each recorded commit (or npm version), reads the license from the shipped notice, refuses a non-permissive one (only OFL-1.1, Apache-2.0, MIT and UFL-1.0 are allowed), copies only the listed faces and rewrites the hashes and provenance; `.gitattributes` keeps the binaries byte-exact.
148
+
149
+ Embedding: open faces are flagged `embed: "used"`. `registry.embeddedFonts` stays the 33 Office and base faces, while `prepareNodeFonts().options.embeddedFonts` (or `registry.selectEmbeddedFonts`) supplies every face and `renderSvg` embeds an open face only when the slide's text names its family, so a Montserrat slide carries Montserrat and not the whole pack. Raster output reads the files from `fontFiles`. `includeOpenFonts: false` leaves the open families out of the office pack entirely.
150
+
122
151
  ## Script fonts, lang and right-to-left text (FF-19)
123
152
 
124
153
  Previews itemize text by Unicode script. Each run uses the OOXML script slot its script belongs to (`latin`, `eastAsian` or `complexScript`), as resolved by core `resolveScriptFonts` from the document's language and font scheme. Latin, Greek and Cyrillic text stays in the design font. The text's role picks the major (heading) or minor (body) slots: title, subtitle and tag text is heading, as in the exporter's heading shapes, and all other text is body. Licensed script fonts are never bundled. With a measured registry, a proprietary family (for example Meiryo, Microsoft YaHei, Malgun Gothic, Arabic Typesetting, David, Mangal or Angsana New) is previewed with its designated open replacement from `SCRIPT_FONT_REPLACEMENTS`, and each replacement is recorded in `registry.substitutions` as `visual`. The PPTX keeps the chosen family. If the slot's face has no glyph for a run, the run falls back by coverage to the designated OFL Noto family for its script.
125
154
 
126
- The replacement faces are an optional, hash-pinned font pack: 63 static regular and bold faces from 31 `@expo-google-fonts/noto-*` packages (SIL OFL 1.1). The pinned faces total 66.9 MiB, of which 55.9 MiB is CJK. Installing all 31 packages takes about 325.8 MiB, because they also ship weights the manifest does not pin. They are exact optional peer dependencies, so the renderer install does not grow. Install only the scripts you need, then load them:
155
+ The replacement faces are an optional, hash-pinned font pack: 63 static regular and bold faces from 31 `@expo-google-fonts/noto-*` packages (SIL OFL 1.1). The pinned faces total 66.9 MiB, of which 55.9 MiB is CJK. Installing all 31 packages takes about 325.8 MiB, because they also ship weights the manifest does not pin. Thirty of the 31 are exact optional peer dependencies, so the renderer install does not grow. The exception is `@expo-google-fonts/noto-sans` (Latin, Cyrillic and Greek; regular, bold, italic and bold italic, about 14 MiB installed), which is a pinned runtime dependency because it is the default glyph-fallback face: `loadOfficeFontRegistry` and `prepareNodeFonts({pack: 'office'})` always load it, marked fallback-only (it serves glyph fallback and requests for Noto Sans, never stands in for another family, and is embedded in a standalone SVG only when its text draws it, like the open families (embed "used"); raster output reads it from `fontFiles`). Install only the other scripts you need, then load them:
127
156
 
128
157
  ```js
129
158
  import { prepareNodeFonts } from '@openpresentation/opf-render/fonts-node';
@@ -137,12 +166,35 @@ const png = await svgToPng(slides[0], options);
137
166
 
138
167
  `scripts` accepts ISO 15924 codes (`Jpan`, `Hans`, `Hant`, `Kore`, `Arab`, `Hebr`, `Deva`, `Beng`, `Thai` and others; see `scriptFontPackages('all')`) or `'all'`. `detectScripts(presentation)` from `/fonts` lists the scripts a document's text needs. Every face and license notice is checked against `BUNDLED_FONT_MANIFEST`. A missing package fails with `font-resource-unavailable` and names the exact version to install. Script faces are left out of `options.embeddedFonts` unless you pass `embedScriptFonts: true`, because a CJK face is 5 to 10 MB. Raster output reads them from `fontFiles`. In a browser, serve the installed packages and load `scriptFontEntries(scripts, { baseUrl })` from `/fonts-browser` with `loadBrowserFontRegistry`. The loader verifies each entry's SHA-256 with Web Crypto before use.
139
168
 
169
+ ### Load only the script faces a document uses (`scripts: 'auto'`)
170
+
171
+ Hosts do not need to detect scripts themselves. `scripts: 'auto'` reads the presentation's drawn text and loads only the faces for the scripts it uses: a Latin, Greek or Cyrillic deck loads none, a Japanese deck loads Noto Sans JP, an Arabic deck the three Arabic packages. Text decides, so a deck whose `language` is `ja` but whose text is Latin still loads nothing. The document language only tells Han text apart (through core `resolveScriptFonts`, the same profile the renderer plans with; a core without it treats Han as Simplified Chinese, as the renderer does). Only drawn text counts (titles, text, list items, runs, table cells, chart labels, code, quotes, metrics, furniture); ids, image alt text and sources, URLs, assets, catalogs, speaker notes, `extensions`, a slide's `beat` and the deck's own name, description, filename, author, speaker, audience, purpose, tone, takeaway, duration, tags and narrative are ignored. The organization and a slide's `section` count only when a design turns on that generated header or footer field. Detection itemizes text exactly as drawing does, so with an East Asian language the curly quotes, dashes and ellipsis count as East Asian. `detectPresentationScripts(presentation)` and `autoScriptSelection(presentation)` (from `/fonts-node` and `/fonts-browser`) return the decision.
172
+
173
+ ```js
174
+ // Node: pass the presentation with scripts: 'auto'. Auto never fails on a package that is not installed
175
+ // or a script no pinned font serves; it reports them through onDiagnostic and registry.scriptSelection.
176
+ const { options, registry } = await prepareNodeFonts({ pack: 'office', substitutionPolicy: 'visual', scripts: 'auto', presentation });
177
+ registry.scriptSelection; // { detected, scripts, unavailable, packages, notInstalled }
178
+
179
+ // Browser: script faces are fetched lazily, once each, hash-verified, from where the host serves the
180
+ // installed @expo-google-fonts packages. Call ensureScripts after every edit and render again afterwards.
181
+ const fonts = await loadBrowserFontRegistry(baseEntries, { substitutionPolicy: 'visual', fallbackFamily: 'Roboto', scriptBaseUrl: '/script-fonts/' });
182
+ if (fonts.pendingScripts(presentation).length) await fonts.ensureScripts(presentation); // resolves { detected, scripts, unavailable, loaded }
183
+ // pendingScripts is synchronous: empty means nothing is missing, so render at once.
184
+ ```
185
+
186
+ Where the renderer has per-character glyph fallback (`glyphFallbackFamilies`), auto also loads Noto Sans when the text has Greek or Cyrillic (the chosen Latin face may lack it), and the next CJK face of the fallback chain when a drawn Han, kana or Hangul character is missing from the loaded ones (a Simplified-only hanzi in Japanese text, hanja). That is capped at one fallback package beyond the packages the text's own scripts need: a character no CJK face covers never pulls in all four (10 to 20 MiB each); it is listed in `ensureScripts().uncovered` (Node: `script-glyph-uncovered`, `registry.scriptSelection.uncovered`) and the renderer reports `missing-glyph`. Browser loads are all or nothing per package: a failed fetch, hash check or `FontFace.load()` leaves the registry, the document and `loadedScriptPackages` unchanged and the package pending. One failing package does not lose the others: `ensureScripts` loads every package it can, then rejects with an `OPFFontError` whose `details` are `{ loaded, failed: [{package, code, message}] }`, and the next call retries only the failures. `ensureScripts(presentation, { signal })` and `loadScripts(scripts, { signal })` take a per-call `AbortSignal` (the creation signal is not reused), and `dispose()` during a load leaves no face behind and rejects with `font-registry-disposed`. Script faces carry `embed: "used"`, like the open families: the eager `registry.embeddedFonts` leaves them out, and when you pass them explicitly (`registry.selectEmbeddedFonts(face => face.scripts)`) an SVG embeds one only when the slide's text uses its family, so a Latin slide never carries a CJK face.
187
+
188
+ `loadBrowserFontRegistry` also accepts `scripts: 'auto'` with `presentation` (loads on creation), and `fonts.loadScripts(['Arab'])` for explicit scripts. A registry grows in place: `registry.addFaces(entries)` registers faces atomically, refreshes the script aliases (Meiryo resolves to Noto Sans JP once its face exists) and drops `registry.substitutions` recorded before the face existed, so hosts keep one `textMeasurement`. Faces are never unloaded before `dispose()`. Measurements planned before a face was added (for example a long-lived `createScriptTextMeasurement` wrapper) do not know it; call `ensureScripts` before creating them. Sizes: one CJK package is 10.4 (Japanese), 20.1 (Simplified Chinese), 13.6 (Traditional Chinese) or 11.8 MiB (Korean); the three Arabic packages total 1.8 MiB and each other script under 1.5 MiB. A Latin-only deck downloads nothing extra.
189
+
140
190
  The renderer measures and paints with the same runs. A line whose runs use different families is drawn as positioned tspans, each with its own measured advance. For consistent line breaks, pass the same measurement to pagination and editing: `createScriptTextMeasurement(registry.textMeasurement, resolveScriptFonts(presentation))`. Without a registry, the SVG names every candidate family in order (for example `Meiryo, Noto Sans JP, sans-serif`), and the host resolves glyphs itself.
141
191
 
142
192
  The SVG root carries `lang` and `xml:lang` from the document's `language`. Measurement applies the same OpenType language system that a browser selects for that `lang`; Noto Sans KR spacing, for example, differs under `KOR`. Direction is set per paragraph, meaning the text between hard line breaks. The rule is core's `paragraphDirection(text, deckDirection)`, the same function the exporter uses for `a:pPr rtl`. A paragraph is right to left when the deck language is right to left and its first strong character is right to left, or it has none. Strong characters follow UAX #9 P2: isolates are skipped, LRM/RLM/ALM count, and the letters of every right-to-left script, historic ones included, are right to left. The renderer does not keep its own copy of the rule. With a core that lacks the function, every paragraph is left to right, as in export, and a right-to-left deck reports `paragraph-direction-unavailable`. Every wrapped line of a right-to-left paragraph is laid out as a right-to-left isolate (U+2067 ... U+2069), so lines of one paragraph never differ. Measured rich-text fragments are placed from the right edge.
143
193
 
144
194
  When the installed core has no `resolveScriptFonts` (core 0.11.0 and earlier, which this release no longer accepts by default) and the document names a `language`, the renderer reports `language-preview-unavailable` once through `onDiagnostic`. The preview then uses the design font for every script, sets no `lang` and lays out every paragraph left to right. If the resolver throws, the renderer falls back the same way and reports `language-preview-unresolved`.
145
195
 
196
+ A face that lacks a character never fails a preview. As in a browser or PowerPoint font linking, each character the resolved face lacks is measured and drawn with the first loaded face that has it, along a fixed chain (`glyphFallbackFamilies(character, profile)`): the character's own script face, the deck language's script face, Noto Sans (Latin, Cyrillic and Greek), the other CJK faces (Japanese, Simplified, Traditional, Korean), then every other Noto script face. A Greek or Cyrillic word under Georgia (Gelasio) or a script scheme's face (Constantia previews with the open PT Serif, which has Cyrillic, so only its Greek text falls back), Greek under Meiryo, Yu Gothic, Microsoft YaHei or Malgun Gothic, Japanese-only kanji beside Hangul, and Simplified-only hanzi in a Japanese deck therefore draw with Noto Sans or the CJK face that has them, and each word stays in one face when one has all of it. Each substitution is reported once per family pair and path as a `font-glyph-fallback` diagnostic through `onDiagnostic` (`fontFamily`, `fallbackFamily`, `scripts`, `characters`); it is a note, not an error. Pass `glyphFallback: 'none'` to `renderSvg` or `createScriptTextMeasurement` to keep exact faces, so a missing glyph raises `missing-glyph` again. Coverage is checked per grapheme cluster and per word: a base letter and the combining marks after it (NFD Vietnamese, Cyrillic U+0306) move together to the first face that has the whole cluster, and only their word moves, not the sentence. Without a registry, a Greek or Cyrillic run names `Noto Sans` after the design font in its font stack. With `scripts: 'auto'`, the CJK fallback faces are loaded for you: the language's face plus at most one fallback face (opf-render#55's cap). Greek and Cyrillic need no package, because the office registry always carries Noto Sans. Serif decks prefer the serif face of each script where one is pinned (Noto Serif Hebrew, Tibetan); no Noto Serif is bundled for Latin, Cyrillic, Greek or CJK, so their fallback text draws in the sans face. A character that no loaded face has still raises `missing-glyph`. The PPTX export is unchanged: it names the chosen font and PowerPoint links its own fallback fonts.
197
+
146
198
  Characters are assigned to slots following PowerPoint where its rules are known:
147
199
 
148
200
  - Letters take their script's slot.
@@ -157,8 +209,8 @@ Limits:
157
209
  - Known differences from PowerPoint: the character tables above are approximations, not PowerPoint's full per-character table. Non-ASCII common characters, such as Latin-1 symbols and other general punctuation, join the neighbouring run instead of following PowerPoint's per-character slot, and PowerPoint's `hint="eastAsia"` run property is not modelled.
158
210
  - Per-run language (`lang` on individual runs) is not modelled. Han text uses kana or Hangul context, then the document language, and defaults to Simplified Chinese.
159
211
  - Faces have no italics, so italic script text uses upright advances. Browsers may slant it synthetically.
160
- - Serif CJK replacements (Noto Serif JP/SC/TC/KR) are designated but not pinned; serif CJK requests use the sans face unless you supply the serif face.
161
- - Shaping uses fontkit. An offline Chromium check keeps 27 runs across 26 scripts within 0.1 px of HarfBuzz advances (`npm run test:script-fonts-browser`). Other texts, fonts and PowerPoint's own shaping are not certified.
212
+ - Serif CJK replacements (Noto Serif JP/SC/TC/KR) are designated but not pinned; serif CJK requests, and serif Latin, Cyrillic and Greek fallback text, use the sans face unless you supply a serif face (a loaded designated serif family is preferred automatically).
213
+ - Shaping uses fontkit. An offline Chromium check keeps 37 runs across 28 cases within 0.1 px of HarfBuzz advances (`npm run test:script-fonts-browser`). Other texts, fonts and PowerPoint's own shaping are not certified.
162
214
  - Estimated (unmeasured) rich text keeps logical fragment order and relies on the browser's bidi algorithm.
163
215
 
164
216
  These browser entrypoints are included in the published package. For coordinated development, the sibling OPF repository's `pnpm pack:ecosystem` prepares local npm tarballs. See the core repository's [live editor guide](https://github.com/OpenPresentation/opf/blob/main/docs/live-editor.md) for installation and the fidelity contract. Identical SVG geometry does not guarantee identical raster pixels across browser engines or PowerPoint.
@@ -64,6 +64,7 @@ const FROM_POLICY = FONT_POLICY.filter(row => row.replacement).map(row => ({
64
64
  compatibility: row.replacement.compatibility,
65
65
  ...(row.replacement.compatibility === "metric" ? {weights: [400, 700]} : {}),
66
66
  ...(row.replacement.weight ? {weight: row.replacement.weight} : {}),
67
+ ...(row.replacement.disabledFeatures ? {disabledFeatures: row.replacement.disabledFeatures} : {}),
67
68
  ...(row.replacement.source ? {source: row.replacement.source} : {}),
68
69
  ...(row.replacement.measured ? {measured: row.replacement.measured} : {}),
69
70
  ...(row.replacement.decision ? {decision: row.replacement.decision} : {}),
@@ -75,4 +76,42 @@ const FROM_POLICY = FONT_POLICY.filter(row => row.replacement).map(row => ({
75
76
  }));
76
77
  const listed = new Set(FROM_POLICY.map(rule => rule.requestedFamily.toLowerCase()));
77
78
  export const FONT_COMPATIBILITY = Object.freeze([...FROM_POLICY, ...LEGACY.filter(rule => !listed.has(rule.requestedFamily.toLowerCase()))].map(rule=>Object.freeze({...rule,substitutes:Object.freeze(rule.substitutes),...(rule.weights?{weights:Object.freeze(rule.weights)}:{}),...(rule.measured?{measured:Object.freeze({...rule.measured})}:{})})));
78
- export const EXPERIMENTAL_FONT_CANDIDATES = Object.freeze([Object.freeze({requestedFamily:"Aptos",substitute:"Akasia",source:"https://codeberg.org/bloudraad/akasia",note:"Upstream claims metric compatibility in twelve styles. Not bundled or automatically selected; OPF conformance testing is pending. No Narrow or Display compatibility is implied."})]);
79
+ // FF-31: OpenType features a policy row turns off in its replacement face (Georgia -> Gelasio: liga, clig).
80
+ // The row's metric claim is measured that way, so the renderer must shape (fonts.js) and draw (svg.js)
81
+ // the face that way too. Keyed by the replacement family: a Georgia deck is measured and drawn with
82
+ // Gelasio, and Gelasio requested by name is treated identically, so measurement and drawing never disagree.
83
+ const DISABLED_FEATURES = new Map(FONT_POLICY.filter(row => row.replacement?.disabledFeatures?.length).map(row => [row.replacement.family.toLowerCase(), Object.freeze([...row.replacement.disabledFeatures])]));
84
+ /** OpenType feature tags to turn off for a preview face, from the font policy; undefined when none. */
85
+ export function disabledFeaturesFor(family) {
86
+ return typeof family === "string" ? DISABLED_FEATURES.get(family.trim().toLowerCase()) : undefined;
87
+ }
88
+ /** CSS for SVG text drawn with a face whose features the policy turns off (`liga` and `clig` map to font-variant-ligatures), or undefined. */
89
+ export function disabledFeaturesStyle(family) {
90
+ const tags = disabledFeaturesFor(family);
91
+ if (!tags) return undefined;
92
+ const ligatures = tags.some(tag => tag === "liga" || tag === "clig") ? "font-variant-ligatures:none;" : "";
93
+ return `${ligatures}font-feature-settings:${tags.map(tag => `'${tag}' 0`).join(",")}`;
94
+ }
95
+ /**
96
+ * Raster (resvg) ignores font-variant-ligatures and font-feature-settings, so it would still draw Gelasio's
97
+ * fi/fl/ffi/ffl ligature glyphs. For SVG about to be rasterized only (never the emitted SVG), put a zero-width
98
+ * non-joiner after each f that a ligature would join to the next letter, in text drawn with a face whose
99
+ * policy row turns liga or clig off. ZWNJ has zero advance, so the glyphs follow the unligated measurement.
100
+ */
101
+ export function separateLigatures(svg) {
102
+ if (!DISABLED_FEATURES.size || !/<text[\s>]/.test(svg)) return svg;
103
+ const tokens = /<(\/?)([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>|<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|[^<]+/g;
104
+ const stack = [], ligatureFree = family => DISABLED_FEATURES.get(family)?.some(tag => tag === "liga" || tag === "clig") === true;
105
+ return svg.replace(tokens, (token, close, name, attributes, selfClose) => {
106
+ if (name === undefined) return stack.at(-1) ? token.replace(/f(?=[fil])/g, "f\u200C") : token;
107
+ if (name !== "text" && name !== "tspan") return token;
108
+ if (close) { stack.pop(); return token; }
109
+ const declared = /\sfont-family\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(attributes);
110
+ const family = declared ? (declared[1] ?? declared[2]).split(",")[0].trim().replace(/^['"]|['"]$/g, "").toLowerCase() : undefined;
111
+ if (!selfClose) stack.push(family === undefined ? stack.at(-1) ?? false : ligatureFree(family));
112
+ return token;
113
+ });
114
+ }
115
+ // Candidates that were measured and did not qualify. Never bundled or selected automatically.
116
+ // Akasia (Aptos) was dropped: its repository is gone and Intos, pinned and measured identical to Aptos 2.01, replaces it.
117
+ export const EXPERIMENTAL_FONT_CANDIDATES = Object.freeze([Object.freeze({requestedFamily:"Segoe UI",substitute:"Selawik",source:"https://github.com/microsoft/Selawik/releases/tag/1.01",note:"Microsoft's OFL Segoe UI fallback, measured 2026-09-29 against Segoe UI 5.71: mean width difference 0.16% and up to 2.5% in regular, 2.65% in italic (no italic faces), 1.75% in Semibold, 349 code points, lowercase 5% shorter, hhea ascent 8% smaller. Fails the metric bar; not bundled. See docs/evidence/font-replacements-20260923/metric-candidates-20260929.json in core."})]);