explico 0.7.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 (236) hide show
  1. package/CHANGELOG.md +1409 -1
  2. package/README.md +60 -15
  3. package/dist/compiler/astro.d.ts +6 -1
  4. package/dist/compiler/cli.d.ts +54 -3
  5. package/dist/compiler/cli.js +80 -6
  6. package/dist/compiler/collections.d.ts +35 -2
  7. package/dist/compiler/collections.js +84 -8
  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 +14 -1
  15. package/dist/compiler/logging.js +6 -2
  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 +21 -11
  27. package/dist/reference/delimiter-size.js +15 -39
  28. package/dist/reference/diagrams-validate.d.ts +7 -2
  29. package/dist/reference/diagrams-validate.js +9 -1
  30. package/dist/reference/equations-validate.d.ts +8 -5
  31. package/dist/reference/equations-validate.js +1 -0
  32. package/dist/reference/equations.d.mts +69 -34
  33. package/dist/reference/equations.mjs +90 -44
  34. package/dist/reference/examples-validate.d.ts +31 -0
  35. package/dist/reference/examples-validate.js +48 -0
  36. package/dist/reference/figures-validate.d.ts +1 -1
  37. package/dist/reference/figures-validate.js +4 -1
  38. package/dist/reference/folds-validate.d.ts +33 -0
  39. package/dist/reference/folds-validate.js +32 -0
  40. package/dist/reference/gate-abbreviations.d.ts +3 -1
  41. package/dist/reference/gate-abbreviations.js +3 -2
  42. package/dist/reference/gate-course-rules.d.ts +32 -0
  43. package/dist/reference/gate-course-rules.js +85 -0
  44. package/dist/reference/gate-heading-length.d.ts +5 -2
  45. package/dist/reference/gate-heading-length.js +47 -23
  46. package/dist/reference/gate-heading-structure.d.ts +41 -0
  47. package/dist/reference/gate-heading-structure.js +113 -0
  48. package/dist/reference/gate-math-punctuation.d.ts +71 -0
  49. package/dist/reference/gate-math-punctuation.js +251 -0
  50. package/dist/reference/gate-math.d.ts +44 -6
  51. package/dist/reference/gate-math.js +63 -10
  52. package/dist/reference/gate-part-pages.d.ts +50 -0
  53. package/dist/reference/gate-part-pages.js +118 -0
  54. package/dist/reference/gate-prose-notation.d.ts +32 -11
  55. package/dist/reference/gate-prose-notation.js +30 -8
  56. package/dist/reference/katex-delimiters.d.mts +15 -0
  57. package/dist/reference/katex-delimiters.mjs +138 -0
  58. package/dist/reference/katex-options.mjs +4 -0
  59. package/dist/reference/keyed-float-validate.d.ts +27 -10
  60. package/dist/reference/keyed-float-validate.js +15 -1
  61. package/dist/reference/keyed-jsx-floats.d.mts +11 -5
  62. package/dist/reference/keyed-jsx-floats.mjs +73 -20
  63. package/dist/reference/knowledge-checks-validate.d.ts +1 -1
  64. package/dist/reference/knowledge-checks.d.mts +3 -1
  65. package/dist/reference/knowledge-checks.mjs +9 -4
  66. package/dist/reference/lab-math-scope.d.ts +1 -0
  67. package/dist/reference/lab-math-slot.d.ts +34 -0
  68. package/dist/reference/lab-math-slot.js +6 -0
  69. package/dist/reference/math-glyphs.d.mts +80 -4
  70. package/dist/reference/math-glyphs.mjs +588 -39
  71. package/dist/reference/page-notation.d.ts +6 -1
  72. package/dist/reference/raw-floats-validate.js +1 -1
  73. package/dist/reference/raw-floats.mjs +1 -1
  74. package/dist/reference/rehype-heading-anchors.d.mts +14 -0
  75. package/dist/reference/rehype-heading-anchors.mjs +116 -17
  76. package/dist/reference/rehype-table-header-labels.d.mts +6 -1
  77. package/dist/reference/rehype-table-header-labels.mjs +21 -3
  78. package/dist/reference/remark-asides.d.mts +3 -0
  79. package/dist/reference/remark-asides.mjs +180 -0
  80. package/dist/reference/remark-memo.d.mts +3 -1
  81. package/dist/reference/remark-memo.mjs +42 -11
  82. package/dist/reference/remark-notation.d.mts +60 -10
  83. package/dist/reference/remark-notation.mjs +387 -36
  84. package/dist/reference/render-lab-math.d.ts +4 -26
  85. package/dist/reference/render-lab-math.js +5 -5
  86. package/dist/reference/sections-validate.d.ts +27 -0
  87. package/dist/reference/sections-validate.js +41 -0
  88. package/dist/reference/sections.d.mts +97 -0
  89. package/dist/reference/sections.mjs +306 -0
  90. package/dist/reference/tables-validate.d.ts +1 -1
  91. package/dist/reference/types.d.ts +24 -1
  92. package/dist/reference/unit-words.d.ts +37 -0
  93. package/dist/reference/unit-words.js +170 -0
  94. package/dist/rules/catalog.d.ts +176 -0
  95. package/dist/rules/catalog.js +983 -0
  96. package/dist/rules/course-rules.d.ts +115 -0
  97. package/dist/rules/course-rules.js +292 -0
  98. package/dist/rules/course-rules.schema.json +138 -0
  99. package/dist/rules/in-effect.d.ts +53 -0
  100. package/dist/rules/in-effect.js +142 -0
  101. package/dist/rules/rules.schema.json +1072 -0
  102. package/dist/rules/settings.d.ts +3 -0
  103. package/dist/rules/settings.js +290 -0
  104. package/dist/rules/waivers.d.ts +53 -0
  105. package/dist/rules/waivers.js +132 -0
  106. package/dist/session/PreferenceStore.d.ts +5 -0
  107. package/dist/session/PreferenceStore.js +9 -0
  108. package/docs/VERSIONING.md +2 -2
  109. package/docs/authoring/README.md +232 -40
  110. package/docs/authoring/agents.md +55 -9
  111. package/docs/authoring/components.md +178 -22
  112. package/docs/authoring/migrating.md +1324 -2
  113. package/docs/authoring/notation.md +616 -344
  114. package/docs/authoring/prose.md +551 -0
  115. package/docs/authoring/retired.md +35 -0
  116. package/docs/authoring/structure-rules.md +422 -40
  117. package/docs/authoring/structure.md +445 -62
  118. package/docs/authoring/versioning.md +20 -28
  119. package/docs/authoring/visualizations.md +142 -20
  120. package/docs/katex-bracket-scaling.md +191 -0
  121. package/package.json +3 -2
  122. package/src/compiler/cli.ts +139 -6
  123. package/src/compiler/collections.ts +144 -10
  124. package/src/compiler/course-config.ts +273 -34
  125. package/src/compiler/knowledge-version.ts +1 -1
  126. package/src/compiler/lesson-derivation.ts +54 -15
  127. package/src/compiler/logging.ts +21 -4
  128. package/src/compiler/manifest.ts +573 -104
  129. package/src/components/BlockHeading.astro +113 -0
  130. package/src/components/CourseContents.astro +221 -0
  131. package/src/components/CurriculumMap.astro +5 -6
  132. package/src/components/FloatIndex.astro +200 -0
  133. package/src/components/HoverPanel.astro +431 -79
  134. package/src/components/PartBridge.astro +27 -0
  135. package/src/components/PartOverview.astro +401 -0
  136. package/src/components/PartScope.astro +27 -0
  137. package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
  138. package/src/components/assessments/AssessmentRunner.tsx +8 -5
  139. package/src/components/assessments/AssessmentSet.astro +23 -26
  140. package/src/components/block-heading.ts +146 -0
  141. package/src/components/card-arrow.ts +243 -0
  142. package/src/components/card-drag.ts +631 -0
  143. package/src/components/card-jump.ts +45 -0
  144. package/src/components/card-manager.ts +1589 -0
  145. package/src/components/card-memory.ts +177 -0
  146. package/src/components/card-placement.ts +504 -0
  147. package/src/components/card-side.ts +336 -0
  148. package/src/components/card-travel.ts +197 -0
  149. package/src/components/citation/CitationLayer.astro +77 -76
  150. package/src/components/diagram/Diagram.astro +161 -316
  151. package/src/components/disclosure/course-disclosure.ts +24 -0
  152. package/src/components/disclosure/hover-preview.ts +122 -42
  153. package/src/components/disclosure/persisted-disclosure.ts +3 -3
  154. package/src/components/disclosure/print-as-shown.ts +252 -0
  155. package/src/components/examples/CompactExample.astro +3 -1
  156. package/src/components/examples/CompactExamples.astro +101 -94
  157. package/src/components/figure/Figure.astro +174 -274
  158. package/src/components/figure/FloatCaption.astro +87 -0
  159. package/src/components/figure/figure-state.ts +58 -0
  160. package/src/components/figure/figure-tools.ts +87 -0
  161. package/src/components/float-name.ts +101 -0
  162. package/src/components/fold/Fold.astro +125 -0
  163. package/src/components/fold/fold-id.ts +40 -0
  164. package/src/components/memo/Memo.astro +162 -231
  165. package/src/components/memo/ShortNote.astro +6 -6
  166. package/src/components/notation/NotationFormula.astro +5 -1
  167. package/src/components/notation/NotationGlossary.astro +24 -14
  168. package/src/components/notation/NotationLayer.astro +169 -162
  169. package/src/components/notation/NotationMath.astro +10 -1
  170. package/src/components/notation/render-notation-math.ts +111 -10
  171. package/src/components/reference/FloatRefLayer.astro +485 -423
  172. package/src/components/starlight/AboutPanel.astro +22 -6
  173. package/src/components/starlight/CollapsibleSections.astro +207 -93
  174. package/src/components/starlight/DiagramEnhancer.astro +15 -138
  175. package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
  176. package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
  177. package/src/components/starlight/LayoutHeader.astro +298 -19
  178. package/src/components/starlight/LayoutPageSidebar.astro +99 -12
  179. package/src/components/starlight/LayoutPageTitle.astro +120 -0
  180. package/src/components/starlight/LayoutSidebar.astro +229 -11
  181. package/src/components/starlight/LessonExportActions.astro +53 -72
  182. package/src/components/starlight/LessonFooter.astro +26 -4
  183. package/src/components/starlight/section-parts.ts +15 -2
  184. package/src/components/starlight/section-summary.ts +181 -0
  185. package/src/components/table/Table.astro +95 -76
  186. package/src/components/table/TableOverflowExpander.astro +65 -28
  187. package/src/content-config.ts +40 -4
  188. package/src/reference/base-accents.mjs +31 -0
  189. package/src/reference/base-roman-labels.mjs +2 -2
  190. package/src/reference/consistency.ts +176 -5
  191. package/src/reference/delimiter-size.ts +40 -61
  192. package/src/reference/diagrams-validate.ts +17 -1
  193. package/src/reference/equations-validate.ts +10 -5
  194. package/src/reference/equations.mjs +90 -44
  195. package/src/reference/examples-validate.ts +97 -0
  196. package/src/reference/figures-validate.ts +6 -1
  197. package/src/reference/folds-validate.ts +65 -0
  198. package/src/reference/gate-abbreviations.ts +3 -2
  199. package/src/reference/gate-course-rules.ts +134 -0
  200. package/src/reference/gate-heading-length.ts +68 -37
  201. package/src/reference/gate-heading-structure.ts +193 -0
  202. package/src/reference/gate-math-punctuation.ts +381 -0
  203. package/src/reference/gate-math.ts +119 -12
  204. package/src/reference/gate-part-pages.ts +217 -0
  205. package/src/reference/gate-prose-notation.ts +74 -13
  206. package/src/reference/katex-delimiters.mjs +138 -0
  207. package/src/reference/katex-options.mjs +4 -0
  208. package/src/reference/keyed-float-validate.ts +45 -11
  209. package/src/reference/keyed-jsx-floats.mjs +73 -20
  210. package/src/reference/knowledge-checks-validate.ts +1 -1
  211. package/src/reference/knowledge-checks.mjs +9 -4
  212. package/src/reference/lab-math-slot.ts +36 -0
  213. package/src/reference/math-glyphs.mjs +588 -39
  214. package/src/reference/raw-floats-validate.ts +1 -1
  215. package/src/reference/raw-floats.mjs +1 -1
  216. package/src/reference/rehype-heading-anchors.mjs +116 -17
  217. package/src/reference/rehype-table-header-labels.mjs +21 -3
  218. package/src/reference/remark-asides.mjs +180 -0
  219. package/src/reference/remark-memo.mjs +42 -11
  220. package/src/reference/remark-notation.mjs +387 -36
  221. package/src/reference/render-lab-math.ts +17 -32
  222. package/src/reference/sections-validate.ts +81 -0
  223. package/src/reference/sections.mjs +306 -0
  224. package/src/reference/tables-validate.ts +2 -1
  225. package/src/reference/types.ts +24 -0
  226. package/src/reference/unit-words.ts +233 -0
  227. package/src/rules/catalog.ts +1298 -0
  228. package/src/rules/course-rules.ts +456 -0
  229. package/src/rules/in-effect.ts +235 -0
  230. package/src/rules/settings.ts +448 -0
  231. package/src/rules/waivers.ts +249 -0
  232. package/src/session/PreferenceStore.ts +15 -0
  233. package/src/styles/global.css +1471 -163
  234. package/src/styles/surfaces.css +182 -0
  235. package/src/components/hover-panel.ts +0 -339
  236. package/src/components/popup-stack.ts +0 -345
