explico 0.8.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 (230) hide show
  1. package/CHANGELOG.md +1239 -1
  2. package/README.md +60 -15
  3. package/dist/compiler/astro.d.ts +6 -1
  4. package/dist/compiler/cli.d.ts +46 -2
  5. package/dist/compiler/cli.js +70 -4
  6. package/dist/compiler/collections.d.ts +35 -2
  7. package/dist/compiler/collections.js +68 -5
  8. package/dist/compiler/course-config.d.ts +3 -194
  9. package/dist/compiler/course-config.js +78 -5
  10. package/dist/compiler/exports/standalone-html.d.ts +6 -1
  11. package/dist/compiler/knowledge-version.js +1 -1
  12. package/dist/compiler/lesson-derivation.d.ts +27 -10
  13. package/dist/compiler/lesson-derivation.js +23 -8
  14. package/dist/compiler/logging.d.ts +4 -1
  15. package/dist/compiler/logging.js +4 -1
  16. package/dist/compiler/manifest.d.ts +204 -31
  17. package/dist/compiler/manifest.js +270 -55
  18. package/dist/compiler/resolution-report.d.ts +6 -1
  19. package/dist/course-config-Dsnri9Tk.d.ts +464 -0
  20. package/dist/reference/base-accents.d.mts +26 -0
  21. package/dist/reference/base-accents.mjs +31 -0
  22. package/dist/reference/base-roman-labels.d.mts +2 -2
  23. package/dist/reference/base-roman-labels.mjs +2 -2
  24. package/dist/reference/consistency.d.ts +14 -2
  25. package/dist/reference/consistency.js +108 -4
  26. package/dist/reference/delimiter-size.d.ts +3 -2
  27. package/dist/reference/diagrams-validate.d.ts +7 -2
  28. package/dist/reference/diagrams-validate.js +9 -1
  29. package/dist/reference/equations-validate.d.ts +8 -5
  30. package/dist/reference/equations-validate.js +1 -0
  31. package/dist/reference/equations.d.mts +60 -31
  32. package/dist/reference/equations.mjs +83 -43
  33. package/dist/reference/examples-validate.d.ts +31 -0
  34. package/dist/reference/examples-validate.js +48 -0
  35. package/dist/reference/figures-validate.d.ts +1 -1
  36. package/dist/reference/figures-validate.js +4 -1
  37. package/dist/reference/folds-validate.d.ts +33 -0
  38. package/dist/reference/folds-validate.js +32 -0
  39. package/dist/reference/gate-abbreviations.d.ts +3 -1
  40. package/dist/reference/gate-abbreviations.js +1 -0
  41. package/dist/reference/gate-course-rules.d.ts +32 -0
  42. package/dist/reference/gate-course-rules.js +85 -0
  43. package/dist/reference/gate-heading-length.d.ts +5 -2
  44. package/dist/reference/gate-heading-length.js +45 -21
  45. package/dist/reference/gate-heading-structure.d.ts +41 -0
  46. package/dist/reference/gate-heading-structure.js +113 -0
  47. package/dist/reference/gate-math-punctuation.d.ts +71 -0
  48. package/dist/reference/gate-math-punctuation.js +251 -0
  49. package/dist/reference/gate-math.d.ts +22 -6
  50. package/dist/reference/gate-math.js +42 -8
  51. package/dist/reference/gate-part-pages.d.ts +50 -0
  52. package/dist/reference/gate-part-pages.js +118 -0
  53. package/dist/reference/gate-prose-notation.d.ts +21 -11
  54. package/dist/reference/gate-prose-notation.js +25 -7
  55. package/dist/reference/katex-delimiters.mjs +20 -7
  56. package/dist/reference/keyed-float-validate.d.ts +27 -10
  57. package/dist/reference/keyed-float-validate.js +15 -1
  58. package/dist/reference/keyed-jsx-floats.d.mts +11 -5
  59. package/dist/reference/keyed-jsx-floats.mjs +73 -20
  60. package/dist/reference/knowledge-checks-validate.d.ts +1 -1
  61. package/dist/reference/knowledge-checks.d.mts +3 -1
  62. package/dist/reference/knowledge-checks.mjs +9 -4
  63. package/dist/reference/lab-math-scope.d.ts +1 -0
  64. package/dist/reference/lab-math-slot.d.ts +34 -0
  65. package/dist/reference/lab-math-slot.js +6 -0
  66. package/dist/reference/math-glyphs.d.mts +80 -4
  67. package/dist/reference/math-glyphs.mjs +572 -37
  68. package/dist/reference/page-notation.d.ts +6 -1
  69. package/dist/reference/raw-floats-validate.js +1 -1
  70. package/dist/reference/raw-floats.mjs +1 -1
  71. package/dist/reference/rehype-heading-anchors.d.mts +14 -0
  72. package/dist/reference/rehype-heading-anchors.mjs +116 -17
  73. package/dist/reference/rehype-table-header-labels.d.mts +6 -1
  74. package/dist/reference/rehype-table-header-labels.mjs +21 -3
  75. package/dist/reference/remark-asides.d.mts +3 -0
  76. package/dist/reference/remark-asides.mjs +180 -0
  77. package/dist/reference/remark-memo.d.mts +3 -1
  78. package/dist/reference/remark-memo.mjs +42 -11
  79. package/dist/reference/remark-notation.d.mts +60 -10
  80. package/dist/reference/remark-notation.mjs +387 -36
  81. package/dist/reference/render-lab-math.d.ts +4 -26
  82. package/dist/reference/render-lab-math.js +4 -4
  83. package/dist/reference/sections-validate.d.ts +27 -0
  84. package/dist/reference/sections-validate.js +41 -0
  85. package/dist/reference/sections.d.mts +97 -0
  86. package/dist/reference/sections.mjs +306 -0
  87. package/dist/reference/tables-validate.d.ts +1 -1
  88. package/dist/reference/types.d.ts +17 -1
  89. package/dist/reference/unit-words.d.ts +37 -0
  90. package/dist/reference/unit-words.js +170 -0
  91. package/dist/rules/catalog.d.ts +176 -0
  92. package/dist/rules/catalog.js +983 -0
  93. package/dist/rules/course-rules.d.ts +115 -0
  94. package/dist/rules/course-rules.js +292 -0
  95. package/dist/rules/course-rules.schema.json +138 -0
  96. package/dist/rules/in-effect.d.ts +53 -0
  97. package/dist/rules/in-effect.js +142 -0
  98. package/dist/rules/rules.schema.json +1072 -0
  99. package/dist/rules/settings.d.ts +3 -0
  100. package/dist/rules/settings.js +290 -0
  101. package/dist/rules/waivers.d.ts +53 -0
  102. package/dist/rules/waivers.js +132 -0
  103. package/dist/session/PreferenceStore.d.ts +5 -0
  104. package/dist/session/PreferenceStore.js +9 -0
  105. package/docs/VERSIONING.md +2 -2
  106. package/docs/authoring/README.md +232 -40
  107. package/docs/authoring/agents.md +55 -9
  108. package/docs/authoring/components.md +178 -22
  109. package/docs/authoring/migrating.md +1096 -2
  110. package/docs/authoring/notation.md +576 -322
  111. package/docs/authoring/prose.md +551 -0
  112. package/docs/authoring/retired.md +35 -0
  113. package/docs/authoring/structure-rules.md +422 -40
  114. package/docs/authoring/structure.md +445 -62
  115. package/docs/authoring/versioning.md +20 -28
  116. package/docs/authoring/visualizations.md +142 -20
  117. package/package.json +2 -2
  118. package/src/compiler/cli.ts +116 -6
  119. package/src/compiler/collections.ts +111 -7
  120. package/src/compiler/course-config.ts +273 -34
  121. package/src/compiler/knowledge-version.ts +1 -1
  122. package/src/compiler/lesson-derivation.ts +54 -15
  123. package/src/compiler/logging.ts +8 -2
  124. package/src/compiler/manifest.ts +573 -104
  125. package/src/components/BlockHeading.astro +113 -0
  126. package/src/components/CourseContents.astro +221 -0
  127. package/src/components/CurriculumMap.astro +5 -6
  128. package/src/components/FloatIndex.astro +200 -0
  129. package/src/components/HoverPanel.astro +431 -79
  130. package/src/components/PartBridge.astro +27 -0
  131. package/src/components/PartOverview.astro +401 -0
  132. package/src/components/PartScope.astro +27 -0
  133. package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
  134. package/src/components/assessments/AssessmentRunner.tsx +8 -5
  135. package/src/components/assessments/AssessmentSet.astro +23 -26
  136. package/src/components/block-heading.ts +146 -0
  137. package/src/components/card-arrow.ts +243 -0
  138. package/src/components/card-drag.ts +631 -0
  139. package/src/components/card-jump.ts +45 -0
  140. package/src/components/card-manager.ts +1589 -0
  141. package/src/components/card-memory.ts +177 -0
  142. package/src/components/card-placement.ts +504 -0
  143. package/src/components/card-side.ts +336 -0
  144. package/src/components/card-travel.ts +197 -0
  145. package/src/components/citation/CitationLayer.astro +77 -76
  146. package/src/components/diagram/Diagram.astro +161 -316
  147. package/src/components/disclosure/course-disclosure.ts +24 -0
  148. package/src/components/disclosure/hover-preview.ts +122 -42
  149. package/src/components/disclosure/persisted-disclosure.ts +3 -3
  150. package/src/components/disclosure/print-as-shown.ts +252 -0
  151. package/src/components/examples/CompactExample.astro +3 -1
  152. package/src/components/examples/CompactExamples.astro +101 -94
  153. package/src/components/figure/Figure.astro +174 -274
  154. package/src/components/figure/FloatCaption.astro +87 -0
  155. package/src/components/figure/figure-state.ts +58 -0
  156. package/src/components/figure/figure-tools.ts +87 -0
  157. package/src/components/float-name.ts +101 -0
  158. package/src/components/fold/Fold.astro +125 -0
  159. package/src/components/fold/fold-id.ts +40 -0
  160. package/src/components/memo/Memo.astro +162 -231
  161. package/src/components/memo/ShortNote.astro +6 -6
  162. package/src/components/notation/NotationFormula.astro +5 -1
  163. package/src/components/notation/NotationGlossary.astro +3 -4
  164. package/src/components/notation/NotationLayer.astro +169 -162
  165. package/src/components/notation/NotationMath.astro +10 -1
  166. package/src/components/reference/FloatRefLayer.astro +485 -423
  167. package/src/components/starlight/AboutPanel.astro +5 -4
  168. package/src/components/starlight/CollapsibleSections.astro +207 -93
  169. package/src/components/starlight/DiagramEnhancer.astro +15 -138
  170. package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
  171. package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
  172. package/src/components/starlight/LayoutHeader.astro +298 -19
  173. package/src/components/starlight/LayoutPageSidebar.astro +99 -12
  174. package/src/components/starlight/LayoutPageTitle.astro +120 -0
  175. package/src/components/starlight/LayoutSidebar.astro +229 -11
  176. package/src/components/starlight/LessonExportActions.astro +53 -72
  177. package/src/components/starlight/LessonFooter.astro +26 -4
  178. package/src/components/starlight/section-parts.ts +15 -2
  179. package/src/components/starlight/section-summary.ts +181 -0
  180. package/src/components/table/Table.astro +95 -76
  181. package/src/components/table/TableOverflowExpander.astro +65 -28
  182. package/src/content-config.ts +40 -4
  183. package/src/reference/base-accents.mjs +31 -0
  184. package/src/reference/base-roman-labels.mjs +2 -2
  185. package/src/reference/consistency.ts +176 -5
  186. package/src/reference/delimiter-size.ts +3 -2
  187. package/src/reference/diagrams-validate.ts +17 -1
  188. package/src/reference/equations-validate.ts +10 -5
  189. package/src/reference/equations.mjs +83 -43
  190. package/src/reference/examples-validate.ts +97 -0
  191. package/src/reference/figures-validate.ts +6 -1
  192. package/src/reference/folds-validate.ts +65 -0
  193. package/src/reference/gate-abbreviations.ts +1 -0
  194. package/src/reference/gate-course-rules.ts +134 -0
  195. package/src/reference/gate-heading-length.ts +66 -35
  196. package/src/reference/gate-heading-structure.ts +193 -0
  197. package/src/reference/gate-math-punctuation.ts +381 -0
  198. package/src/reference/gate-math.ts +69 -10
  199. package/src/reference/gate-part-pages.ts +217 -0
  200. package/src/reference/gate-prose-notation.ts +59 -12
  201. package/src/reference/katex-delimiters.mjs +20 -7
  202. package/src/reference/keyed-float-validate.ts +45 -11
  203. package/src/reference/keyed-jsx-floats.mjs +73 -20
  204. package/src/reference/knowledge-checks-validate.ts +1 -1
  205. package/src/reference/knowledge-checks.mjs +9 -4
  206. package/src/reference/lab-math-slot.ts +36 -0
  207. package/src/reference/math-glyphs.mjs +572 -37
  208. package/src/reference/raw-floats-validate.ts +1 -1
  209. package/src/reference/raw-floats.mjs +1 -1
  210. package/src/reference/rehype-heading-anchors.mjs +116 -17
  211. package/src/reference/rehype-table-header-labels.mjs +21 -3
  212. package/src/reference/remark-asides.mjs +180 -0
  213. package/src/reference/remark-memo.mjs +42 -11
  214. package/src/reference/remark-notation.mjs +387 -36
  215. package/src/reference/render-lab-math.ts +16 -31
  216. package/src/reference/sections-validate.ts +81 -0
  217. package/src/reference/sections.mjs +306 -0
  218. package/src/reference/tables-validate.ts +2 -1
  219. package/src/reference/types.ts +17 -0
  220. package/src/reference/unit-words.ts +233 -0
  221. package/src/rules/catalog.ts +1298 -0
  222. package/src/rules/course-rules.ts +456 -0
  223. package/src/rules/in-effect.ts +235 -0
  224. package/src/rules/settings.ts +448 -0
  225. package/src/rules/waivers.ts +249 -0
  226. package/src/session/PreferenceStore.ts +15 -0
  227. package/src/styles/global.css +1471 -163
  228. package/src/styles/surfaces.css +182 -0
  229. package/src/components/hover-panel.ts +0 -339
  230. package/src/components/popup-stack.ts +0 -345
