@openpresentation/opf-render 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/README.md +67 -7
  2. package/dist/font-compatibility.js +40 -1
  3. package/dist/font-manifest.js +1285 -12
  4. package/dist/font-policy.js +91 -36
  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/anton/Anton-Regular.ttf +0 -0
  20. package/fonts/anton/OFL.txt +93 -0
  21. package/fonts/anton/PROVENANCE.json +21 -0
  22. package/fonts/archivo-narrow/ArchivoNarrow_400Regular.ttf +0 -0
  23. package/fonts/archivo-narrow/ArchivoNarrow_400Regular_Italic.ttf +0 -0
  24. package/fonts/archivo-narrow/ArchivoNarrow_700Bold.ttf +0 -0
  25. package/fonts/archivo-narrow/ArchivoNarrow_700Bold_Italic.ttf +0 -0
  26. package/fonts/archivo-narrow/OFL.txt +93 -0
  27. package/fonts/archivo-narrow/PROVENANCE.json +47 -0
  28. package/fonts/barlow/Barlow-Bold.ttf +0 -0
  29. package/fonts/barlow/Barlow-BoldItalic.ttf +0 -0
  30. package/fonts/barlow/Barlow-Italic.ttf +0 -0
  31. package/fonts/barlow/Barlow-Regular.ttf +0 -0
  32. package/fonts/barlow/OFL.txt +93 -0
  33. package/fonts/barlow/PROVENANCE.json +45 -0
  34. package/fonts/bebas-neue/BebasNeue_400Regular.ttf +0 -0
  35. package/fonts/bebas-neue/OFL.txt +93 -0
  36. package/fonts/bebas-neue/PROVENANCE.json +23 -0
  37. package/fonts/bitter/Bitter_400Regular.ttf +0 -0
  38. package/fonts/bitter/Bitter_400Regular_Italic.ttf +0 -0
  39. package/fonts/bitter/Bitter_700Bold.ttf +0 -0
  40. package/fonts/bitter/Bitter_700Bold_Italic.ttf +0 -0
  41. package/fonts/bitter/OFL.txt +94 -0
  42. package/fonts/bitter/PROVENANCE.json +49 -0
  43. package/fonts/carlito/Carlito-Bold.ttf +0 -0
  44. package/fonts/carlito/Carlito-BoldItalic.ttf +0 -0
  45. package/fonts/carlito/Carlito-Italic.ttf +0 -0
  46. package/fonts/carlito/Carlito-Regular.ttf +0 -0
  47. package/fonts/carlito/OFL.txt +94 -0
  48. package/fonts/eb-garamond/EBGaramond_400Regular.ttf +0 -0
  49. package/fonts/eb-garamond/EBGaramond_400Regular_Italic.ttf +0 -0
  50. package/fonts/eb-garamond/EBGaramond_700Bold.ttf +0 -0
  51. package/fonts/eb-garamond/EBGaramond_700Bold_Italic.ttf +0 -0
  52. package/fonts/eb-garamond/OFL.txt +93 -0
  53. package/fonts/eb-garamond/PROVENANCE.json +47 -0
  54. package/fonts/figtree/Figtree_400Regular.ttf +0 -0
  55. package/fonts/figtree/Figtree_400Regular_Italic.ttf +0 -0
  56. package/fonts/figtree/Figtree_700Bold.ttf +0 -0
  57. package/fonts/figtree/Figtree_700Bold_Italic.ttf +0 -0
  58. package/fonts/figtree/OFL.txt +93 -0
  59. package/fonts/figtree/PROVENANCE.json +47 -0
  60. package/fonts/intos/Intos-Bold.ttf +0 -0
  61. package/fonts/intos/Intos-BoldItalic.ttf +0 -0
  62. package/fonts/intos/Intos-Italic.ttf +0 -0
  63. package/fonts/intos/Intos-Regular.ttf +0 -0
  64. package/fonts/intos/IntosDisplay-Bold.ttf +0 -0
  65. package/fonts/intos/IntosDisplay-BoldItalic.ttf +0 -0
  66. package/fonts/intos/IntosDisplay-Italic.ttf +0 -0
  67. package/fonts/intos/IntosDisplay-Regular.ttf +0 -0
  68. package/fonts/intos/IntosNarrow-Bold.ttf +0 -0
  69. package/fonts/intos/IntosNarrow-BoldItalic.ttf +0 -0
  70. package/fonts/intos/IntosNarrow-Italic.ttf +0 -0
  71. package/fonts/intos/IntosNarrow-Regular.ttf +0 -0
  72. package/fonts/intos/IntosSerif-Bold.ttf +0 -0
  73. package/fonts/intos/IntosSerif-BoldItalic.ttf +0 -0
  74. package/fonts/intos/IntosSerif-Italic.ttf +0 -0
  75. package/fonts/intos/IntosSerif-Regular.ttf +0 -0
  76. package/fonts/intos/NOTICE.md +18 -0
  77. package/fonts/intos/OFL.txt +94 -0
  78. package/fonts/intos/PROVENANCE.json +144 -0
  79. package/fonts/libre-caslon-text/LibreCaslonText_400Regular.ttf +0 -0
  80. package/fonts/libre-caslon-text/LibreCaslonText_400Regular_Italic.ttf +0 -0
  81. package/fonts/libre-caslon-text/LibreCaslonText_700Bold.ttf +0 -0
  82. package/fonts/libre-caslon-text/OFL.txt +93 -0
  83. package/fonts/libre-caslon-text/PROVENANCE.json +39 -0
  84. package/fonts/lora/Lora-Bold.ttf +0 -0
  85. package/fonts/lora/Lora-BoldItalic.ttf +0 -0
  86. package/fonts/lora/Lora-Italic.ttf +0 -0
  87. package/fonts/lora/Lora-Regular.ttf +0 -0
  88. package/fonts/lora/OFL.txt +93 -0
  89. package/fonts/lora/PROVENANCE.json +48 -0
  90. package/fonts/merriweather-sans/MerriweatherSans-Bold.ttf +0 -0
  91. package/fonts/merriweather-sans/MerriweatherSans-BoldItalic.ttf +0 -0
  92. package/fonts/merriweather-sans/MerriweatherSans-Italic.ttf +0 -0
  93. package/fonts/merriweather-sans/MerriweatherSans-Regular.ttf +0 -0
  94. package/fonts/merriweather-sans/OFL.txt +96 -0
  95. package/fonts/merriweather-sans/PROVENANCE.json +48 -0
  96. package/fonts/montserrat/Montserrat_400Regular.ttf +0 -0
  97. package/fonts/montserrat/Montserrat_400Regular_Italic.ttf +0 -0
  98. package/fonts/montserrat/Montserrat_700Bold.ttf +0 -0
  99. package/fonts/montserrat/Montserrat_700Bold_Italic.ttf +0 -0
  100. package/fonts/montserrat/Montserrat_900Black.ttf +0 -0
  101. package/fonts/montserrat/OFL.txt +93 -0
  102. package/fonts/montserrat/PROVENANCE.json +55 -0
  103. package/fonts/open-sans/OFL.txt +92 -0
  104. package/fonts/open-sans/OpenSans_400Regular.ttf +0 -0
  105. package/fonts/open-sans/OpenSans_400Regular_Italic.ttf +0 -0
  106. package/fonts/open-sans/OpenSans_700Bold.ttf +0 -0
  107. package/fonts/open-sans/OpenSans_700Bold_Italic.ttf +0 -0
  108. package/fonts/open-sans/PROVENANCE.json +47 -0
  109. package/fonts/poppins/OFL.txt +93 -0
  110. package/fonts/poppins/PROVENANCE.json +47 -0
  111. package/fonts/poppins/Poppins_400Regular.ttf +0 -0
  112. package/fonts/poppins/Poppins_400Regular_Italic.ttf +0 -0
  113. package/fonts/poppins/Poppins_700Bold.ttf +0 -0
  114. package/fonts/poppins/Poppins_700Bold_Italic.ttf +0 -0
  115. package/fonts/pt-serif/OFL.txt +93 -0
  116. package/fonts/pt-serif/PROVENANCE.json +50 -0
  117. package/fonts/pt-serif/PT_Serif-Web-Bold.ttf +0 -0
  118. package/fonts/pt-serif/PT_Serif-Web-BoldItalic.ttf +0 -0
  119. package/fonts/pt-serif/PT_Serif-Web-Italic.ttf +0 -0
  120. package/fonts/pt-serif/PT_Serif-Web-Regular.ttf +0 -0
  121. package/fonts/red-hat-display/OFL.txt +93 -0
  122. package/fonts/red-hat-display/PROVENANCE.json +79 -0
  123. package/fonts/red-hat-display/RedHatDisplay_300Light.ttf +0 -0
  124. package/fonts/red-hat-display/RedHatDisplay_300Light_Italic.ttf +0 -0
  125. package/fonts/red-hat-display/RedHatDisplay_400Regular.ttf +0 -0
  126. package/fonts/red-hat-display/RedHatDisplay_400Regular_Italic.ttf +0 -0
  127. package/fonts/red-hat-display/RedHatDisplay_600SemiBold.ttf +0 -0
  128. package/fonts/red-hat-display/RedHatDisplay_600SemiBold_Italic.ttf +0 -0
  129. package/fonts/red-hat-display/RedHatDisplay_700Bold.ttf +0 -0
  130. package/fonts/red-hat-display/RedHatDisplay_700Bold_Italic.ttf +0 -0
  131. package/fonts/red-hat-text/OFL.txt +93 -0
  132. package/fonts/red-hat-text/PROVENANCE.json +47 -0
  133. package/fonts/red-hat-text/RedHatText_400Regular.ttf +0 -0
  134. package/fonts/red-hat-text/RedHatText_400Regular_Italic.ttf +0 -0
  135. package/fonts/red-hat-text/RedHatText_700Bold.ttf +0 -0
  136. package/fonts/red-hat-text/RedHatText_700Bold_Italic.ttf +0 -0
  137. package/fonts/source-sans-3/OFL.txt +93 -0
  138. package/fonts/source-sans-3/PROVENANCE.json +49 -0
  139. package/fonts/source-sans-3/SourceSans3-Bold.ttf +0 -0
  140. package/fonts/source-sans-3/SourceSans3-BoldIt.ttf +0 -0
  141. package/fonts/source-sans-3/SourceSans3-It.ttf +0 -0
  142. package/fonts/source-sans-3/SourceSans3-Regular.ttf +0 -0
  143. package/fonts/work-sans/OFL.txt +93 -0
  144. package/fonts/work-sans/PROVENANCE.json +47 -0
  145. package/fonts/work-sans/WorkSans_400Regular.ttf +0 -0
  146. package/fonts/work-sans/WorkSans_400Regular_Italic.ttf +0 -0
  147. package/fonts/work-sans/WorkSans_700Bold.ttf +0 -0
  148. package/fonts/work-sans/WorkSans_700Bold_Italic.ttf +0 -0
  149. package/package.json +11 -10