@@ -1,4 +1,4 @@
1
- import katex from 'katex';
1
+ import katex from './katex-delimiters.mjs';
2
2
 
3
3
  import { BASE_LIBRARY } from './base-library.mjs';
4
4
  import { PARENTHESIZED_FUNCTIONS } from './base-operators.mjs';
@@ -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);
@@ -386,6 +452,19 @@ function trimTrailingWhitespace(latex, start, end) {
386
452
  return trimmed;
387
453
  }
388
454
 
455
+ /**
456
+ * KaTeX gives a node it produced by expanding a macro a `loc` from the lexer
457
+ * over the *macro body*, not over the source being scanned: `\vdots` expands
458
+ * to `{\varvdots\rule{0pt}{15pt}}`, and its `\varvdots` atom arrives with
459
+ * `loc.start = 1`, `loc.end = 10` — offsets into that 27-character body.
460
+ * Those numbers are valid indices into any longer source, so they read as a
461
+ * real span pointing at unrelated text. Every `loc` therefore has to prove it
462
+ * belongs to the string this pass parsed before its offsets are believed.
463
+ */
464
+ function isAuthoredLocation(loc, latex) {
465
+ return loc?.lexer?.input === latex;
466
+ }
467
+
389
468
  function nodeSpan(node, latex) {
390
469
  let start = Number.POSITIVE_INFINITY;
391
470
  let end = Number.NEGATIVE_INFINITY;
@@ -397,7 +476,8 @@ function nodeSpan(node, latex) {
397
476
  if (
398
477
  value.loc &&
399
478
  Number.isInteger(value.loc.start) &&
400
- Number.isInteger(value.loc.end)
479
+ Number.isInteger(value.loc.end) &&
480
+ isAuthoredLocation(value.loc, latex)
401
481
  ) {
402
482
  start = Math.min(start, value.loc.start);
403
483
  end = Math.max(end, value.loc.end);
@@ -437,6 +517,9 @@ function isIdentifierNode(node) {
437
517
  if (node.type !== 'mathord' && node.type !== 'textord') return false;
438
518
  if (typeof node.text !== 'string') return false;
439
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;
440
523
  return /^(?:\p{L}|\\[A-Za-z]+)$/u.test(node.text);
441
524
  }
442
525
 
@@ -447,16 +530,198 @@ const sameSpan = (left, right) =>
447
530
  const overlaps = (left, right) =>
448
531
  left.start < right.end && right.start < left.end;
449
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
+
450
709
  /**
451
710
  * Walk the parse tree once, collecting: every maximal run of sibling nodes
452
711
  * (`containers`, where a canonical form is matched), every identifier atom with
453
- * its source span, and the spans of every sub/superscript (so a symbol nested
454
- * 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.
455
719
  */
456
720
  function collectSourceStructure(tree, latex) {
457
721
  const containers = [];
458
722
  const identifiers = [];
459
723
  const scriptSpans = [];
724
+ const decorations = [];
460
725
  const seenContainers = new Set();
461
726
  const seenIdentifiers = new Set();
462
727
 
@@ -471,10 +736,12 @@ function collectSourceStructure(tree, latex) {
471
736
  containers.push(usable);
472
737
  }
473
738
 
474
- function visitValue(value, ignored = false) {
739
+ function visitValue(value, ignored = false, context = ROOT_CONTEXT) {
475
740
  if (Array.isArray(value)) {
476
- if (!ignored && value.every(isNode)) addContainer(value);
477
- 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);
478
745
  return;
479
746
  }
480
747
  if (!isNode(value)) return;
@@ -495,6 +762,8 @@ function collectSourceStructure(tree, latex) {
495
762
  node: value,
496
763
  signature: sequenceSignature([value]),
497
764
  ...span,
765
+ decorations: context.decorations,
766
+ font: context.font,
498
767
  });
499
768
  }
500
769
  }
@@ -506,32 +775,126 @@ function collectSourceStructure(tree, latex) {
506
775
  }
507
776
  }
508
777
 
509
- 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;
510
797
  if (Array.isArray(child)) {
511
- if (!ignoredBody && child.every(isNode)) addContainer(child);
512
- 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
+ }
513
802
  } else if (isNode(child)) {
514
- if (!ignoredBody) addContainer([child]);
515
- visitValue(child, ignoredBody);
803
+ if (recordContainers) addContainer([child]);
804
+ visitValue(child, ignoredBody, childContext);
516
805
  }