@@ -46,7 +46,8 @@ const IGNORED_MATH_TOKENS = new Set([
46
46
  ]);
47
47
 
48
48
  // KaTeX font ids mapped back to their LaTeX command, so a single-letter font
49
- // node can be reconstructed as its source token (`\mathbb{N}`).
49
+ // node can be reconstructed as its source token (`\mathbb{N}`). `\bm{x}`
50
+ // parses as `boldsymbol`, so it is rebuilt as `\boldsymbol{x}`.
50
51
  const FONT_COMMANDS = new Map([
51
52
  ['mathbb', '\\mathbb'],
52
53
  ['mathcal', '\\mathcal'],
@@ -55,8 +56,73 @@ const FONT_COMMANDS = new Map([
55
56
  ['mathrm', '\\mathrm'],
56
57
  ['mathbf', '\\mathbf'],
57
58
  ['mathit', '\\mathit'],
59
+ ['mathnormal', '\\mathnormal'],
60
+ ['mathsf', '\\mathsf'],
61
+ ['mathtt', '\\mathtt'],
62
+ ['boldsymbol', '\\boldsymbol'],
58
63
  ]);
59
64
 
65
+ // A font that makes a different symbol: `\mathcal{N}` is not `N`, and
66
+ // `\mathbf{x}` is not `x`, so a letter in one of these never borrows the
67
+ // plain letter's card (rule N4). `\mathit` / `\mathnormal` render the plain
68
+ // italic letter and fall back as before; `\mathrm` is a roman label, skipped
69
+ // whole (rule N10).
70
+ const DISTINCT_FONTS = new Set([
71
+ 'mathbf',
72
+ 'boldsymbol',
73
+ 'mathcal',
74
+ 'mathscr',
75
+ 'mathfrak',
76
+ 'mathbb',
77
+ 'mathsf',
78
+ 'mathtt',
79
+ ]);
80
+
81
+ // Every math-mode accent KaTeX 0.16 parses, over and under (`accent` /
82
+ // `accentUnder` nodes, named by their `label`). Text-mode accents (`\'`, `\^`,
83
+ // …) fail the strict parse, so they never reach the resolver.
84
+ const ACCENT_LABELS = [
85
+ '\\acute',
86
+ '\\grave',
87
+ '\\ddot',
88
+ '\\tilde',
89
+ '\\bar',
90
+ '\\breve',
91
+ '\\check',
92
+ '\\hat',
93
+ '\\vec',
94
+ '\\dot',
95
+ '\\mathring',
96
+ '\\widecheck',
97
+ '\\widehat',
98
+ '\\widetilde',
99
+ '\\overrightarrow',
100
+ '\\overleftarrow',
101
+ '\\Overrightarrow',
102
+ '\\overleftrightarrow',
103
+ '\\overgroup',
104
+ '\\overlinesegment',
105
+ '\\overleftharpoon',
106
+ '\\overrightharpoon',
107
+ '\\underleftarrow',
108
+ '\\underrightarrow',
109
+ '\\underleftrightarrow',
110
+ '\\undergroup',
111
+ '\\underlinesegment',
112
+ '\\utilde',
113
+ ];
114
+
115
+ /**
116
+ * Every decoration rule N22 treats as an operator, by canonical name: each
117
+ * accent (`\hat`, `\widehat`, … — `\widehat` is not `\hat`), `\overline`,
118
+ * `\underline`, and the superscript marks `'` (any run of primes), `*` (`*`
119
+ * or `\ast`) and `\dagger`. `^\top`, `^{-1}`, powers, subscripts and fonts
120
+ * are not decorations.
121
+ */
122
+ export const DECORATION_NAMES = Object.freeze(
123
+ new Set([...ACCENT_LABELS, '\\overline', '\\underline', "'", '*', '\\dagger']),
124
+ );
125
+
60
126
  function fontBodyText(value) {
61
127
  if (Array.isArray(value)) {
62
128
  const parts = value.map(fontBodyText);
@@ -451,6 +517,9 @@ function isIdentifierNode(node) {
451
517
  if (node.type !== 'mathord' && node.type !== 'textord') return false;
452
518
  if (typeof node.text !== 'string') return false;
453
519
  if (/^[0-9.,]+$/u.test(node.text)) return false;
520
+ // A prime is a mark on the symbol before it (rule N22), never a symbol of
521
+ // its own — whether typed `'` or spelled `^\prime`.
522
+ if (node.text === '\\prime') return false;
454
523
  return /^(?:\p{L}|\\[A-Za-z]+)$/u.test(node.text);
455
524
  }
456
525
 
@@ -461,16 +530,198 @@ const sameSpan = (left, right) =>
461
530
  const overlaps = (left, right) =>
462
531
  left.start < right.end && right.start < left.end;
463
532
 
533
+ // --- decorations (rule N22) ---------------------------------------------
534
+
535
+ /** The nodes of a script or group, with `{…}` groups flattened. */
536
+ function flattenGroup(value) {
537
+ if (Array.isArray(value)) return value.flatMap(flattenGroup);
538
+ if (!isNode(value)) return [];
539
+ if (value.type === 'ordgroup') return flattenGroup(value.body);
540
+ return [value];
541
+ }
542
+
543
+ const isPrimeNode = (node) =>
544
+ node.type === 'textord' && node.text === '\\prime';
545
+
546
+ /** The mark a superscript is, if any: a run of primes, `*` / `\ast`, or `\dagger`. */
547
+ function scriptMarkName(sup) {
548
+ const items = flattenGroup(sup);
549
+ if (items.length === 0) return undefined;
550
+ if (isPrimeNode(items[0])) return "'";
551
+ if (items.length !== 1 || items[0].type !== 'atom') return undefined;
552
+ if (items[0].text === '*' || items[0].text === '\\ast') return '*';
553
+ if (items[0].text === '\\dagger') return '\\dagger';
554
+ return undefined;
555
+ }
556
+
557
+ /**
558
+ * The decoration a node is, as `{ name, part, command? }` — `part` is the child
559
+ * key holding what it decorates, `command` the source text before it — or
560
+ * `undefined` for any other node.
561
+ */
562
+ function decorationOf(node) {
563
+ if (!isNode(node)) return undefined;
564
+ if (node.type === 'accent' || node.type === 'accentUnder') {
565
+ return DECORATION_NAMES.has(node.label)
566
+ ? { name: node.label, part: 'base', command: node.label }
567
+ : undefined;
568
+ }
569
+ if (node.type === 'overline' || node.type === 'underline') {
570
+ const name = `\\${node.type}`;
571
+ return { name, part: 'body', command: name };
572
+ }
573
+ if (node.type === 'supsub' && isNode(node.base)) {
574
+ const name = scriptMarkName(node.sup);
575
+ return name === undefined ? undefined : { name, part: 'base' };
576
+ }
577
+ return undefined;
578
+ }
579
+
580
+ function mergeSpans(spans) {
581
+ const present = spans.filter(Boolean);
582
+ if (present.length === 0) return undefined;
583
+ return {
584
+ start: Math.min(...present.map((span) => span.start)),
585
+ end: Math.max(...present.map((span) => span.end)),
586
+ };
587
+ }
588
+
589
+ // Every source spelling of a KaTeX font id, for widening a font node's span
590
+ // over its command (`\bm{x}` and `\boldsymbol{x}` are one node).
591
+ const FONT_SOURCE_COMMANDS = new Map(
592
+ [...FONT_COMMANDS].map(([font, command]) => [
593
+ font,
594
+ {
595
+ mathbf: ['\\mathbf', '\\bold'],
596
+ boldsymbol: ['\\boldsymbol', '\\bm'],
597
+ mathbb: ['\\mathbb', '\\Bbb'],
598
+ mathfrak: ['\\mathfrak', '\\frak'],
599
+ }[font] ?? [command],
600
+ ]),
601
+ );
602
+
603
+ /**
604
+ * Widen the span of an accent's (or `\overline`'s, or a font's) argument over
605
+ * the command that precedes it and the braces around it. `undefined` when no
606
+ * command in `commands` is there — a node a macro produced, whose source is
607
+ * elsewhere.
608
+ */
609
+ function widenOverCommand(latex, span, commands) {
610
+ let cursor = span.start;
611
+ for (;;) {
612
+ while (cursor > 0 && /\s/.test(latex[cursor - 1])) cursor -= 1;
613
+ if (latex[cursor - 1] === '{' && !isEscaped(latex, cursor - 1)) {
614
+ cursor -= 1;
615
+ continue;
616
+ }
617
+ break;
618
+ }
619
+ const command = commands.find(
620
+ (name) => latex.slice(cursor - name.length, cursor) === name,
621
+ );
622
+ if (command === undefined) return undefined;
623
+ const start = cursor - command.length;
624
+ const argument = skipWhitespace(latex, cursor);
625
+ const group =
626
+ latex[argument] === '{' ? readBracedGroup(latex, argument) : undefined;
627
+ return { start, end: Math.max(span.end, group?.end ?? span.end) };
628
+ }
629
+
630
+ /** Extend a span over the `'` primes KaTeX leaves without a `loc`. */
631
+ function extendOverPrimes(latex, end) {
632
+ let cursor = skipWhitespace(latex, end);
633
+ if (latex[cursor] !== "'") return end;
634
+ while (latex[cursor] === "'") cursor += 1;
635
+ return cursor;
636
+ }
637
+
638
+ /**
639
+ * A node's full extent in the source. {@link nodeSpan} reads only the `loc`s
640
+ * KaTeX records, and KaTeX records none on an accent, an `\overline`, a
641
+ * `supsub` or a `'` prime — so `\hat{\beta}` spans only `\beta`, and `b''`
642
+ * only `b`. This widens each decoration over its command text or its trailing
643
+ * primes, so a binding over a decorated form wraps the whole of it.
644
+ */
645
+ function sourceSpan(value, latex) {
646
+ if (Array.isArray(value)) {
647
+ return mergeSpans(value.map((child) => sourceSpan(child, latex)));
648
+ }
649
+ if (!isNode(value)) return undefined;
650
+ const own =
651
+ value.loc &&
652
+ Number.isInteger(value.loc.start) &&
653
+ Number.isInteger(value.loc.end) &&
654
+ isAuthoredLocation(value.loc, latex)
655
+ ? { start: value.loc.start, end: value.loc.end }
656
+ : undefined;
657
+ let span = mergeSpans([
658
+ own,
659
+ ...childValues(value).map((child) => sourceSpan(child, latex)),
660
+ ]);
661
+ if (!span) return undefined;
662
+
663
+ const commands =
664
+ value.type === 'font'
665
+ ? FONT_SOURCE_COMMANDS.get(value.font)
666
+ : decorationOf(value)?.command;
667
+ if (commands !== undefined) {
668
+ span = widenOverCommand(latex, span, [commands].flat()) ?? span;
669
+ } else if (
670
+ value.type === 'supsub' &&
671
+ flattenGroup(value.sup).some((node) => isPrimeNode(node) && !node.loc)
672
+ ) {
673
+ span = { start: span.start, end: extendOverPrimes(latex, span.end) };
674
+ }
675
+ return {
676
+ start: span.start,
677
+ end: trimTrailingWhitespace(latex, span.start, span.end),
678
+ };
679
+ }
680
+
681
+ /** A decoration as the resolver records it, or `undefined` if its source text is not found. */
682
+ function decorationRecord(node, latex) {
683
+ const decoration = decorationOf(node);
684
+ if (decoration === undefined) return undefined;
685
+ const span = sourceSpan(node, latex);
686
+ const partSpan = sourceSpan(node[decoration.part], latex);
687
+ if (!span || !partSpan) return undefined;
688
+ // The decoration's own text (`\hat{`, `'`) was not found: nothing to wrap.
689
+ if (span.start === partSpan.start && span.end === partSpan.end) {
690
+ return undefined;
691
+ }
692
+ const inner = flattenGroup(node[decoration.part]);
693
+ const single =
694
+ inner.length === 1 &&
695
+ (isIdentifierNode(inner[0]) ||
696
+ (inner[0].type === 'font' && FONT_COMMANDS.has(inner[0].font)));
697
+ const baseSpan = unwrapEnclosingGroupSpan(latex, partSpan);
698
+ return {
699
+ name: decoration.name,
700
+ start: span.start,
701
+ end: span.end,
702
+ part: partSpan,
703
+ base: single ? latex.slice(baseSpan.start, baseSpan.end).trim() : undefined,
704
+ };
705
+ }
706
+
707
+ const ROOT_CONTEXT = Object.freeze({ decorations: [], font: undefined });
708
+
464
709
  /**
465
710
  * Walk the parse tree once, collecting: every maximal run of sibling nodes
466
711
  * (`containers`, where a canonical form is matched), every identifier atom with
467
- * its source span, and the spans of every sub/superscript (so a symbol nested
468
- * in a script can still be marked).
712
+ * its source span, the spans of every sub/superscript (so a symbol nested in a
713
+ * script can still be marked), and every decoration (rule N22).
714
+ *
715
+ * Each identifier also carries the decorations it sits under (outermost first;
716
+ * the letter in `\hat{x}'` is under both) and the distinct font it is set in
717
+ * (`font: 'mathbf'`), if any. No container is recorded inside a distinct
718
+ * font's body, so the `N` of `\mathcal{N}` can never match an `N` entry.
469
719
  */
470
720
  function collectSourceStructure(tree, latex) {
471
721
  const containers = [];
472
722
  const identifiers = [];
473
723
  const scriptSpans = [];
724
+ const decorations = [];
474
725
  const seenContainers = new Set();
475
726
  const seenIdentifiers = new Set();
476
727
 
@@ -485,10 +736,12 @@ function collectSourceStructure(tree, latex) {
485
736
  containers.push(usable);
486
737
  }
487
738
 
488
- function visitValue(value, ignored = false) {
739
+ function visitValue(value, ignored = false, context = ROOT_CONTEXT) {
489
740
  if (Array.isArray(value)) {
490
- if (!ignored && value.every(isNode)) addContainer(value);
491
- for (const child of value) visitValue(child, ignored);
741
+ if (!ignored && context.font === undefined && value.every(isNode)) {
742
+ addContainer(value);
743
+ }
744
+ for (const child of value) visitValue(child, ignored, context);
492
745
  return;
493
746
  }
494
747
  if (!isNode(value)) return;
@@ -509,6 +762,8 @@ function collectSourceStructure(tree, latex) {
509
762
  node: value,
510
763
  signature: sequenceSignature([value]),
511
764
  ...span,
765
+ decorations: context.decorations,
766
+ font: context.font,
512
767
  });
513
768
  }
514
769
  }
@@ -520,32 +775,126 @@ function collectSourceStructure(tree, latex) {
520
775
  }
521
776
  }
522
777
 
523
- for (const child of childValues(value)) {
778
+ const decoration = ignored ? undefined : decorationRecord(value, latex);
779
+ if (decoration) decorations.push(decoration);
780
+ const decoratedPart = decoration ? decorationOf(value).part : undefined;
781
+ const fontContext =
782
+ value.type === 'font' && DISTINCT_FONTS.has(value.font)
783
+ ? { ...context, font: value.font }
784
+ : context;
785
+
786
+ for (const key of SOURCE_CHILD_KEYS) {
787
+ const child = value[key];
788
+ if (child === undefined || child === null) continue;
789
+ const childContext =
790
+ key === decoratedPart
791
+ ? {
792
+ ...fontContext,
793
+ decorations: [...fontContext.decorations, decoration],
794
+ }
795
+ : fontContext;
796
+ const recordContainers = !ignoredBody && childContext.font === undefined;
524
797
  if (Array.isArray(child)) {
525
- if (!ignoredBody && child.every(isNode)) addContainer(child);
526
- for (const nested of child) visitValue(nested, ignoredBody);
798
+ if (recordContainers && child.every(isNode)) addContainer(child);
799
+ for (const nested of child) {
800
+ visitValue(nested, ignoredBody, childContext);
801
+ }
527
802
  } else if (isNode(child)) {
528
- if (!ignoredBody) addContainer([child]);
529
- visitValue(child, ignoredBody);
803
+ if (recordContainers) addContainer([child]);
804
+ visitValue(child, ignoredBody, childContext);
530
805
  }
531
806
  }
532
807
  }
533
808
 
534
809
  addContainer(tree);
535
810
  visitValue(tree);
536
- return { containers, identifiers, scriptSpans };
811
+ return { containers, identifiers, scriptSpans, decorations };
537
812
  }
538
813
 
539
814
  // --- base-glyph signatures ------------------------------------------
540
815
 
541
816
  function identifierBaseSignature(node) {
542
817
  if (isIdentifierNode(node)) return sequenceSignature([node]);
543
- if (node?.type === 'supsub' && isIdentifierNode(node.base)) {
818
+ // `t_k` lends its card to a bare `t`, but `b'` or `\theta^{*}` does not lend
819
+ // its card to `b` or `\theta`: a mark makes a different quantity (N22).
820
+ if (
821
+ node?.type === 'supsub' &&
822
+ isIdentifierNode(node.base) &&
823
+ scriptMarkName(node.sup) === undefined
824
+ ) {
544
825
  return sequenceSignature([node.base]);
545
826
  }
546
827
  return undefined;
547
828
  }
548
829
 
830
+ /**
831
+ * The decoration a notation entry is an **operator entry** for: exactly one
832
+ * decoration over an empty group (`\hat{}`, `\overline{}`, `{}'`, `{}^{*}`,
833
+ * `{}^{\dagger}`), else `undefined`. Such an entry explains the decoration
834
+ * wherever it composes (rule N22); it never binds a letter.
835
+ */
836
+ function decorationOfTree(tree) {
837
+ if (!Array.isArray(tree) || tree.length !== 1) return undefined;
838
+ const [node] = tree;
839
+ const decoration = decorationOf(node);
840
+ if (decoration === undefined) return undefined;
841
+ if (node.type === 'supsub' && node.sub) return undefined;
842
+ const part = node[decoration.part];
843
+ return isNode(part) && part.type === 'ordgroup' && part.body.length === 0
844
+ ? decoration.name
845
+ : undefined;
846
+ }
847
+
848
+ /**
849
+ * The operator-entry `latex` for a decoration name — the decoration over an
850
+ * empty group: `\hat{}`, `\overline{}`, `{}'`, `{}^{*}`, `{}^{\dagger}`. The
851
+ * inverse of {@link decorationOfEntry}, and how the decoration alone is shown.
852
+ *
853
+ * @param {string} name
854
+ * @returns {string}
855
+ */
856
+ export function operatorEntryLatex(name) {
857
+ if (name === "'") return "{}'";
858
+ if (name === '*') return '{}^{*}';
859
+ if (name === '\\dagger') return '{}^{\\dagger}';
860
+ return `${name}{}`;
861
+ }
862
+
863
+ /**
864
+ * How a decoration is shown on its own — over a placeholder square, so a
865
+ * reader sees where it goes: `\hat{\square}`, `\square'`, `\square^{*}`.
866
+ * A lone `\dot{}` renders as a speck. Display only: the entry's `latex` stays
867
+ * the empty-group form the resolver keys on.
868
+ *
869
+ * @param {string} name
870
+ * @returns {string}
871
+ */
872
+ export function operatorDisplayLatex(name) {
873
+ return operatorEntryLatex(name).replace(/^\{\}/, '\\square').replace(/\{\}$/, '{\\square}');
874
+ }
875
+
876
+ /**
877
+ * The decoration a notation entry's `latex` is an operator entry for (see
878
+ * {@link decorationOfTree}), or `undefined`. `\hat{\beta}` is a whole form,
879
+ * not an operator entry.
880
+ *
881
+ * @param {string} latex
882
+ * @returns {string | undefined}
883
+ */
884
+ export function decorationOfEntry(latex) {
885
+ if (typeof latex !== 'string' || latex.trim() === '') return undefined;
886
+ try {
887
+ return decorationOfTree(
888
+ parseMath(stripNotationMarkers(latex), 'operator entry'),
889
+ );
890
+ } catch (error) {
891
+ if (error instanceof GlyphResolutionError && error.code === 'invalid-latex') {
892
+ return undefined;
893
+ }
894
+ throw error;
895
+ }
896
+ }
897
+
549
898
  /**
550
899
  * The shorter page form a parameterized symbol also permits: the bare base
551
900
  * glyph of `t_k` / `r_m` / `j^{(m)}` (one significant node) or the head of a
@@ -623,7 +972,7 @@ function unwrapEnclosingGroupSpan(latex, span) {
623
972
  }
624
973
 
625
974
  const FONT_WRAPPER_OPEN =
626
- /\\(?:mathbb|mathcal|mathfrak|mathscr|mathrm|mathbf|mathit)\{$/;
975
+ /\\(?:mathbb|mathcal|mathfrak|mathscr|mathrm|mathbf|mathit|mathnormal|mathsf|mathtt|boldsymbol|bm|bold|Bbb|frak)\{$/;
627
976
 
628
977
  /**
629
978
  * KaTeX puts no `loc` on a font node (`\mathbb{E}`, `\mathrm{SST}`, …), so a
@@ -666,7 +1015,7 @@ function isSafeScriptNesting(inner, outer, scriptSpans) {
666
1015
  * registered. This is deliberately *not* `collectSourceStructure`'s
667
1016
  * `identifiers` list: that list is built for scanning *page* math, where a
668
1017
  * `\mathrm{…}` body is correctly treated as opaque (never a bindable atom on
669
- * its own, [N3](../../docs/authoring/notation.md#n3)) — but reusing it here to
1018
+ * its own, [N10](../../docs/authoring/notation.md#n10)) — but reusing it here to
670
1019
  * gate the *definition's own* registration wrongly excluded every glyph whose
671
1020
  * whole declared form is a roman label (`\mathrm{SSR}`, `\mathrm{SST}`, …):
672
1021
  * such a definition produced zero `identifiers`, so it never entered
@@ -703,6 +1052,7 @@ function buildGlyphIndex(definitions) {
703
1052
  const bySignature = new Map();
704
1053
  const byBase = new Map();
705
1054
  const ambiguousBase = new Set();
1055
+ const operators = new Map();
706
1056
  let maxNodeLength = 0;
707
1057
 
708
1058
  for (const [index, value] of definitions.entries()) {
@@ -739,6 +1089,22 @@ function buildGlyphIndex(definitions) {
739
1089
  const entry = { key, notation };
740
1090
  byKey.set(key, entry);
741
1091
 
1092
+ // An operator entry (`\hat{}`) binds no letter; it explains its
1093
+ // decoration wherever that composes (rule N22).
1094
+ const operator = decorationOfTree(tree);
1095
+ if (operator !== undefined) {
1096
+ const clash = operators.get(operator);
1097
+ if (clash && clash !== key) {
1098
+ fail(
1099
+ 'ambiguous-canonical-notation',
1100
+ `Page glyphs "${clash}" and "${key}" are both operator entries for ${operator}.`,
1101
+ { left: clash, right: key },
1102
+ );
1103
+ }
1104
+ operators.set(operator, key);
1105
+ continue;
1106
+ }
1107
+
742
1108
  const signature = sequenceSignature(tree);
743
1109
  if (hasBindableGlyph(tree) && tree.length > 0) {
744
1110
  const clash = bySignature.get(signature);
@@ -763,7 +1129,7 @@ function buildGlyphIndex(definitions) {
763
1129
  }
764
1130
  }
765
1131
 
766
- return { byKey, bySignature, byBase, ambiguousBase, maxNodeLength };
1132
+ return { byKey, bySignature, byBase, ambiguousBase, operators, maxNodeLength };
767
1133
  }
768
1134
 
769
1135
  /** Bind whole canonical forms: greedy longest match, left to right, per run. */
@@ -783,7 +1149,9 @@ function matchCanonicalForms(latex, strippedSource, containers, glyphs) {
783
1149
  const nodes = container.slice(cursor, cursor + length);
784
1150
  const key = glyphs.bySignature.get(sequenceSignature(nodes));
785
1151
  if (key === undefined) continue;
786
- const rawSpan = sequenceSpan(nodes, strippedSource);
1152
+ // `sourceSpan`, not `sequenceSpan`: a whole-form match on `\hat{\beta}`
1153
+ // or `b'` wraps the decoration too, not just the letter under it.
1154
+ const rawSpan = sourceSpan(nodes, strippedSource);
787
1155
  if (!rawSpan) continue;
788
1156
  const span = expandFontWrapperSpan(
789
1157
  latex,
@@ -837,6 +1205,9 @@ function matchBaseGlyphs(latex, identifiers, canonical, glyphs, scriptSpans) {
837
1205
 
838
1206
  const cover = canonical.find((binding) => contains(binding, identifier));
839
1207
  if (cover && !isSafeScriptNesting(identifier, cover, scriptSpans)) continue;
1208
+ // `\mathbf{x}` is a different symbol from `x` (rule N4): it binds only
1209
+ // through its own entry or an `\explain`, never the plain letter's card.
1210
+ if (identifier.font !== undefined) continue;
840
1211
 
841
1212
  const baseSignature = identifier.signature;
842
1213
  if (glyphs.ambiguousBase.has(baseSignature)) {
@@ -899,26 +1270,61 @@ function injectScopeBindings(latex, bindings) {
899
1270
  return output;
900
1271
  }
901
1272
 
902
- function unresolvedIdentifiers(latex, identifiers, bindings) {
903
- return identifiers
904
- .filter((identifier) => {
905
- const token = latex.slice(identifier.start, identifier.end);
906
- return (
907
- !BASE_LIBRARY.has(token) &&
908
- !bindings.some((binding) => contains(binding, identifier))
909
- );
910
- })
911
- .map(({ start, end }) => {
1273
+ /**
1274
+ * Every identifier no binding covers, plus every decorated form whose
1275
+ * decoration is suspended on this page and that no whole form covers (rule
1276
+ * N22). A letter under such a decoration is reported once, as the decorated
1277
+ * form (`b''''`, not `b`): that whole form is what needs the entry.
1278
+ */
1279
+ function unresolvedIdentifiers(latex, identifiers, bindings, suspended) {
1280
+ const letters = identifiers
1281
+ .filter(
1282
+ (identifier) =>
1283
+ !bindings.some((binding) => contains(binding, identifier)) &&
1284
+ !suspended.some((decoration) => contains(decoration.part, identifier)),
1285
+ )
1286
+ .map(({ start, end, font }) => {
912
1287
  // Report `\mathbb{E}`, not a bare `E`, so the fix hint points at the
913
1288
  // whole token the author has to wrap in `\explain`.
914
1289
  const wrapped = expandFontWrapperSpan(latex, { start, end });
915
1290
  return {
916
1291
  token: latex.slice(wrapped.start, wrapped.end),
1292
+ // A styled base-library letter (`\mathbf{e}`) is not Euler's number.
1293
+ libraryToken: latex.slice(
1294
+ font === undefined ? start : wrapped.start,
1295
+ font === undefined ? end : wrapped.end,
1296
+ ),
917
1297
  start: wrapped.start,
918
1298
  end: wrapped.end,
1299
+ ...(font === undefined ? {} : { font }),
919
1300
  };
920
1301
  })
921
- .sort((left, right) => left.start - right.start || left.end - right.end);
1302
+ .filter(({ libraryToken }) => !BASE_LIBRARY.has(libraryToken))
1303
+ .map(({ libraryToken: _libraryToken, ...item }) => item);
1304
+ const decorated = suspended.map(({ name, start, end }) => ({
1305
+ token: latex.slice(start, end),
1306
+ start,
1307
+ end,
1308
+ suspended: name,
1309
+ }));
1310
+ return [...letters, ...decorated].sort(
1311
+ (left, right) => left.start - right.start || left.end - right.end,
1312
+ );
1313
+ }
1314
+
1315
+ /**
1316
+ * A decoration over an `\explain{key}{…}` call starts at that call's content
1317
+ * group in the blanked source; widen it to the call itself, so a marker
1318
+ * injected around the decoration never splits `\explain{key}` from its `{…}`.
1319
+ */
1320
+ function widenOverExplainPrefix(span, explicitCalls) {
1321
+ let { start } = span;
1322
+ for (;;) {
1323
+ const call = explicitCalls.find((candidate) => candidate.prefixEnd === start);
1324
+ if (!call) break;
1325
+ start = call.callStart;
1326
+ }
1327
+ return { start, end: span.end };
922
1328
  }
923
1329
 
924
1330
  // --- N13: nonlinear functions take a parenthesized argument --------------
@@ -1162,14 +1568,24 @@ function findNestedBareDelimiters(tree) {
1162
1568
  * Resolve and annotate one KaTeX expression against a page's flat glyph table.
1163
1569
  * Source offsets in the result always refer to the original, unmodified LaTeX.
1164
1570
  *
1571
+ * Decorations (rule N22) are reported in `decorations`: `wholeForm` is the key
1572
+ * of the binding that covers the decorated form (`\hat{\beta}` has its own
1573
+ * entry), else `null`; `operator` is the key of the operator entry
1574
+ * (`latex: \hat{}`) whose `\explain` now wraps the decorated form, else
1575
+ * `null`. A decoration named in `options.suspended` never composes: a
1576
+ * decorated form no whole form covers is reported in `unresolved`, marked
1577
+ * `suspended`.
1578
+ *
1165
1579
  * @param {string} latex
1166
1580
  * @param {Array<{key: string, notation: string}>} definitions
1581
+ * @param {{ suspended?: readonly string[] }} [options]
1167
1582
  */
1168
- export function resolveMathGlyphs(latex, definitions) {
1583
+ export function resolveMathGlyphs(latex, definitions, options = {}) {
1169
1584
  if (typeof latex !== 'string') {
1170
1585
  fail('invalid-latex-input', 'Math source must be a string.');
1171
1586
  }
1172
1587
 
1588
+ const suspended = new Set(options.suspended ?? []);
1173
1589
  const glyphs = buildGlyphIndex(definitions);
1174
1590
  const explicitCalls = findExplainCalls(latex, glyphs.byKey);
1175
1591
  // The source KaTeX actually parses — `\explain{key}{` prefixes blanked to
@@ -1206,10 +1622,8 @@ export function resolveMathGlyphs(latex, definitions) {
1206
1622
  { violations: nestingViolations },
1207
1623
  );
1208
1624
  }
1209
- const { containers, identifiers, scriptSpans } = collectSourceStructure(
1210
- tree,
1211
- strippedSource,
1212
- );
1625
+ const { containers, identifiers, scriptSpans, decorations } =
1626
+ collectSourceStructure(tree, strippedSource);
1213
1627
 
1214
1628
  // A canonical container match can be found by seeing through transparent
1215
1629
  // ordgroup/font wrapping into a structural sibling of an explicit
@@ -1237,7 +1651,48 @@ export function resolveMathGlyphs(latex, definitions) {
1237
1651
  scriptSpans,
1238
1652
  );
1239
1653
 
1240
- const bindings = [...canonical, ...base, ...explicitCalls]
1654
+ // Rule N22: a decoration a whole form covers is part of that symbol. One
1655
+ // that composes is wrapped in its operator entry's `\explain`, around the
1656
+ // letter's own — unless it is suspended on this page, when the decorated
1657
+ // form is unresolved.
1658
+ const letterBindings = [...canonical, ...base, ...explicitCalls];
1659
+ const operatorBindings = [];
1660
+ const suspendedDecorations = [];
1661
+ const decorationResults = decorations.map((decoration) => {
1662
+ const span = widenOverExplainPrefix(decoration, explicitCalls);
1663
+ const wholeForm =
1664
+ covered.find((binding) => contains(binding, span))?.key ?? null;
1665
+ let operator = null;
1666
+ if (wholeForm === null && suspended.has(decoration.name)) {
1667
+ suspendedDecorations.push({ ...decoration, ...span });
1668
+ } else if (wholeForm === null && glyphs.operators.has(decoration.name)) {
1669
+ const straddles = [...letterBindings, ...operatorBindings].some(
1670
+ (binding) =>
1671
+ overlaps(binding, span) &&
1672
+ !contains(span, binding) &&
1673
+ !contains(binding, span),
1674
+ );
1675
+ if (!straddles) {
1676
+ operator = glyphs.operators.get(decoration.name);
1677
+ operatorBindings.push({
1678
+ key: operator,
1679
+ ...span,
1680
+ token: latex.slice(span.start, span.end),
1681
+ level: 'scope',
1682
+ match: 'operator',
1683
+ });
1684
+ }
1685
+ }
1686
+ return {
1687
+ name: decoration.name,
1688
+ ...span,
1689
+ token: latex.slice(span.start, span.end),
1690
+ wholeForm,
1691
+ operator,
1692
+ };
1693
+ });
1694
+
1695
+ const bindings = [...letterBindings, ...operatorBindings]
1241
1696
  .sort(
1242
1697
  (left, right) =>
1243
1698
  left.start - right.start ||
@@ -1279,7 +1734,13 @@ export function resolveMathGlyphs(latex, definitions) {
1279
1734
  match,
1280
1735
  token,
1281
1736
  })),
1282
- unresolved: unresolvedIdentifiers(latex, identifiers, bindings),
1737
+ unresolved: unresolvedIdentifiers(
1738
+ latex,
1739
+ identifiers,
1740
+ bindings,
1741
+ suspendedDecorations,
1742
+ ),
1743
+ decorations: decorationResults,
1283
1744
  };
1284
1745
  }
1285
1746
 
@@ -1383,18 +1844,92 @@ export function collectOperators(latex) {
1383
1844
  return operators;
1384
1845
  }
1385
1846
 
1847
+ // --- decorations -----------------------------------------------------
1848
+
1849
+ /**
1850
+ * Blank every `\explain{key}{` / `\slot{key}{` prefix to spaces, keeping the
1851
+ * source's length, so KaTeX parses what the markers wrap and every offset
1852
+ * still points into the original. Unlike `findExplainCalls`, no key is
1853
+ * checked: a scan has no glyph table.
1854
+ */
1855
+ function blankMarkerPrefixes(latex) {
1856
+ let source = latex;
1857
+ const calls = [];
1858
+ for (let cursor = 0; cursor < latex.length; cursor++) {
1859
+ const command = MARKER_COMMANDS.find(
1860
+ (name) =>
1861
+ latex.startsWith(name, cursor) &&
1862
+ !isEscaped(latex, cursor) &&
1863
+ !/[A-Za-z@]/.test(latex[cursor + name.length] ?? ''),
1864
+ );
1865
+ if (!command) continue;
1866
+ const keyGroup = readBracedGroup(
1867
+ latex,
1868
+ skipWhitespace(latex, cursor + command.length),
1869
+ );
1870
+ if (!keyGroup) continue;
1871
+ const prefixEnd = skipWhitespace(latex, keyGroup.end);
1872
+ if (latex[prefixEnd] !== '{') continue;
1873
+ source =
1874
+ source.slice(0, cursor) +
1875
+ ' '.repeat(prefixEnd - cursor) +
1876
+ source.slice(prefixEnd);
1877
+ calls.push({ callStart: cursor, prefixEnd });
1878
+ cursor = prefixEnd;
1879
+ }
1880
+ return { source, calls };
1881
+ }
1882
+
1883
+ /**
1884
+ * Every decoration in one KaTeX expression (rule N22): an accent (`\hat`,
1885
+ * `\widehat`, …), `\overline` / `\underline`, or a superscript mark (`'` for
1886
+ * any run of primes, `*` for `*` / `\ast`, `\dagger`). `name` is the
1887
+ * canonical name in {@link DECORATION_NAMES}; `start`/`end` span the whole
1888
+ * decorated form in `latex`; `base` is the source of what it decorates when
1889
+ * that is one symbol (`\beta`, `b`, `\mathbf{x}`), else `undefined`.
1890
+ *
1891
+ * Pure read, the twin of {@link collectOperators}: `\text{}`, `\mathrm{}` and
1892
+ * operator bodies are skipped, and a span that will not parse returns `[]`
1893
+ * (the math gate owns that error).
1894
+ *
1895
+ * @param {string} latex
1896
+ * @returns {Array<{ name: string, start: number, end: number, base: string | undefined }>}
1897
+ */
1898
+ export function collectDecorations(latex) {
1899
+ if (typeof latex !== 'string' || latex.trim() === '') return [];
1900
+
1901
+ const { source, calls } = blankMarkerPrefixes(latex);
1902
+ let tree;
1903
+ try {
1904
+ tree = parseMath(source, 'decoration scan');
1905
+ } catch (error) {
1906
+ if (error instanceof GlyphResolutionError && error.code === 'invalid-latex') {
1907
+ return [];
1908
+ }
1909
+ throw error;
1910
+ }
1911
+
1912
+ return collectSourceStructure(tree, source).decorations.map(
1913
+ ({ name, start, end, base }) => ({
1914
+ name,
1915
+ ...widenOverExplainPrefix({ start, end }, calls),
1916
+ base,
1917
+ }),
1918
+ );
1919
+ }
1920
+
1386
1921
  // --- named roman labels ----------------------------------------------
1387
1922
 
1388
1923
  /**
1389
1924
  * Every multi-letter `\mathrm{…}` label in one KaTeX expression (`KE`, `PV`,
1390
1925
  * `opt`) — the upright-label counterpart to {@link collectOperators}. A
1391
- * single-letter body (`\mathrm{d}`, the N7 differential) is not collected:
1926
+ * single-letter body (`\mathrm{d}`, the rule-N2 differential) is not collected:
1392
1927
  * that one is a fixed typographic operator, not a named quantity a reader
1393
1928
  * would need explained.
1394
1929
  *
1395
1930
  * Pure read: nothing here rewrites the source. Used by the
1396
1931
  * `roman-label-explained` consistency check (rule N10); the glyph resolver
1397
- * still skips `\mathrm{…}` bodies whole ([N3](../../docs/authoring/notation.md#n3)).
1932
+ * still skips `\mathrm{…}` bodies whole ([N10](../../docs/authoring/notation.md#n10)).
1398
1933
  *
1399
1934
  * Same `stripNotationMarkers` preprocessing as {@link collectOperators}, and
1400
1935
  * for the same reason: an unstripped `\explain{…}{…}` elsewhere in the span