@@ -3,6 +3,10 @@
3
3
  // peers, so the renderer install stays small; hosts install only what they use.
4
4
  import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
5
5
  import { OPFFontError } from "./fonts.js";
6
+ import * as scriptFontModule from "./script-fonts.js";
7
+ import { scriptOfCharacter, scriptsOfText } from "./script-fonts.js";
8
+ // Optional core exports are read from the namespace so an older published core still loads.
9
+ import * as opfCore from "@openpresentation/opf";
6
10
 
7
11
  const SCRIPT_ALIASES = Object.freeze({Hira: "Jpan", Kana: "Jpan", Hrkt: "Jpan", Hang: "Kore", Hani: "Hans", Zyyy: "Latn"});
8
12
 
@@ -30,11 +34,146 @@ export function scriptFontPackages(scripts) {
30
34
  * which `loadBrowserFontRegistry` verifies before use.
31
35
  */
32
36
  export function scriptFontEntries(scripts, {baseUrl}) {
37
+ return scriptPackageEntries(scriptFontPackages(scripts), {baseUrl});
38
+ }
39
+
40
+ /** Browser entries for whole manifest packages (see `scriptFontEntries`). */
41
+ export function scriptPackageEntries(packages, {baseUrl}) {
33
42
  if (typeof baseUrl !== "string" || !baseUrl) throw new OPFFontError("invalid-font-source", "scriptFontEntries requires the baseUrl that serves the installed font packages.");
34
43
  const root = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
35
- return scriptFontPackages(scripts).flatMap(pkg => pkg.faces.map(face => ({
44
+ return packages.flatMap(pkg => pkg.faces.map(face => ({
36
45
  url: `${root}${pkg.name.split("/").pop()}/${face.file}`,
37
46
  family: face.family, weight: face.weight, italic: face.italic, sha256: face.sha256,
38
47
  scripts: [...pkg.scripts], package: `${pkg.name}@${pkg.version}`, license: pkg.license,
48
+ // A script face is embedded in a standalone SVG only when the slide's text uses its family.
49
+ embed: "used",
39
50
  })));
40
51
  }
52
+
53
+ // Fields a preview never draws (checked against what the renderer and core composition draw).
54
+ // Everywhere: the schema URL, embedded assets, catalog records, speaker notes, ids, image alt text and sources,
55
+ // links, metadata and typed `extensions` passthrough. On the presentation: name (aria-label fallback), description,
56
+ // filename, author, speaker, audience, purpose, tone, takeaway, duration, tags, narrative, language and version.
57
+ // On a slide: beat. The organization (name) and a slide's section are drawn only as generated header/footer
58
+ // furniture (`organization: true` / `section: true` in a design), so they count only when a design asks for
59
+ // them. Everything else is drawn text: titles, text blocks, list items, runs, table cells, chart labels,
60
+ // categories and series names, code, quotes, metrics, timelines, furniture text.
61
+ const UNDRAWN_KEYS = Object.freeze(["$schema", "assets", "catalogs", "notes", "id", "alt", "altText", "src", "url", "href", "link", "metadata", "extensions"]);
62
+ const UNDRAWN_ROOT_KEYS = Object.freeze(["name", "description", "filename", "author", "authors", "creator", "speaker", "audience", "purpose", "tone", "takeaway", "duration", "tags", "narrative", "language", "license", "version", "keywords"]);
63
+ const UNDRAWN_SLIDE_KEYS = Object.freeze(["beat"]);
64
+ const UNDRAWN_VALUE = /^(?:https?|data|blob|pkg|file|mailto):\S*$/i;
65
+
66
+ /** True when a design in the presentation turns on generated `field` furniture (`organization` or `section`). */
67
+ function furnitureUses(presentation, field) {
68
+ const designs = [presentation?.design, ...(Array.isArray(presentation?.slides) ? presentation.slides.map(slide => slide?.design) : [])];
69
+ const pattern = new RegExp(`"${field}"\\s*:\\s*true`);
70
+ return designs.some(design => design && pattern.test(JSON.stringify(design)));
71
+ }
72
+
73
+ /** The strings a preview of this presentation draws. */
74
+ export function* drawnStrings(presentation) {
75
+ const undrawn = new Set(UNDRAWN_KEYS), undrawnRoot = new Set(UNDRAWN_ROOT_KEYS), undrawnSlide = new Set(UNDRAWN_SLIDE_KEYS);
76
+ if (!furnitureUses(presentation, "organization")) undrawnRoot.add("organization");
77
+ if (!furnitureUses(presentation, "section")) undrawnSlide.add("section");
78
+ function* visit(item, level) {
79
+ if (typeof item === "string") { if (!UNDRAWN_VALUE.test(item)) yield item; }
80
+ else if (Array.isArray(item)) for (const child of item) yield* visit(child, level === "slides" ? "slide" : level === "root" ? "deep" : level);
81
+ else if (item && typeof item === "object") {
82
+ for (const [key, child] of Object.entries(item)) {
83
+ if (undrawn.has(key) || (level === "root" && undrawnRoot.has(key)) || (level === "slide" && undrawnSlide.has(key))) continue;
84
+ yield* visit(child, level === "root" && key === "slides" ? "slides" : "deep");
85
+ }
86
+ }
87
+ }
88
+ yield* visit(presentation, "root");
89
+ }
90
+
91
+ /**
92
+ * The script profile of a presentation: core `resolveScriptFonts` output, exactly what the renderer plans
93
+ * with. When the installed core has none (published core 0.11.0 and earlier) or it throws, the renderer
94
+ * ignores the document language, so the profile is empty and Han text is Simplified Chinese, as drawn.
95
+ */
96
+ export function presentationScriptProfile(presentation) {
97
+ if (typeof opfCore.resolveScriptFonts === "function") {
98
+ try { return opfCore.resolveScriptFonts(presentation); } catch { /* the renderer falls back the same way */ }
99
+ }
100
+ return {};
101
+ }
102
+
103
+ const servedScripts = () => new Set(BUNDLED_FONT_MANIFEST.packages.filter(item => item.pack === "scripts").flatMap(item => item.scripts));
104
+ const cjkKeys = ["Jpan", "Hans", "Hant", "Kore"];
105
+ const cjkCharacters = new Set(["Hani", "Hira", "Kana", "Hang", "Bopo"]);
106
+ /** True when the renderer has per-character glyph fallback (FF-19 glyphFallbackFamilies). */
107
+ const hasGlyphFallback = () => typeof scriptFontModule.glyphFallbackFamilies === "function";
108
+
109
+ /**
110
+ * What a preview of the presentation draws: its script profile, the scripts of its text (the renderer's own
111
+ * itemization, so language-dependent punctuation counts), and the CJK characters that a glyph fallback might
112
+ * have to draw with another CJK face.
113
+ */
114
+ export function analyzePresentationScripts(presentation, profile = presentationScriptProfile(presentation)) {
115
+ const scripts = new Set(), cjk = new Set();
116
+ for (const text of drawnStrings(presentation)) {
117
+ for (const script of scriptsOfText(text, profile)) scripts.add(script);
118
+ for (const character of text) if (character.codePointAt(0) > 0x2E7F && cjkCharacters.has(scriptOfCharacter(character))) cjk.add(character);
119
+ }
120
+ return { profile, detected: [...scripts].sort(), cjk };
121
+ }
122
+
123
+ /**
124
+ * Script keys (ISO 15924: `Jpan`, `Hans`, `Arab`, ...) whose faces a preview of this presentation needs.
125
+ * Text decides, itemized exactly as drawing does: the document language only tells Han text apart
126
+ * (Japanese, Korean, Simplified or Traditional Chinese) and makes curly quotes, dashes and the ellipsis
127
+ * East Asian. Text the preview never draws (ids, alt text, sources, assets, catalogs, notes) is ignored.
128
+ * Greek and Cyrillic add nothing: the office registry always carries Noto Sans (the glyph-fallback face) and Roboto covers them.
129
+ * Pass `profile` to reuse a resolved profile.
130
+ */
131
+ export function detectPresentationScripts(presentation, { profile } = {}) {
132
+ return analyzePresentationScripts(presentation, profile).detected;
133
+ }
134
+
135
+ /** The scripts of an analysis that a pinned pack serves (`scripts`, the input for `scriptFontPackages`) and those it does not. */
136
+ export function scriptSelectionOf(analysis) {
137
+ const served = servedScripts();
138
+ return { detected: analysis.detected, scripts: analysis.detected.filter(script => served.has(script)), unavailable: analysis.detected.filter(script => !served.has(script)) };
139
+ }
140
+
141
+ /**
142
+ * What `scripts: 'auto'` loads for a presentation: the `detected` scripts, those a pinned pack serves
143
+ * (`scripts`, the input for `scriptFontPackages`) and those it does not (`unavailable`, for example
144
+ * Cherokee or Tifinagh, which draw with whatever the design font covers).
145
+ */
146
+ export function autoScriptSelection(presentation, options) {
147
+ return scriptSelectionOf(analyzePresentationScripts(presentation, options?.profile));
148
+ }
149
+
150
+ /**
151
+ * The next CJK package a glyph fallback needs (FF-19): a drawn Han, kana or Hangul character that no loaded
152
+ * script face has (Japanese-only kanji in a Simplified Chinese run, Simplified-only hanzi beside kana, hanja)
153
+ * takes the first unloaded CJK face along the renderer's fallback chain. `covers(character)` says whether a
154
+ * loaded script face has the glyph; `loaded` holds the loaded package names. Undefined when nothing is needed,
155
+ * the renderer has no glyph fallback, or the cap is reached: a character no CJK face covers must not pull in
156
+ * every CJK package (10 to 20 MiB each), so at most ONE fallback package is loaded beyond the packages the
157
+ * text's own scripts need. Characters still uncovered then are reported (`uncoveredCjkCharacters`) and the
158
+ * renderer reports `missing-glyph` for them.
159
+ */
160
+ export function nextFallbackPackage(analysis, { covers, loaded }) {
161
+ if (!hasGlyphFallback()) return undefined;
162
+ const packages = BUNDLED_FONT_MANIFEST.packages.filter(item => item.pack === "scripts" && item.scripts.some(script => cjkKeys.includes(script)));
163
+ const primary = analysis.detected.filter(script => cjkKeys.includes(script)).length;
164
+ if (packages.filter(item => loaded.has(item.name)).length >= primary + 1) return undefined;
165
+ for (const character of analysis.cjk) {
166
+ if (covers(character)) continue;
167
+ for (const family of scriptFontModule.glyphFallbackFamilies(character, analysis.profile, analysis.profile?.serif === true)) {
168
+ const pkg = packages.find(item => !loaded.has(item.name) && item.faces.some(face => face.family === family));
169
+ if (pkg) return pkg;
170
+ }
171
+ }
172
+ return undefined;
173
+ }
174
+
175
+ /** Drawn Han, kana or Hangul characters (at most `limit`) that no loaded script face covers. Empty without glyph fallback (nothing else would draw them). */
176
+ export function uncoveredCjkCharacters(analysis, { covers, limit = 16 }) {
177
+ if (!hasGlyphFallback()) return [];
178
+ return [...analysis.cjk].filter(character => !covers(character)).slice(0, limit);
179
+ }
@@ -7,6 +7,14 @@
7
7
  // face is missing or lacks glyphs falls back to the designated open (OFL Noto)
8
8
  // replacement for that script. Licensed fonts are never bundled: a preview names
9
9
  // the designated replacement, while the PPTX keeps the chosen family (FF-07).
10
+ //
11
+ // Glyph fallback: a face that lacks a character (a Latin replacement without
12
+ // Cyrillic or Greek, a CJK face without a kanji or hanzi) never fails a preview.
13
+ // As in a browser or PowerPoint font linking, each such character takes the
14
+ // first bundled face that has it along the deterministic chain of
15
+ // `glyphFallbackFamilies`, in measurement and drawing alike, and the substitution
16
+ // is reported as a note. `glyphFallback: "none"` (strict mode) keeps the exact
17
+ // faces and lets a missing glyph raise `missing-glyph`.
10
18
 
11
19
  const freeze = value => { if (value && typeof value === "object") { for (const child of Object.values(value)) freeze(child); Object.freeze(value); } return value; };
12
20
 
@@ -199,16 +207,31 @@ export function itemizeScripts(text, profile) {
199
207
  return runs;
200
208
  }
201
209
 
202
- /** Script keys (as used by the script font pack) present in the strings of any JSON value. */
203
- export function detectScripts(value, profile) {
204
- const scripts = new Set();
210
+ /**
211
+ * Non-Latin script keys the renderer would plan runs for in `text`. It applies the same shortcut as drawing
212
+ * (`createScriptFonts().plan`): Latin, Greek and Cyrillic text is one run in the design font, except that
213
+ * with an East Asian document language the curly quotes, dashes and ellipsis are East Asian characters.
214
+ */
215
+ export function scriptsOfText(text, profile) {
216
+ const source = String(text ?? "");
217
+ if (latinOnly.test(source) && !(eastAsianLanguage(profile) && eastAsianAmbiguous.test(source))) return [];
218
+ return itemizeScripts(source, profile).filter(run => run.script !== "Latn").map(run => run.script);
219
+ }
220
+
221
+ /**
222
+ * Script keys (as used by the script font pack) present in the strings of any JSON value.
223
+ * The profile's own script is included too, unless `includeLanguage` is false (then only
224
+ * text decides). `ignoreKeys` names object keys whose values are not visited.
225
+ */
226
+ export function detectScripts(value, profile, { includeLanguage = true, ignoreKeys = [] } = {}) {
227
+ const scripts = new Set(), ignored = new Set(ignoreKeys);
205
228
  const visit = item => {
206
- if (typeof item === "string") { if (!latinOnly.test(item)) for (const run of itemizeScripts(item, profile)) if (run.script !== "Latn") scripts.add(run.script); }
229
+ if (typeof item === "string") { for (const script of scriptsOfText(item, profile)) scripts.add(script); }
207
230
  else if (Array.isArray(item)) item.forEach(visit);
208
- else if (item && typeof item === "object") Object.values(item).forEach(visit);
231
+ else if (item && typeof item === "object") for (const [key, child] of Object.entries(item)) if (!ignored.has(key)) visit(child);
209
232
  };
210
233
  visit(value);
211
- if (profile?.script && !latinGroup.has(profile.script)) scripts.add(fontKey(profile.script, "", profile));
234
+ if (includeLanguage && profile?.script && !latinGroup.has(profile.script)) scripts.add(fontKey(profile.script, "", profile));
212
235
  return [...scripts].sort();
213
236
  }
214
237
 
@@ -219,6 +242,35 @@ export function designatedFamilies(script, serif = false) {
219
242
  return (serif ? [entry.serif, entry.sans] : [entry.sans, entry.serif]).filter(Boolean);
220
243
  }
221
244
 
245
+ const hanKeys = ["Jpan", "Hans", "Hant", "Kore"];
246
+
247
+ /**
248
+ * Deterministic glyph fallback chain for one character: the designated open
249
+ * families, in order, that a preview tries when the chosen face lacks the
250
+ * character (the same order in measurement and drawing).
251
+ *
252
+ * 1. the character's own script policy face (`SCRIPT_FONT_FAMILIES`; a Han
253
+ * character takes the deck language's face: Japanese, Simplified, Traditional
254
+ * or Korean);
255
+ * 2. the deck language's script face;
256
+ * 3. Noto Sans, which covers Latin, Cyrillic and Greek;
257
+ * 4. the other CJK faces (Japanese, Simplified, Traditional, Korean);
258
+ * 5. every other Noto script face, in `SCRIPT_FONT_FAMILIES` order.
259
+ *
260
+ * `profile` is core `resolveScriptFonts` output (or the same shape); serif
261
+ * profiles prefer the serif face of each script. Families that are not loaded
262
+ * are skipped by the caller.
263
+ */
264
+ export function glyphFallbackFamilies(character, profile = {}, serif = profile?.serif === true) {
265
+ const script = scriptOfCharacter(character);
266
+ const language = fontKey(profile?.script ?? "Zzzz", "", profile);
267
+ // A Han character has no script of its own: it takes the deck language's face when that is CJK,
268
+ // otherwise every CJK face is tried in turn (Japanese, Simplified, Traditional, Korean).
269
+ const own = script === "Hani" ? (hanKeys.includes(language) ? language : "Zzzz") : fontKey(script, character, profile);
270
+ const keys = [own, language, "Latn", ...hanKeys, ...Object.keys(SCRIPT_FONT_FAMILIES)];
271
+ return [...new Set([...new Set(keys)].flatMap(key => designatedFamilies(key, serif)))];
272
+ }
273
+
222
274
  /**
223
275
  * Aliases from proprietary script fonts to the first loaded designated
224
276
  * replacement. `families` are the loaded script-replacement families.
@@ -286,9 +338,23 @@ const scriptMeasurement = Symbol.for("@openpresentation/opf-render/script-measur
286
338
  * plus an optional `serif` flag. `measurement` is an optional font-registry
287
339
  * `textMeasurement`; without it the plan names every candidate family in order
288
340
  * so the host resolves glyphs per character.
341
+ *
342
+ * `options.glyphFallback` is `"chain"` (default: a character its face lacks
343
+ * takes the first bundled face along `glyphFallbackFamilies`) or `"none"`
344
+ * (exact faces: a missing glyph raises `missing-glyph`). `options.onFallback`
345
+ * receives each new substitution note `{fontFamily, fallbackFamily, scripts,
346
+ * characters, path?}` once; `fallbacks` lists them all.
289
347
  */
290
- export function createScriptFonts(profile = {}, measurement) {
291
- const inner = measurement?.[scriptMeasurement]?.inner ?? measurement;
348
+ export function createScriptFonts(profile = {}, measurement, options = {}) {
349
+ const wrapped = measurement?.[scriptMeasurement];
350
+ const inner = wrapped?.inner ?? measurement;
351
+ // Glyph fallback is on unless `glyphFallback: "none"` (strict faces); a wrapped measurement keeps its options.
352
+ // Only defined keys override: a caller passing `glyphFallback: undefined` must not undo a wrapper's `"none"`.
353
+ // A renderer-supplied `onFallback` chains with, and does not replace, the wrapper's own.
354
+ const notifiers = [wrapped?.options?.onFallback, options?.onFallback].filter(notify => typeof notify === "function");
355
+ options = {...wrapped?.options, ...Object.fromEntries(Object.entries(options ?? {}).filter(([, value]) => value !== undefined)), onFallback: notifiers.length ? note => { for (const notify of notifiers) notify(note); } : undefined};
356
+ const fallbackEnabled = options.glyphFallback !== "none";
357
+ const fallbackNotes = new Map();
292
358
  const measured = typeof inner?.measure === "function";
293
359
  const serif = profile.serif === true;
294
360
  // The language the SVG declares (lang) also selects OpenType language systems in measurement.
@@ -333,6 +399,37 @@ export function createScriptFonts(profile = {}, measurement) {
333
399
  const supplement = profile.supplement ? {script: profile.supplement.script === "Hang" ? "Kore" : profile.supplement.script, family: useHeading ? profile.supplement.heading : profile.supplement.body} : undefined;
334
400
  return {latin: style.fontFamily, eastAsian: slots.eastAsian ?? style.fontFamily, complexScript: slots.complexScript ?? style.fontFamily, supplement};
335
401
  };
402
+ const nonAscii = /[^\u0020-\u007E]/;
403
+ const cyrillicOrGreek = /[\u0370-\u052F\u1F00-\u1FFF]/;
404
+ const chains = new Map();
405
+ const chainFor = character => {
406
+ const script = scriptOfCharacter(character);
407
+ let chain = chains.get(script);
408
+ if (!chain) chains.set(script, chain = glyphFallbackFamilies(character, profile, serif));
409
+ return chain;
410
+ };
411
+ /** Collect that `family` draws characters the chosen `from` face lacks; `flushFallbacks` records them per planned text. */
412
+ const noteFallback = (pending, from, family, characters) => {
413
+ if (from === null || from === family) return;
414
+ const key = `${from}\u0000${family}`;
415
+ let entry = pending.get(key);
416
+ if (!entry) pending.set(key, entry = {from, family, characters: new Set()});
417
+ for (const character of characters) entry.characters.add(character);
418
+ };
419
+ const flushFallbacks = (pending, style) => {
420
+ for (const {from, family, characters} of pending.values()) {
421
+ const key = `${from}\u0000${family}\u0000${style.path ?? ""}`;
422
+ let note = fallbackNotes.get(key);
423
+ const isNew = !note;
424
+ if (isNew) fallbackNotes.set(key, note = {fontFamily: from, fallbackFamily: family, scripts: [], characters: [], ...(style.path ? {path: style.path} : {})});
425
+ for (const character of characters) {
426
+ const script = scriptOfCharacter(character);
427
+ if (!note.scripts.includes(script)) note.scripts.push(script);
428
+ if (note.characters.length < 16 && !note.characters.includes(character)) note.characters.push(character);
429
+ }
430
+ if (isNew) options.onFallback?.(note);
431
+ }
432
+ };
336
433
  const candidatesFor = (run, slots) => {
337
434
  const list = [run.role === "eastAsian" ? slots.eastAsian : run.role === "complexScript" ? slots.complexScript : slots.latin];
338
435
  if (run.script !== "Latn") {
@@ -345,13 +442,22 @@ export function createScriptFonts(profile = {}, measurement) {
345
442
  /** Font runs for text drawn in `style` (the resolved latin style). `own` runs use the style's family. */
346
443
  const plan = (text, style) => {
347
444
  text = String(text ?? "");
348
- if (latinOnly.test(text) && !(eastAsianText && eastAsianAmbiguous.test(text))) return [{text, family: style.fontFamily, own: true}];
349
- const cacheKey = `${style.fontFamily}\u0000${style.fontWeight ?? 400}\u0000${!!style.italic}\u0000${textRole(style) ?? ""}\u0000${text}`;
445
+ if (latinOnly.test(text) && !(eastAsianText && eastAsianAmbiguous.test(text))) {
446
+ // Latin-group text is one run in the latin slot's face, unless that face lacks a character
447
+ // (Cyrillic or Greek beyond a Latin replacement): then it is planned per character below.
448
+ if (!fallbackEnabled || !nonAscii.test(text)) return [{text, family: style.fontFamily, own: true}];
449
+ if (!measured) return cyrillicOrGreek.test(text) ? [{text, family: style.fontFamily, own: false, stack: [style.fontFamily, ...designatedFamilies("Latn", serif)]}] : [{text, family: style.fontFamily, own: true}];
450
+ const own = resolveName(style.fontFamily, style);
451
+ if (own === null || covers(own, text, style)) return [{text, family: style.fontFamily, own: true}];
452
+ }
453
+ const cacheKey = `${style.fontFamily}\u0000${style.fontWeight ?? 400}\u0000${!!style.italic}\u0000${textRole(style) ?? ""}\u0000${style.path ?? ""}\u0000${text}`;
350
454
  const cached = plans.get(cacheKey);
351
455
  if (cached) return cached;
352
456
  const slots = slotsFor(style);
353
457
  const runs = itemizeScripts(text, profile).map(run => ({...run, candidates: candidatesFor(run, slots)}));
458
+ const pending = new Map();
354
459
  const global = [...new Set([...runs.flatMap(run => run.candidates), ...designatedFamilies(fontKey(profile.script ?? "Zzzz", text, profile), serif), ...designatedFamilies("Latn", serif)])];
460
+ const unique = names => names.map(name => resolveName(name, style)).filter((name, index, all) => name && all.indexOf(name) === index);
355
461
  const out = [];
356
462
  const push = (text, family, stack) => {
357
463
  const own = family === style.fontFamily && (!stack || stack.length === 1);
@@ -361,20 +467,51 @@ export function createScriptFonts(profile = {}, measurement) {
361
467
  };
362
468
  for (const run of runs) {
363
469
  if (!measured) { push(run.text, run.candidates[0], run.candidates); continue; }
470
+ const chosen = resolveName(run.candidates[0], style);
364
471
  const whole = run.candidates.map(family => resolveName(family, style)).find(name => name && covers(name, run.text, style));
365
- if (whole) { push(run.text, whole); continue; }
366
- // No single candidate covers the run: choose per character, keeping
367
- // combining marks and joiners with their base character.
368
- const primary = resolveName(run.candidates[0], style) ?? style.fontFamily;
369
- let previous = primary;
472
+ if (whole) {
473
+ if (whole !== chosen) noteFallback(pending, chosen, whole, [...run.text].filter(character => !covers(chosen ?? whole, character, style)));
474
+ push(run.text, whole);
475
+ continue;
476
+ }
477
+ // No single candidate covers the run. Split it into grapheme clusters (a base character with
478
+ // the combining marks and joiners that follow it) and group those into script segments
479
+ // (common characters stay with their neighbours; white space ends a word). Each segment takes the first face that
480
+ // covers all of its letters and marks: the chosen face, the previous fallback face, the
481
+ // other candidates, then the fallback chain. A word is never drawn in two faces when one
482
+ // face has all of it (Greek beside a Latin serif, a kanji run beside Hangul). A cluster
483
+ // that face still lacks, mark included, moves whole to the first face that has the cluster,
484
+ // so a base and its mark are never split across faces.
485
+ const primary = chosen ?? style.fontFamily;
486
+ let sticky = null;
487
+ // The note lists what the chosen face lacks (a mark, not its covered base letter); a cluster it only lacks as a whole lists all.
488
+ const missingFrom = (face, text) => { const lacking = [...text].filter(character => !covers(face, character, style)); return lacking.length ? lacking : [...text]; };
489
+ const choose = (subject, sample) => unique([run.candidates[0], ...(sticky ? [sticky] : []), ...run.candidates, ...global, ...(fallbackEnabled ? chainFor(sample) : [])]).find(name => covers(name, subject, style));
490
+ const segments = [];
370
491
  for (const character of run.text) {
371
- const script = scriptOfCharacter(character);
372
- let family = previous;
373
- if (script !== "Zinh") family = [...run.candidates, ...global].map(name => resolveName(name, style)).find(name => name && covers(name, character, style)) ?? primary;
374
- push(character, family);
375
- previous = family;
492
+ const script = scriptOfCharacter(character), last = segments.at(-1)?.clusters.at(-1);
493
+ if (script === "Zinh" && last) { last.text += character; continue; }
494
+ const weak = script === "Zyyy" || script === "Zinh", segment = segments.at(-1);
495
+ const cluster = {text: character, base: character, weak};
496
+ // A segment is one script and one word: white space closes it, so a single lacking mark
497
+ // moves its word to another face, not the whole sentence.
498
+ if (segment && !(segment.closed && !weak) && (weak || segment.script === undefined || segment.script === script)) {
499
+ segment.clusters.push(cluster);
500
+ if (!weak) segment.script = script; else if (/^\s/.test(character)) segment.closed = true;
501
+ } else segments.push({script: weak ? undefined : script, clusters: [cluster]});
502
+ }
503
+ for (const segment of segments) {
504
+ const strong = segment.clusters.filter(cluster => !cluster.weak);
505
+ const face = strong.length ? choose(strong.map(cluster => cluster.text).join(""), strong[0].base) : undefined;
506
+ for (const cluster of segment.clusters) {
507
+ const family = face && covers(face, cluster.text, style) ? face : choose(cluster.text, cluster.base) ?? choose(cluster.base, cluster.base) ?? primary;
508
+ if (family !== primary && family !== chosen) sticky = family;
509
+ if (chosen !== null && family !== chosen && covers(family, cluster.text, style)) noteFallback(pending, chosen, family, missingFrom(chosen, cluster.text));
510
+ push(cluster.text, family);
511
+ }
376
512
  }
377
513
  }
514
+ flushFallbacks(pending, style);
378
515
  if (plans.size >= 4096) plans.delete(plans.keys().next().value);
379
516
  plans.set(cacheKey, out);
380
517
  return out;
@@ -392,7 +529,7 @@ export function createScriptFonts(profile = {}, measurement) {
392
529
  if (runs.length === 1 && runs[0].own) return inner.measure(text, size, styled(style));
393
530
  return runs.reduce((total, run) => total + inner.measure(run.text, size, styleFor(run, style)), 0);
394
531
  },
395
- [scriptMeasurement]: {inner, profile},
532
+ [scriptMeasurement]: {inner, profile, options},
396
533
  };
397
534
  if (typeof inner.outlineBounds === "function") {
398
535
  textMeasurement.outlineBounds = (text, size, style) => {
@@ -411,7 +548,7 @@ export function createScriptFonts(profile = {}, measurement) {
411
548
  };
412
549
  }
413
550
  }
414
- return {profile, plan, runWidths, textMeasurement, rtl: isRtlProfile(profile)};
551
+ return {profile, plan, runWidths, textMeasurement, rtl: isRtlProfile(profile), get fallbacks() { return [...fallbackNotes.values()].map(note => ({...note, scripts: [...note.scripts], characters: [...note.characters]})); }};
415
552
  }
416
553
 
417
554
  /**
@@ -420,7 +557,7 @@ export function createScriptFonts(profile = {}, measurement) {
420
557
  * replacement). Pass the same wrapper to core pagination, the renderer and the
421
558
  * editor so their line breaks agree for non-Latin text.
422
559
  */
423
- export function createScriptTextMeasurement(measurement, profile) {
560
+ export function createScriptTextMeasurement(measurement, profile, options) {
424
561
  if (typeof measurement?.measure !== "function") throw new TypeError("createScriptTextMeasurement requires a textMeasurement with measure().");
425
- return createScriptFonts(profile, measurement).textMeasurement;
562
+ return createScriptFonts(profile, measurement, options).textMeasurement;
426
563
  }
package/dist/svg.d.ts CHANGED
@@ -32,6 +32,15 @@ export declare const engineDefaults: Readonly<{
32
32
  }>;
33
33
 
34
34
  export type RenderDiagnostic = LayoutDiagnostic | {
35
+ /** A face lacked glyphs for the text; a bundled fallback face draws them in the preview. A note, not an error. */
36
+ code: "font-glyph-fallback";
37
+ path?: string;
38
+ message: string;
39
+ fontFamily: string;
40
+ fallbackFamily: string;
41
+ scripts: string[];
42
+ characters: string[];
43
+ } | {
35
44
  code: "unsupported-pattern" | "date-needs-value" | "language-preview-unavailable" | "language-preview-unresolved" | "paragraph-direction-unavailable";
36
45
  path: string;
37
46
  message: string;
@@ -55,6 +64,8 @@ export type RenderDiagnostic = LayoutDiagnostic | {
55
64
 
56
65
  export interface RenderSvgOptions {
57
66
  textMeasurement?: TextMeasurement;
67
+ /** `"chain"` (default): a character the resolved face lacks is drawn with the first bundled face that has it, and reported as `font-glyph-fallback`. `"none"`: exact faces, a missing glyph raises `missing-glyph`. */
68
+ glyphFallback?: "chain" | "none";
58
69
  /** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */
59
70
  textRasterPadding?: number;
60
71
  embeddedFonts?: EmbeddedFont[];
package/dist/svg.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  // still loads; resolveScriptFonts ships with core FF-18.
9
9
  import * as opfCore from "@openpresentation/opf";
10
10
  import { createScriptFonts } from "./script-fonts.js";
11
+ import { disabledFeaturesStyle } from "./font-compatibility.js";
11
12
 
12
13
  export const packageName = "@openpresentation/opf-render";
13
14
 
@@ -485,6 +486,28 @@ function reportLanguageDiagnostic(context, diagnostic) {
485
486
  context.options.onDiagnostic?.(diagnostic);
486
487
  }
487
488
 
489
+ /**
490
+ * Report a glyph fallback once per resolvePresentation call, family pair and path (FF-19).
491
+ * It is a note, not an error: the face the font scheme resolved to lacks glyphs for the text,
492
+ * so a bundled face that has them draws those characters. The PPTX still names the chosen font.
493
+ */
494
+ function reportGlyphFallback(context, note) {
495
+ context.glyphFallbackDiagnostics ??= new Set();
496
+ const key = `${note.fontFamily}\u0000${note.fallbackFamily}\u0000${note.path ?? ""}`;
497
+ if (context.glyphFallbackDiagnostics.has(key)) return;
498
+ context.glyphFallbackDiagnostics.add(key);
499
+ context.options.onDiagnostic?.({
500
+ code: "font-glyph-fallback",
501
+ ...(note.path ? { path: note.path } : {}),
502
+ // The note is reported once per family pair and path: it names characters the face lacks, such as these, not every one.
503
+ message: `'${note.fontFamily}' lacks glyphs for characters such as ${note.characters.slice(0, 8).map(character => `U+${character.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`).join(", ")}; the preview draws those with '${note.fallbackFamily}'. The PPTX keeps the chosen font.`,
504
+ fontFamily: note.fontFamily,
505
+ fallbackFamily: note.fallbackFamily,
506
+ scripts: [...note.scripts],
507
+ characters: [...note.characters]
508
+ });
509
+ }
510
+
488
511
  function inferLayoutId(slide) {
489
512
  if (slide.layout) return slide.layout;
490
513
  if (slide.blocks?.length) return "blank";
@@ -609,7 +632,10 @@ function bindSlide(presentation, slide, layout, index, context) {
609
632
  const design = resolveDesign(presentation, slide, context, index);
610
633
  // Script fonts (FF-19): each text run is measured and drawn with its script
611
634
  // slot's face; the latin slot stays the design font scheme's family.
612
- const scriptFonts = createScriptFonts(scriptProfile(presentation, index, design, context), context.options.textMeasurement);
635
+ const scriptFonts = createScriptFonts(scriptProfile(presentation, index, design, context), context.options.textMeasurement, {
636
+ glyphFallback: context.options.glyphFallback,
637
+ onFallback: note => reportGlyphFallback(context, note)
638
+ });
613
639
  const textMeasurement = scriptFonts.textMeasurement ?? context.options.textMeasurement;
614
640
  for (const role of ["heading","body","code"]) design.fonts[role] = resolveTextStyle({fontFamily:design.fonts[role],fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
615
641
  const geometry = composeSlide(slide, { ...design.dimensions, layout, presentation, slideIndex: index, fonts: design.fonts, contentAlignment:design.contentAlignment, titleAlignment:design.titleAlignment, textRasterPadding:context.options.textRasterPadding, contentBox:design.contentBox, textMeasurement, date: context.options.date, socialPlatforms: socialPlatformRecords(context) });
@@ -674,8 +700,7 @@ function renderResolvedSlide(resolved, slideIndex, options) {
674
700
  const lang = script?.languageSource === "document" || script?.languageSource === "option" ? script.bcp47 : undefined;
675
701
  const { width, height } = bound.design.dimensions;
676
702
  const title = bound.slide.title ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
677
- const children = [
678
- renderEmbeddedFonts(options.embeddedFonts),
703
+ const content = [
679
704
  renderBackground(bound, width, height, options),
680
705
  renderSlideImage(bound, options),
681
706
  renderBranding(bound, resolved.presentation, width, height, options),
@@ -683,6 +708,7 @@ function renderResolvedSlide(resolved, slideIndex, options) {
683
708
  renderFurniture(bound, resolved.presentation, width, height, options, "header"),
684
709
  renderFurniture(bound, resolved.presentation, width, height, options, "footer")
685
710
  ].filter(Boolean);
711
+ const children = [renderEmbeddedFonts(embeddedFontsFor(options.embeddedFonts, content)), ...content].filter(Boolean);
686
712
 
687
713
  return tag(
688
714
  "svg",
@@ -1434,6 +1460,13 @@ function paragraphRtl(bound, text) {
1434
1460
  * a textLength spanning differently fonted tspans does not rasterize reliably;
1435
1461
  * the caller then drops its own textLength and anchors at the start.
1436
1462
  */
1463
+ // Nested script runs of a line drawn with a flagged face (Gelasio) opt back into default shaping,
1464
+ // unless the run itself uses a flagged family (tag() then adds that family's own style).
1465
+ function nestedReset(style, run) {
1466
+ const parent = disabledFeaturesStyle(style?.fontFamily);
1467
+ return parent && !disabledFeaturesStyle([run.stack ?? run.family].flat()[0]) ? RESET_POLICY_FEATURES : undefined;
1468
+ }
1469
+
1437
1470
  function scriptLine(text, style, bound, type, { rtl = false, placement, trace } = {}) {
1438
1471
  const value = String(text ?? "");
1439
1472
  const scripts = bound.scriptFonts;
@@ -1447,7 +1480,7 @@ function scriptLine(text, style, bound, type, { rtl = false, placement, trace }
1447
1480
  const widths = placement && placement.width > 0 ? scripts.runWidths(runs, placement.fontSize, style) : undefined;
1448
1481
  if (!widths) {
1449
1482
  return { content: isolate(runs.map(run => run.own ? escapeText(run.text)
1450
- : tag("tspan", { "font-family": fontStack(run.stack ?? run.family, type) }, escapeText(run.text))).join("")) };
1483
+ : tag("tspan", { "font-family": fontStack(run.stack ?? run.family, type), style: nestedReset(style, run) }, escapeText(run.text))).join("")) };
1451
1484
  }
1452
1485
  const total = widths.reduce((sum, width) => sum + width, 0), factor = total > 0 ? placement.width / total : 1;
1453
1486
  let advance = 0, offset = 0;
@@ -1460,6 +1493,7 @@ function scriptLine(text, style, bound, type, { rtl = false, placement, trace }
1460
1493
  x: stableNumber(placement.x + left),
1461
1494
  textLength: width > 0 ? stableNumber(width) : undefined, lengthAdjust: width > 0 ? "spacingAndGlyphs" : undefined,
1462
1495
  "font-family": run.own ? undefined : fontStack(run.family, type),
1496
+ style: run.own ? undefined : nestedReset(style, run),
1463
1497
  ...(trace ? trace(start, offset) : {})
1464
1498
  }, isolate(escapeText(run.text)));
1465
1499
  }).join("");
@@ -1472,6 +1506,49 @@ function segmentSpan(attrs, segment, text, style, bound, type, options) {
1472
1506
  return tag("tspan", { ...attrs, "font-family": scripted.family }, scripted.content);
1473
1507
  }
1474
1508
 
1509
+ // Faces flagged embed:"used" (the vendored open, Intos and script-pack faces) are embedded only when the slide's own markup draws
1510
+ // them: the family in a font-family list, at a font-weight and font-style some text of the slide takes (attributes are
1511
+ // inherited down the element tree, as in SVG). A used family none of whose faces matches a drawn weight and style keeps all
1512
+ // its faces, so the browser can always choose. Every other face (the npm packs) is embedded as before.
1513
+ function embeddedFontsFor(fonts = [], content) {
1514
+ if (!fonts.some(font => font?.embed === "used")) return fonts;
1515
+ const drawn = drawnFaces(content.join("\n"));
1516
+ const wanted = font => {
1517
+ const triples = drawn.get(String(font.family).toLowerCase());
1518
+ if (!triples) return false;
1519
+ const family = fonts.filter(other => other?.embed === "used" && String(other.family).toLowerCase() === String(font.family).toLowerCase());
1520
+ const matching = family.filter(other => triples.has(`${other.weight}|${other.italic ? "italic" : "normal"}`));
1521
+ return matching.length ? matching.includes(font) : true;
1522
+ };
1523
+ return fonts.filter(font => font?.embed !== "used" || wanted(font));
1524
+ }
1525
+
1526
+ const FONT_WEIGHT_KEYWORDS = { normal: "400", bold: "700" };
1527
+ /** family (lowercase) to the set of "weight|style" pairs the markup draws text in. */
1528
+ function drawnFaces(markup) {
1529
+ const drawn = new Map();
1530
+ const tokens = /<(\/?)([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>|<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|[^<]+/g;
1531
+ const stack = [{ families: [], weight: "400", style: "normal", text: false }];
1532
+ const attribute = (attributes, name) => { const found = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(attributes); return found ? found[1] ?? found[2] : undefined; };
1533
+ for (const [token, close, name, attributes, selfClose] of markup.matchAll(tokens)) {
1534
+ const top = stack.at(-1);
1535
+ if (name === undefined) {
1536
+ if (top.text && /\S/.test(token)) for (const family of top.families) { const set = drawn.get(family) ?? new Set(); set.add(`${top.weight}|${top.style}`); drawn.set(family, set); }
1537
+ continue;
1538
+ }
1539
+ if (close) { if (stack.length > 1) stack.pop(); continue; }
1540
+ const family = attribute(attributes, "font-family"), weight = attribute(attributes, "font-weight"), style = attribute(attributes, "font-style");
1541
+ const next = {
1542
+ families: family === undefined ? top.families : family.split(",").map(item => item.trim().replace(/^&quot;|&quot;$|^["']|["']$/g, "").toLowerCase()).filter(Boolean),
1543
+ weight: weight === undefined ? top.weight : FONT_WEIGHT_KEYWORDS[weight.trim()] ?? weight.trim(),
1544
+ style: style === undefined ? top.style : /italic|oblique/.test(style) ? "italic" : "normal",
1545
+ text: top.text || name === "text",
1546
+ };
1547
+ if (!selfClose) stack.push(next);
1548
+ }
1549
+ return drawn;
1550
+ }
1551
+
1475
1552
  function renderEmbeddedFonts(fonts = []) {
1476
1553
  if (!fonts.length) return "";
1477
1554
  const css = fonts.map(font => {
@@ -1541,7 +1618,7 @@ function renderRichLines(value,fit,box,bound,config) {
1541
1618
  if(!flow)return fragments.join('\n');
1542
1619
  const x=alignment==='right'?box.x+box.width:alignment==='center'?box.x+box.width/2:box.x;
1543
1620
  const first=line.fragments[0];
1544
- return tag('text',{x:stableNumber(x),y:stableNumber(box.y+line.baseline),'text-anchor':alignment==='right'?'end':alignment==='center'?'middle':'start','xml:space':'preserve','font-family':fontStack(first?.style.fontFamily??config.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(first?.fontSize??fit.fontSize),'font-weight':first?.style.fontWeight??config.fontWeight??400,'font-style':first?.style.italic?'italic':undefined},fragments.join(''));
1621
+ return tag('text',{x:stableNumber(x),y:stableNumber(box.y+line.baseline),'text-anchor':alignment==='right'?'end':alignment==='center'?'middle':'start','xml:space':'preserve','font-family':fontStack(first?.style.fontFamily??config.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(first?.fontSize??fit.fontSize),'font-weight':first?.style.fontWeight??config.fontWeight??400,'font-style':first?.style.italic?'italic':undefined,[NO_POLICY_FEATURES]:true},fragments.join(''));
1545
1622
  });
1546
1623
  let cursor=0;
1547
1624
  const whole=value.map(run=>typeof run==='string'?run:run.text).join('');
@@ -1694,7 +1771,22 @@ function escapeAttr(value) {
1694
1771
  return escapeText(value).replaceAll('"', "&quot;");
1695
1772
  }
1696
1773
 
1774
+ // FF-31: text drawn with a face whose features the font policy turns off (Gelasio for Georgia) says so, so
1775
+ // browsers do not ligate what the measurement did not. Only text or tspan elements that name such a family.
1776
+ // A flow line's outer text element, which only carries the first fragment's family, opts out: each of its
1777
+ // tspans names its own family and gets its own style, so other faces do not inherit ligatures:none.
1778
+ const NO_POLICY_FEATURES = Symbol("noPolicyFeatures");
1779
+ // Text in another script nested in a flagged run (scriptLine tspans) goes back to default shaping.
1780
+ const RESET_POLICY_FEATURES = "font-variant-ligatures:normal;font-feature-settings:normal";
1781
+ function withPolicyFeatures(name, attrs) {
1782
+ if (attrs[NO_POLICY_FEATURES]) return attrs;
1783
+ const family = (name === "text" || name === "tspan") && typeof attrs["font-family"] === "string" ? attrs["font-family"].split(",")[0].trim() : undefined;
1784
+ const features = family && disabledFeaturesStyle(family);
1785
+ return features ? { ...attrs, style: attrs.style ? `${attrs.style};${features}` : features } : attrs;
1786
+ }
1787
+
1697
1788
  function tag(name, attrs = {}, children = "") {
1789
+ attrs = withPolicyFeatures(name, attrs);
1698
1790
  const serializedAttrs = Object.keys(attrs)
1699
1791
  .filter((key) => attrs[key] !== undefined && attrs[key] !== null && attrs[key] !== false)
1700
1792
  .sort()
Binary file