517
806
  }
518
807
  }
519
808
 
520
809
  addContainer(tree);
521
810
  visitValue(tree);
522
- return { containers, identifiers, scriptSpans };
811
+ return { containers, identifiers, scriptSpans, decorations };
523
812
  }
524
813
 
525
814
  // --- base-glyph signatures ------------------------------------------
526
815
 
527
816
  function identifierBaseSignature(node) {
528
817
  if (isIdentifierNode(node)) return sequenceSignature([node]);
529
- 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
+ ) {
530
825
  return sequenceSignature([node.base]);
531
826
  }
532
827
  return undefined;
533
828
  }
534
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
+
535
898
  /**
536
899
  * The shorter page form a parameterized symbol also permits: the bare base
537
900
  * glyph of `t_k` / `r_m` / `j^{(m)}` (one significant node) or the head of a
@@ -609,7 +972,7 @@ function unwrapEnclosingGroupSpan(latex, span) {
609
972
  }
610
973
 
611
974
  const FONT_WRAPPER_OPEN =
612
- /\\(?:mathbb|mathcal|mathfrak|mathscr|mathrm|mathbf|mathit)\{$/;
975
+ /\\(?:mathbb|mathcal|mathfrak|mathscr|mathrm|mathbf|mathit|mathnormal|mathsf|mathtt|boldsymbol|bm|bold|Bbb|frak)\{$/;
613
976
 
614
977
  /**
615
978
  * KaTeX puts no `loc` on a font node (`\mathbb{E}`, `\mathrm{SST}`, …), so a
@@ -652,7 +1015,7 @@ function isSafeScriptNesting(inner, outer, scriptSpans) {
652
1015
  * registered. This is deliberately *not* `collectSourceStructure`'s
653
1016
  * `identifiers` list: that list is built for scanning *page* math, where a
654
1017
  * `\mathrm{…}` body is correctly treated as opaque (never a bindable atom on
655
- * 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
656
1019
  * gate the *definition's own* registration wrongly excluded every glyph whose
657
1020
  * whole declared form is a roman label (`\mathrm{SSR}`, `\mathrm{SST}`, …):
658
1021
  * such a definition produced zero `identifiers`, so it never entered
@@ -689,6 +1052,7 @@ function buildGlyphIndex(definitions) {
689
1052
  const bySignature = new Map();
690
1053
  const byBase = new Map();
691
1054
  const ambiguousBase = new Set();
1055
+ const operators = new Map();
692
1056
  let maxNodeLength = 0;
693
1057
 
694
1058
  for (const [index, value] of definitions.entries()) {
@@ -725,6 +1089,22 @@ function buildGlyphIndex(definitions) {
725
1089
  const entry = { key, notation };
726
1090
  byKey.set(key, entry);
727
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
+
728
1108
  const signature = sequenceSignature(tree);
729
1109
  if (hasBindableGlyph(tree) && tree.length > 0) {
730
1110
  const clash = bySignature.get(signature);
@@ -749,7 +1129,7 @@ function buildGlyphIndex(definitions) {
749
1129
  }
750
1130
  }
751
1131
 
752
- return { byKey, bySignature, byBase, ambiguousBase, maxNodeLength };
1132
+ return { byKey, bySignature, byBase, ambiguousBase, operators, maxNodeLength };
753
1133
  }
754
1134
 
755
1135
  /** Bind whole canonical forms: greedy longest match, left to right, per run. */
