@openpresentation/opf-render 0.11.8 → 0.12.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.
@@ -16,6 +16,9 @@
16
16
  // is reported as a note. `glyphFallback: "none"` (strict mode) keeps the exact
17
17
  // faces and lets a missing glyph raise `missing-glyph`.
18
18
 
19
+ import { fontPolicyFor } from "./font-policy.js";
20
+ import { SYMBOL_PLACEHOLDER, SYMBOL_SCRIPT, mapSymbolText, symbolEncodingFor, symbolPreviewFaces } from "./symbol-fonts.js";
21
+
19
22
  const freeze = value => { if (value && typeof value === "object") { for (const child of Object.values(value)) freeze(child); Object.freeze(value); } return value; };
20
23
 
21
24
  /** Designated open replacement families per ISO 15924 script, by style. */
@@ -37,8 +40,33 @@ export const SCRIPT_FONT_FAMILIES = freeze({
37
40
  Ethi: {sans: "Noto Sans Ethiopic"}, Armn: {sans: "Noto Sans Armenian"}, Geor: {sans: "Noto Sans Georgian"},
38
41
  Mong: {sans: "Noto Sans Mongolian"}, Thaa: {sans: "Noto Sans Thaana"}, Syrc: {sans: "Noto Sans Syriac"},
39
42
  Tibt: {serif: "Noto Serif Tibetan"},
43
+ // FF-45 special families. Zsye (emoji symbols) and Zmth (mathematical notation) are ISO 15924 keys for
44
+ // text, not scripts a character carries: emoji and math characters are Common, so they stay in their
45
+ // neighbours' run and reach these faces through the glyph fallback chain (emoji-presentation clusters
46
+ // take the emoji face first). The keys are last so the chain tries every real script face before them.
47
+ Zsye: {sans: "Noto Color Emoji"},
48
+ Zmth: {serif: "STIX Two Math", sans: "Noto Sans Math"},
40
49
  });
41
50
 