@@ -769,7 +1149,9 @@ function matchCanonicalForms(latex, strippedSource, containers, glyphs) {
769
1149
  const nodes = container.slice(cursor, cursor + length);
770
1150
  const key = glyphs.bySignature.get(sequenceSignature(nodes));
771
1151
  if (key === undefined) continue;
772
- 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);
773
1155
  if (!rawSpan) continue;
774
1156
  const span = expandFontWrapperSpan(
775
1157
  latex,
@@ -823,6 +1205,9 @@ function matchBaseGlyphs(latex, identifiers, canonical, glyphs, scriptSpans) {
823
1205
 
824
1206
  const cover = canonical.find((binding) => contains(binding, identifier));
825
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;
826
1211
 
827
1212
  const baseSignature = identifier.signature;
828
1213
  if (glyphs.ambiguousBase.has(baseSignature)) {
@@ -885,26 +1270,61 @@ function injectScopeBindings(latex, bindings) {
885
1270
  return output;
886
1271
  }
887
1272
 
888
- function unresolvedIdentifiers(latex, identifiers, bindings) {
889
- return identifiers
890
- .filter((identifier) => {
891
- const token = latex.slice(identifier.start, identifier.end);
892
- return (
893
- !BASE_LIBRARY.has(token) &&
894
- !bindings.some((binding) => contains(binding, identifier))
895
- );
896
- })
897
- .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 }) => {
898
1287
  // Report `\mathbb{E}`, not a bare `E`, so the fix hint points at the
899
1288
  // whole token the author has to wrap in `\explain`.
900
1289
  const wrapped = expandFontWrapperSpan(latex, { start, end });
901
1290
  return {
902
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
+ ),
903
1297
  start: wrapped.start,
904
1298
  end: wrapped.end,
1299
+ ...(font === undefined ? {} : { font }),
905
1300
  };
906
1301
  })
907
- .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 };
908
1328
  }
909
1329
 
910
1330
  // --- N13: nonlinear functions take a parenthesized argument --------------
@@ -1148,14 +1568,24 @@ function findNestedBareDelimiters(tree) {
1148
1568
  * Resolve and annotate one KaTeX expression against a page's flat glyph table.
1149
1569
  * Source offsets in the result always refer to the original, unmodified LaTeX.
1150
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
+ *
1151
1579
  * @param {string} latex
1152
1580
  * @param {Array<{key: string, notation: string}>} definitions
1581
+ * @param {{ suspended?: readonly string[] }} [options]
1153
1582
  */
1154
- export function resolveMathGlyphs(latex, definitions) {
1583
+ export function resolveMathGlyphs(latex, definitions, options = {}) {
1155
1584
  if (typeof latex !== 'string') {
1156
1585
  fail('invalid-latex-input', 'Math source must be a string.');
1157
1586
  }
1158
1587
 
1588
+ const suspended = new Set(options.suspended ?? []);
1159
1589
  const glyphs = buildGlyphIndex(definitions);
1160
1590
  const explicitCalls = findExplainCalls(latex, glyphs.byKey);
1161
1591
  // The source KaTeX actually parses — `\explain{key}{` prefixes blanked to
@@ -1192,10 +1622,8 @@ export function resolveMathGlyphs(latex, definitions) {
1192
1622
  { violations: nestingViolations },
1193
1623
  );
1194
1624
  }
1195
- const { containers, identifiers, scriptSpans } = collectSourceStructure(
1196
- tree,
1197
- strippedSource,
1198
- );
1625
+ const { containers, identifiers, scriptSpans, decorations } =
1626
+ collectSourceStructure(tree, strippedSource);
1199
1627
 
1200
1628
  // A canonical container match can be found by seeing through transparent
1201
1629
  // ordgroup/font wrapping into a structural sibling of an explicit
@@ -1223,7 +1651,48 @@ export function resolveMathGlyphs(latex, definitions) {
1223
1651
  scriptSpans,
1224
1652
  );
1225
1653
 
1226
- 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]
1227
1696
  .sort(
1228
1697
  (left, right) =>
1229
1698
  left.start - right.start ||
@@ -1265,7 +1734,13 @@ export function resolveMathGlyphs(latex, definitions) {
1265
1734
  match,
1266
1735
  token,
1267
1736
  })),