51
+ /**
52
+ * Emoji presentation (FF-45): a grapheme cluster that a browser or PowerPoint draws with the emoji font. Characters
53
+ * whose default presentation is emoji (Emoji_Presentation), any cluster with VS16 (U+FE0F), a skin tone modifier
54
+ * (U+1F3FB-1F3FF), a regional indicator (flags), a keycap (U+20E3) or an emoji tag (U+E0020-E007F). Digits, #, *
55
+ * and text-default pictographs (U+2764 heart, U+263A) count only with VS16; VS15 (U+FE0E) asks for text.
56
+ */
57
+ const emojiPresentation = /\p{Emoji_Presentation}|\p{Emoji_Modifier}|\p{Regional_Indicator}|️|⃣|[\u{E0020}-\u{E007F}]/u;
58
+ /** True when the text holds an emoji-presentation cluster (planned in the emoji face, in measurement and drawing). */
59
+ export function hasEmojiPresentation(text) {
60
+ return typeof text === "string" && emojiPresentation.test(text);
61
+ }
62
+ const textPresentation = /︎/u;
63
+ /** Characters that need a math face rather than a text face: math alphanumerics, letterlike math sets and the rarer operator blocks. */
64
+ const mathNotation = /[\u{1D400}-\u{1D7FF}⟀-⟯⦀-⧿⨀-⫿ℂℇℊ-ℓℕℙ-ℝℤℨℬℭℯ-ℱℳ-ℸ]/u;
65
+ /** True when the text holds mathematical notation that ordinary text faces lack (loads the math pack under `scripts: 'auto'`). */
66
+ export function hasMathNotation(text) {
67
+ return typeof text === "string" && mathNotation.test(text);
68
+ }
69
+
42
70
  const replacement = (script, substitutes, requestedFamilies) => requestedFamilies.map(requestedFamily => ({
43
71
  requestedFamily, script, substitutes, compatibility: "visual",
44
72
  note: "Designated open replacement for preview only; the PPTX keeps the requested family. Appearance and advances differ.",
@@ -85,8 +113,64 @@ export const SCRIPT_FONT_REPLACEMENTS = freeze([
85
113
  ...replacement("Thaa", sansThenSerif("Thaa"), ["MV Boli"]),
86
114
  ...replacement("Syrc", sansThenSerif("Syrc"), ["Estrangelo Edessa"]),
87
115
  ...replacement("Tibt", serifThenSans("Tibt"), ["Microsoft Himalaya"]),
116
+ // FF-45: Segoe UI Emoji previews with Noto Color Emoji (COLRv1 and SVG colour glyphs; Noto Emoji is the monochrome
117
+ // face the raster path draws), Cambria Math with STIX Two Math (serif math face with a MATH table; Noto Sans Math
118
+ // is the sans alternative). Both are visual: advances differ from the Microsoft fonts (see the policy rows).
119
+ ...replacement("Zsye", ["Noto Color Emoji", "Noto Emoji"], ["Segoe UI Emoji"]),
120
+ ...replacement("Zmth", ["STIX Two Math", "Noto Sans Math"], ["Cambria Math"]),
88
121
  ]);
89
122
 
123
+ /**
124
+ * RR-38: the preview size multiplier of a policy row (`replacement.sizeAdjust`) when `drawnFamily` is its replacement, the face the
125
+ * multiplier was measured on; otherwise undefined. `row` is a policy row, or the requested family name. A renderer scales the
126
+ * replacement's font size by it in measurement and drawing alike, so a replacement much wider than the real font (Noto Naskh Arabic
127
+ * for Arabic Typesetting: 1/0.64) draws lines of the length PowerPoint draws. The exported size and core's geometry never change.
128
+ */
129
+ export function sizeAdjustFor(row, drawnFamily) {
130
+ const policy = typeof row === "string" ? fontPolicyFor(row) : row;
131
+ const replacement = policy?.replacement, adjust = replacement?.sizeAdjust;
132
+ return typeof adjust === "number" && adjust !== 1 && typeof replacement.family === "string" && replacement.family.toLowerCase() === String(drawnFamily).toLowerCase() ? adjust : undefined;
133
+ }
134
+
135
+ /**
136
+ * RR-38: where PowerPoint puts the baseline of a line below its top, in em, for the policy row's replacement drawn as `drawnFamily`
137
+ * (`{lineAscent, lineAscentMixed}`: a line in the real font alone, and a line that also holds other fonts), or undefined. Measured in
138
+ * a native PowerPoint probe (Arabic Typesetting 0.70 and 0.78); core places baselines one em below the line top.
139
+ */
140
+ export function lineAscentFor(row, drawnFamily) {
141
+ const policy = typeof row === "string" ? fontPolicyFor(row) : row;
142
+ const replacement = policy?.replacement;
143
+ if (!sizeAdjustFor(policy, drawnFamily) || typeof replacement.lineAscent !== "number") return undefined;
144
+ return {lineAscent: replacement.lineAscent, lineAscentMixed: typeof replacement.lineAscentMixed === "number" ? replacement.lineAscentMixed : replacement.lineAscent};
145
+ }
146
+
147
+ /**
148
+ * RR-38: how far (em of the composed font size) the baseline of a line moves up so runs drawn in a policy replacement sit where PowerPoint
149
+ * puts the real font's baseline. `lines` is the planned runs of one line (one array per fragment). A line whose runs all carry a
150
+ * `lineAscent` takes it; a line that also holds other runs takes `lineAscentMixed`; a line with none is untouched (0).
151
+ */
152
+ export function baselineShift(...lines) {
153
+ const runs = lines.flat().filter(run => run.text !== "");
154
+ const adjusted = runs.filter(run => typeof run.lineAscent === "number");
155
+ if (!adjusted.length) return 0;
156
+ const ascent = adjusted.length === runs.length ? Math.min(...adjusted.map(run => run.lineAscent)) : Math.min(...adjusted.map(run => run.lineAscentMixed ?? run.lineAscent));
157
+ return Math.max(0, 1 - ascent);
158
+ }
159
+
160
+ /**
161
+ * RR-38: the font size a run with a policy size multiplier is measured and drawn at: `size x adjust` on a quarter-pixel grid. Chromium on
162
+ * Linux quantizes a fractional font size (a scan of Noto Naskh Arabic against fontkit: every multiple of 0.25 px within 0.09 px over
163
+ * 340 px lines, other fractions up to 0.27 px), so a size off that grid would break the 0.1 px browser-versus-measured advance gate.
164
+ * Measurement and drawing both go through this function, so they agree on the size whatever the grid.
165
+ */
166
+ export function adjustedFontSize(size, adjust) {
167
+ return typeof adjust === "number" && adjust > 0 ? Math.max(0.25, Math.round(size * adjust * 4) / 4) : size;
168
+ }
169
+
170
+ /** The emoji faces (FF-45): they draw emoji-presentation clusters only, never a run's Latin text or digits. */
171
+ export const EMOJI_FONT_FAMILIES = SCRIPT_FONT_REPLACEMENTS.find(rule => rule.script === "Zsye").substitutes;
172
+ const emojiFaces = new Set(EMOJI_FONT_FAMILIES);
173
+
90
174
  const eastAsianScripts = new Set(["Hani", "Hans", "Hant", "Hanb", "Jpan", "Kore", "Hang", "Jamo", "Hira", "Kana", "Hrkt", "Bopo", "Yiii"]);
91
175
  const complexScripts = new Set([
92
176
  "Arab", "Hebr", "Syrc", "Thaa", "Nkoo", "Adlm", "Rohg", "Mand", "Samr",
@@ -214,8 +298,11 @@ export function itemizeScripts(text, profile) {
214
298
  */
215
299
  export function scriptsOfText(text, profile) {
216
300
  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);
301
+ // FF-45: emoji-presentation clusters and mathematical notation are Common characters inside a Latin (or other) run; their
302
+ // faces are needed all the same, so they are reported as the pseudo-scripts Zsye and Zmth, which the emoji and math packs serve.
303
+ const special = [...(hasEmojiPresentation(source) ? ["Zsye"] : []), ...(hasMathNotation(source) ? ["Zmth"] : [])];
304
+ if (latinOnly.test(source) && !(eastAsianLanguage(profile) && eastAsianAmbiguous.test(source))) return special;
305
+ return [...itemizeScripts(source, profile).filter(run => run.script !== "Latn").map(run => run.script), ...special];
219
306
  }
220
307
 
221
308
  /**
@@ -268,8 +355,11 @@ export function glyphFallbackFamilies(character, profile = {}, serif = profile?.
268
355
  // otherwise every CJK face is tried in turn (Japanese, Simplified, Traditional, Korean).
269
356
  const own = script === "Hani" ? (hanKeys.includes(language) ? language : "Zzzz") : fontKey(script, character, profile);
270
357
  const keys = [own, language, "Latn", ...hanKeys, ...Object.keys(SCRIPT_FONT_FAMILIES)];
271
- return [...new Set([...new Set(keys)].flatMap(key => designatedFamilies(key, serif)))];
358
+ // 6. the open symbol faces (FF-45), when a host loaded them: mathematical operators, arrows, dingbats and pictographs.
359
+ return [...new Set([...new Set(keys)].flatMap(key => designatedFamilies(key, serif)).concat(SYMBOL_FALLBACK_FACES))];
272
360
  }
361
+ /** The symbol faces at the end of the glyph fallback chain, in order: pictographs, then the mathematical operators. */
362
+ const SYMBOL_FALLBACK_FACES = ["Noto Sans Symbols 2", "Noto Sans Symbols", "Noto Sans Math"];
273
363
 
274
364
  /**
275
365
  * Aliases from proprietary script fonts to the first loaded designated
@@ -433,22 +523,94 @@ export function createScriptFonts(profile = {}, measurement, options = {}) {
433
523
  const candidatesFor = (run, slots) => {
434
524
  const list = [run.role === "eastAsian" ? slots.eastAsian : run.role === "complexScript" ? slots.complexScript : slots.latin];
435
525
  if (run.script !== "Latn") {
526
+ // RR-38: a slot family whose policy replacement carries a size multiplier (Arabic Typesetting -> Noto Naskh Arabic, 0.64) names that
527
+ // replacement next: the multiplier was measured on it, and it is the face a registry resolves the family to, so a preview without a
528
+ // measurement provider draws the same face and size as a measured one (it drew the sans face, Noto Sans Arabic, at full size). Other
529
+ // proprietary script families keep the designated order below until they carry a measured multiplier of their own.
530
+ const scriptRule = list[0] ? SCRIPT_FONT_REPLACEMENTS.find(rule => rule.script === run.script && rule.requestedFamily.toLowerCase() === String(list[0]).toLowerCase()) : undefined;
531
+ if (scriptRule && sizeAdjustFor(list[0], scriptRule.substitutes[0])) list.push(scriptRule.substitutes[0]);
436
532
  if (slots.supplement && slots.supplement.script === run.script) list.push(slots.supplement.family);
437
533
  list.push(...designatedFamilies(run.script, serif));
438
534
  }
439
535
  return [...new Set(list.filter(Boolean))];
440
536
  };
441
537
 
538
+ /**
539
+ * FF-45: runs for text in a symbol-encoded family (Symbol, Wingdings, Wingdings 2, Wingdings 3, Webdings). Each
540
+ * character's code (U+F0xx private use or its Windows-1252 character) maps to its Unicode equivalent, drawn with the
541
+ * first loaded face of the family's preview chain that has it (`symbolPreviewFaces`), one run per glyph so the
542
+ * preview can place each at the verified font's advance (`run.symbol.advance`, em). A code with no equivalent, or
543
+ * whose equivalent no loaded face has, draws the placeholder U+25A1 and says so (`run.symbol.placeholder`, the
544
+ * fallback note carries `codes`). Characters that are not codes (a CJK letter, an emoji) are planned as ordinary text.
545
+ * Without a measurement provider every symbol run names the whole chain as its stack.
546
+ */
547
+ const planSymbols = (text, style, encoding) => {
548
+ const cacheKey = `${SYMBOL_SCRIPT}\u0000${encoding}\u0000${style.fontFamily}\u0000${style.fontWeight ?? 400}\u0000${!!style.italic}\u0000${textRole(style) ?? ""}\u0000${style.path ?? ""}\u0000${text}`;
549
+ const cached = plans.get(cacheKey);
550
+ if (cached) return cached;
551
+ const chain = symbolPreviewFaces(encoding);
552
+ const resolvedChain = measured ? chain.map(name => resolveName(name, style)).filter((name, index, all) => name && all.indexOf(name) === index) : chain;
553
+ const out = [], notes = new Map();
554
+ let foreign = "";
555
+ const flushForeign = () => {
556
+ if (!foreign) return;
557
+ for (const run of plan(foreign, {...style, symbolEncoding: null})) out.push(run);
558
+ foreign = "";
559
+ };
560
+ const note = (family, item, drawn, placeholder) => {
561
+ const key = `${family}\u0000${placeholder ? "placeholder" : "glyph"}`;
562
+ let entry = notes.get(key);
563
+ if (!entry) notes.set(key, entry = {fontFamily: encoding, fallbackFamily: family, scripts: [SYMBOL_SCRIPT], characters: [], codes: [], ...(placeholder ? {placeholder: drawn || "none"} : {}), ...(style.path ? {path: style.path} : {})});
564
+ const hex = item.code.toString(16).toUpperCase().padStart(2, "0");
565
+ if (!entry.codes.includes(hex) && entry.codes.length < 32) { entry.codes.push(hex); if (entry.characters.length < 16) entry.characters.push(item.source); }
566
+ };
567
+ for (const item of mapSymbolText(encoding, text)) {
568
+ if (item.code === null) { foreign += item.source; continue; }
569
+ flushForeign();
570
+ let drawn = item.unicode, family = null, placeholder = false;
571
+ if (!measured) { out.push({text: drawn ?? SYMBOL_PLACEHOLDER, family: chain[0], own: false, stack: chain, symbol: {family: encoding, code: item.code, source: item.source, advance: item.advance, ...(drawn === null ? {placeholder: true, reason: item.reason} : {})}}); continue; }
572
+ if (drawn !== null) family = resolvedChain.find(name => covers(name, drawn, style)) ?? null;
573
+ if (family === null) {
574
+ placeholder = true;
575
+ const reason = drawn === null ? item.reason : `no loaded face has U+${drawn.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}; load the symbol faces (scripts: ['${SYMBOL_SCRIPT}'])`;
576
+ // The placeholder is U+25A1 WHITE SQUARE from a loaded symbol face; without one, U+FFFD from the style's face (Noto Sans,
577
+ // Roboto and the office faces have it); a registry with neither draws nothing at the code's advance.
578
+ const own = resolveName(style.fontFamily, style) ?? style.fontFamily;
579
+ const choice = [[SYMBOL_PLACEHOLDER, resolvedChain.find(name => covers(name, SYMBOL_PLACEHOLDER, style))], ["�", [own, ...resolvedChain].find(name => covers(name, "�", style))]].find(([, name]) => name);
580
+ drawn = choice ? choice[0] : "";
581
+ family = choice ? choice[1] : own;
582
+ out.push({text: drawn, family, own: false, symbol: {family: encoding, code: item.code, source: item.source, advance: item.advance, placeholder: true, reason}});
583
+ } else out.push({text: drawn, family, own: false, symbol: {family: encoding, code: item.code, source: item.source, advance: item.advance}});
584
+ note(family, item, drawn, placeholder);
585
+ }
586
+ flushForeign();
587
+ for (const entry of notes.values()) {
588
+ const key = `${entry.fontFamily}\u0000${entry.fallbackFamily}\u0000${entry.placeholder ?? ""}\u0000${style.path ?? ""}`;
589
+ if (fallbackNotes.has(key)) continue;
590
+ fallbackNotes.set(key, entry);
591
+ options.onFallback?.(entry);
592
+ }
593
+ if (plans.size >= 4096) plans.delete(plans.keys().next().value);
594
+ plans.set(cacheKey, out);
595
+ return out;
596
+ };
597
+
442
598
  /** Font runs for text drawn in `style` (the resolved latin style). `own` runs use the style's family. */
443
599
  const plan = (text, style) => {
444
600
  text = String(text ?? "");
445
- if (latinOnly.test(text) && !(eastAsianText && eastAsianAmbiguous.test(text))) {
601
+ // A symbol-encoded family (resolved styles carry `symbolEncoding`; an unresolved style names the family itself) is mapped code by
602
+ // code; `symbolEncoding: null` is the planner's own request for ordinary runs (characters that are not codes).
603
+ const encoding = style.symbolEncoding === null ? undefined : style.symbolEncoding ?? symbolEncodingFor(style.fontFamily)?.family;
604
+ if (encoding) return text ? planSymbols(text, style, encoding) : [{text, family: style.fontFamily, own: true}];
605
+ // An emoji face (Noto Color Emoji for a Segoe UI Emoji run) draws emoji-presentation clusters only: its Latin text and digits
606
+ // are planned per cluster below, where they take a text face (FF-45).
607
+ const ownFace = measured && fallbackEnabled ? resolveName(style.fontFamily, style) : undefined;
608
+ if (latinOnly.test(text) && !(eastAsianText && eastAsianAmbiguous.test(text)) && !(ownFace && emojiFaces.has(ownFace))) {
446
609
  // Latin-group text is one run in the latin slot's face, unless that face lacks a character
447
610
  // (Cyrillic or Greek beyond a Latin replacement): then it is planned per character below.
448
611
  if (!fallbackEnabled || !nonAscii.test(text)) return [{text, family: style.fontFamily, own: true}];
449
612
  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}];
613
+ if (ownFace === null || covers(ownFace, text, style)) return [{text, family: style.fontFamily, own: true}];
452
614
  }
453
615
  const cacheKey = `${style.fontFamily}\u0000${style.fontWeight ?? 400}\u0000${!!style.italic}\u0000${textRole(style) ?? ""}\u0000${style.path ?? ""}\u0000${text}`;
454
616
  const cached = plans.get(cacheKey);
@@ -459,19 +621,32 @@ export function createScriptFonts(profile = {}, measurement, options = {}) {
459
621
  const global = [...new Set([...runs.flatMap(run => run.candidates), ...designatedFamilies(fontKey(profile.script ?? "Zzzz", text, profile), serif), ...designatedFamilies("Latn", serif)])];
460
622
  const unique = names => names.map(name => resolveName(name, style)).filter((name, index, all) => name && all.indexOf(name) === index);
461
623
  const out = [];
462
- const push = (text, family, stack) => {
624
+ const push = (text, family, stack, sizeAdjust, ascent) => {
463
625
  const own = family === style.fontFamily && (!stack || stack.length === 1);
464
626
  const last = out.at(-1);
465
- if (last && last.family === family && JSON.stringify(last.stack) === JSON.stringify(stack)) last.text += text;
466
- else out.push({text, family, own, ...(stack && !own ? {stack} : {})});
627
+ if (last && last.family === family && last.sizeAdjust === sizeAdjust && last.lineAscent === ascent?.lineAscent && JSON.stringify(last.stack) === JSON.stringify(stack)) last.text += text;
628
+ else out.push({text, family, own, ...(stack && !own ? {stack} : {}), ...(sizeAdjust ? {sizeAdjust} : {}), ...(ascent ?? {})});
467
629
  };
468
630
  for (const run of runs) {
469
- if (!measured) { push(run.text, run.candidates[0], run.candidates); continue; }
631
+ // RR-38: the slot family's replacement draws at the policy's size multiplier. Unmeasured, the replacement is the first face
632
+ // the stack names after the requested family; the requested name leaves the stack so a host that has the real font installed
633
+ // (a browser on Windows) cannot draw it at the reduced size.
634
+ const adjustOf = family => sizeAdjustFor(run.candidates[0], family);
635
+ const ascentOf = family => lineAscentFor(run.candidates[0], family);
636
+ if (!measured) {
637
+ const adjust = adjustOf(run.candidates[1]);
638
+ push(run.text, adjust ? run.candidates[1] : run.candidates[0], adjust ? run.candidates.slice(1) : run.candidates, adjust, ascentOf(run.candidates[1]));
639
+ continue;
640
+ }
470
641
  const chosen = resolveName(run.candidates[0], style);
471
- const whole = run.candidates.map(family => resolveName(family, style)).find(name => name && covers(name, run.text, style));
642
+ // FF-45: a run with an emoji-presentation cluster, or whose face is an emoji face, is always planned per cluster: VS16 and
643
+ // emoji-default clusters take the emoji face even when the text face has a monochrome glyph for the base character, and the
644
+ // emoji face never draws the run's Latin text or digits.
645
+ const emojiRun = emojiFaces.has(chosen) || hasEmojiPresentation(run.text);
646
+ const whole = emojiRun ? undefined : run.candidates.map(family => resolveName(family, style)).find(name => name && covers(name, run.text, style));
472
647
  if (whole) {
473
648
  if (whole !== chosen) noteFallback(pending, chosen, whole, [...run.text].filter(character => !covers(chosen ?? whole, character, style)));
474
- push(run.text, whole);
649
+ push(run.text, whole, undefined, adjustOf(whole), ascentOf(whole));
475
650
  continue;
476
651
  }
477
652
  // No single candidate covers the run. Split it into grapheme clusters (a base character with
@@ -486,7 +661,14 @@ export function createScriptFonts(profile = {}, measurement, options = {}) {
486
661
  let sticky = null;
487
662
  // 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
663
  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));
664
+ // An emoji-presentation cluster tries the emoji faces first (Noto Color Emoji, loaded with the emoji pack), then the text
665
+ // faces; any other cluster tries them last, so a run's digits and Latin text (which the emoji face draws emoji-sized) take a
666
+ // text face, while a text-presentation symbol only the emoji face has (U+2764 with VS15) is still drawn rather than missing.
667
+ const choose = (subject, sample, emoji = false) => {
668
+ const names = unique([run.candidates[0], ...(sticky ? [sticky] : []), ...run.candidates, ...global, ...(fallbackEnabled ? chainFor(sample) : [])]);
669
+ const emojiNames = names.filter(name => emojiFaces.has(name)), textNames = names.filter(name => !emojiFaces.has(name));
670
+ return (emoji ? [...emojiNames, ...textNames] : [...textNames, ...emojiNames]).find(name => covers(name, subject, style));
671
+ };
490
672
  const segments = [];
491
673
  for (const character of run.text) {
492
674
  const script = scriptOfCharacter(character), last = segments.at(-1)?.clusters.at(-1);
@@ -504,10 +686,11 @@ export function createScriptFonts(profile = {}, measurement, options = {}) {
504
686
  const strong = segment.clusters.filter(cluster => !cluster.weak);
505
687
  const face = strong.length ? choose(strong.map(cluster => cluster.text).join(""), strong[0].base) : undefined;
506
688
  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;
689
+ const emoji = hasEmojiPresentation(cluster.text);
690
+ const family = !emoji && face && covers(face, cluster.text, style) ? face : choose(cluster.text, cluster.base, emoji) ?? choose(cluster.base, cluster.base, emoji) ?? primary;
508
691
  if (family !== primary && family !== chosen) sticky = family;
509
692
  if (chosen !== null && family !== chosen && covers(family, cluster.text, style)) noteFallback(pending, chosen, family, missingFrom(chosen, cluster.text));
510
- push(cluster.text, family);
693
+ push(cluster.text, family, undefined, adjustOf(family), ascentOf(family));
511
694
  }
512
695
  }
513
696
  }
@@ -517,32 +700,40 @@ export function createScriptFonts(profile = {}, measurement, options = {}) {
517
700
  return out;
518
701
  };
519
702
 
520
- const styleFor = (run, style) => styled(run.own ? style : {...style, fontFamily: run.family});
521
- /** Measured advance of each planned run, in order; undefined without a measurement provider. */
522
- const runWidths = (runs, size, style) => measured ? runs.map(run => inner.measure(run.text, size, styleFor(run, style))) : undefined;
703
+ // A run drawn for another family drops the encoding: the open face's own glyphs are measured.
704
+ const styleFor = (run, style) => styled(run.own ? style : {...style, fontFamily: run.family, symbolEncoding: null});
705
+ /**
706
+ * The advance a planned run occupies: the open face's measured advance, except that a symbol run (FF-45) occupies the
707
+ * verified symbol font's advance for its code, so lines break and bullets sit where PowerPoint puts them; `natural`
708
+ * asks for the open face's advance of that run instead (the SVG compresses a wider glyph to fit, never stretches one).
709
+ */
710
+ const runWidth = (run, size, style, natural = false) => run.symbol && !natural && run.symbol.advance !== null ? run.symbol.advance * size : inner.measure(run.text, adjustedFontSize(size, run.sizeAdjust), styleFor(run, style));
711
+ /** Advance of each planned run, in order (see `runWidth`); undefined without a measurement provider. */
712
+ const runWidths = (runs, size, style, {natural = false} = {}) => measured ? runs.map(run => runWidth(run, size, style, natural)) : undefined;
523
713
  let textMeasurement;
524
714
  if (measured) {
525
715
  textMeasurement = {
526
716
  ...inner,
527
717
  measure(text, size, style) {
528
718
  const runs = plan(text, style);
529
- if (runs.length === 1 && runs[0].own) return inner.measure(text, size, styled(style));
530
- return runs.reduce((total, run) => total + inner.measure(run.text, size, styleFor(run, style)), 0);
719
+ // RR-38: a run drawn at the policy's size multiplier is not the style's own face at its own size: it takes the run path below.
720
+ if (runs.length === 1 && runs[0].own && !runs[0].sizeAdjust) return inner.measure(text, size, styled(style));
721
+ return runs.reduce((total, run) => total + runWidth(run, size, style), 0);
531
722
  },
532
723
  [scriptMeasurement]: {inner, profile, options},
533
724
  };
534
725
  if (typeof inner.outlineBounds === "function") {
535
726
  textMeasurement.outlineBounds = (text, size, style) => {
536
727
  const runs = plan(text, style);
537
- if (runs.length === 1 && runs[0].own) return inner.outlineBounds(text, size, styled(style));
728
+ if (runs.length === 1 && runs[0].own && !runs[0].sizeAdjust) return inner.outlineBounds(text, size, styled(style));
538
729
  let x = 0, box = null;
539
730
  for (const run of runs) {
540
- const runStyle = styleFor(run, style), bounds = inner.outlineBounds(run.text, size, runStyle);
731
+ const runStyle = styleFor(run, style), bounds = inner.outlineBounds(run.text, adjustedFontSize(size, run.sizeAdjust), runStyle);
541
732
  if (bounds) {
542
733
  const left = x + bounds.x, top = bounds.y, right = left + bounds.width, bottom = top + bounds.height;
543
734
  box = box ? {left: Math.min(box.left, left), top: Math.min(box.top, top), right: Math.max(box.right, right), bottom: Math.max(box.bottom, bottom)} : {left, top, right, bottom};
544
735
  }
545
- x += inner.measure(run.text, size, runStyle);
736
+ x += runWidth(run, size, style);
546
737
  }
547
738
  return box && {x: box.left, y: box.top, width: box.right - box.left, height: box.bottom - box.top};
548
739
  };
package/dist/sha256.js ADDED
@@ -0,0 +1,47 @@
1
+ // SHA-256 in plain JavaScript (FIPS 180-4), so the vector PDF writer needs neither node:crypto nor Web Crypto (which is
2
+ // asynchronous) and runs the same in Node and in a browser. Returns the 32-byte digest.
3
+
4
+ const K = new Uint32Array([
5
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
6
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
7
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
8
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
9
+ ]);
10
+
11
+ export function sha256Bytes(input) {
12
+ const data = input instanceof Uint8Array ? input : new Uint8Array(input);
13
+ const length = data.length;
14
+ const padded = new Uint8Array(((length + 9 + 63) >> 6) << 6);
15
+ padded.set(data);
16
+ padded[length] = 0x80;
17
+ const view = new DataView(padded.buffer);
18
+ view.setUint32(padded.length - 8, Math.floor(length / 0x20000000), false);
19
+ view.setUint32(padded.length - 4, (length << 3) >>> 0, false);
20
+ const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
21
+ const w = new Uint32Array(64);
22
+ for (let offset = 0; offset < padded.length; offset += 64) {
23
+ for (let index = 0; index < 16; index++) w[index] = view.getUint32(offset + index * 4, false);
24
+ for (let index = 16; index < 64; index++) {
25
+ const a = w[index - 15], b = w[index - 2];
26
+ const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3);
27
+ const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10);
28
+ w[index] = (w[index - 16] + s0 + w[index - 7] + s1) | 0;
29
+ }
30
+ let [a, b, c, d, e, f, g, hh] = h;
31
+ for (let index = 0; index < 64; index++) {
32
+ const s1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
33
+ const ch = (e & f) ^ (~e & g);
34
+ const t1 = (hh + s1 + ch + K[index] + w[index]) | 0;
35
+ const s0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
36
+ const maj = (a & b) ^ (a & c) ^ (b & c);
37
+ const t2 = (s0 + maj) | 0;
38
+ hh = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0;
39
+ }
40
+ h[0] = (h[0] + a) | 0; h[1] = (h[1] + b) | 0; h[2] = (h[2] + c) | 0; h[3] = (h[3] + d) | 0;
41
+ h[4] = (h[4] + e) | 0; h[5] = (h[5] + f) | 0; h[6] = (h[6] + g) | 0; h[7] = (h[7] + hh) | 0;
42
+ }
43
+ const out = new Uint8Array(32);
44
+ const outView = new DataView(out.buffer);
45
+ for (let index = 0; index < 8; index++) outView.setUint32(index * 4, h[index], false);
46
+ return out;
47
+ }
package/dist/svg.d.ts CHANGED
@@ -41,7 +41,7 @@ export type RenderDiagnostic = LayoutDiagnostic | {
41
41
  scripts: string[];
42
42
  characters: string[];
43
43
  } | {
44
- code: "unsupported-pattern" | "date-needs-value" | "language-preview-unavailable" | "language-preview-unresolved" | "paragraph-direction-unavailable";
44
+ code: "unsupported-pattern" | "variable-example-used" | "date-needs-value" | "language-preview-unavailable" | "language-preview-unresolved" | "paragraph-direction-unavailable";
45
45
  path: string;
46
46
  message: string;
47
47
  } | {
@@ -74,6 +74,14 @@ export interface RenderSvgOptions {
74
74
  onDiagnostic?: (diagnostic: RenderDiagnostic) => void;
75
75
  /** When false, skip AJV boundary validation. Default true. */
76
76
  validate?: boolean;
77
+ /**
78
+ * Values for the deck's template variables, keyed by variable id (core `resolveVariables`). A deck that uses
79
+ * content variables, or is marked `template: true`, is resolved to a concrete deck first. A template previews with
80
+ * each unfilled variable's example (reported as `variable-example-used`); a normal deck with an unfilled required
81
+ * variable throws `unfilled-variables`. `false` draws the document as authored, with `{{id}}` tokens and
82
+ * `var:id` references visible and nothing resolved: the view an editor uses while the template itself is edited.
83
+ */
84
+ variables?: Record<string, unknown> | false;
77
85
  slideIndex?: number;
78
86
  /**
79
87
  * Today's calendar date (ISO YYYY-MM-DD) for `date: true` header/footer furniture. The renderer
@@ -82,6 +90,11 @@ export interface RenderSvgOptions {
82
90
  date?: string;
83
91
  trace?: boolean;
84
92
  catalogs?: Record<string, { records?: unknown[] } | unknown[]>;
93
+ /**
94
+ * Records for catalog sources the host has already fetched, keyed by the source string a document's
95
+ * `catalogs.<kind>.source` names (a single source or an ordered array; first match wins, the bundled
96
+ * default catalog is appended). The renderer never fetches a source itself.
97
+ */
85
98
  catalogSources?: Record<string, { records?: unknown[] } | unknown[]>;
86
99
  }
87
100