1268
- unresolved: unresolvedIdentifiers(latex, identifiers, bindings),
1737
+ unresolved: unresolvedIdentifiers(
1738
+ latex,
1739
+ identifiers,
1740
+ bindings,
1741
+ suspendedDecorations,
1742
+ ),
1743
+ decorations: decorationResults,
1269
1744
  };
1270
1745
  }
1271
1746
 
@@ -1369,18 +1844,92 @@ export function collectOperators(latex) {
1369
1844
  return operators;
1370
1845
  }
1371
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
+
1372
1921
  // --- named roman labels ----------------------------------------------
1373
1922
 
1374
1923
  /**
1375
1924
  * Every multi-letter `\mathrm{…}` label in one KaTeX expression (`KE`, `PV`,
1376
1925
  * `opt`) — the upright-label counterpart to {@link collectOperators}. A
1377
- * single-letter body (`\mathrm{d}`, the N7 differential) is not collected:
1926
+ * single-letter body (`\mathrm{d}`, the rule-N2 differential) is not collected:
1378
1927
  * that one is a fixed typographic operator, not a named quantity a reader
1379
1928
  * would need explained.
1380
1929
  *
1381
1930
  * Pure read: nothing here rewrites the source. Used by the
1382
1931
  * `roman-label-explained` consistency check (rule N10); the glyph resolver
1383
- * still skips `\mathrm{…}` bodies whole ([N3](../../docs/authoring/notation.md#n3)).
1932
+ * still skips `\mathrm{…}` bodies whole ([N10](../../docs/authoring/notation.md#n10)).
1384
1933
  *
1385
1934
  * Same `stripNotationMarkers` preprocessing as {@link collectOperators}, and
1386
1935
  * for the same reason: an unstripped `\explain{…}{…}` elsewhere in the span