explico 0.4.0 → 0.6.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.
- package/CHANGELOG.md +382 -1
- package/README.md +103 -8
- package/dist/compiler/astro.d.ts +9 -1
- package/dist/compiler/astro.js +182 -3
- package/dist/compiler/cli.d.ts +13 -0
- package/dist/compiler/cli.js +28 -4
- package/dist/compiler/collections.d.ts +3 -2
- package/dist/compiler/collections.js +28 -1
- package/dist/compiler/course-config.d.ts +88 -1
- package/dist/compiler/course-config.js +35 -1
- package/dist/compiler/exports/standalone-html.d.ts +34 -0
- package/dist/compiler/exports/standalone-html.js +803 -0
- package/dist/compiler/manifest.d.ts +98 -6
- package/dist/compiler/manifest.js +154 -17
- package/dist/compiler/resolution-report.d.ts +4 -0
- package/dist/reference/base-abbreviations.d.mts +30 -0
- package/dist/reference/base-abbreviations.mjs +60 -0
- package/dist/reference/base-operators.d.mts +15 -0
- package/dist/reference/base-operators.mjs +40 -0
- package/dist/reference/base-roman-labels.d.mts +35 -0
- package/dist/reference/base-roman-labels.mjs +43 -0
- package/dist/reference/consistency.d.ts +8 -2
- package/dist/reference/consistency.js +50 -3
- package/dist/reference/diagrams-validate.d.ts +21 -0
- package/dist/reference/diagrams-validate.js +13 -0
- package/dist/reference/equations.d.mts +10 -15
- package/dist/reference/equations.mjs +67 -14
- package/dist/reference/gate-abbreviations.d.ts +17 -0
- package/dist/reference/gate-abbreviations.js +64 -0
- package/dist/reference/gate-heading-length.d.ts +12 -0
- package/dist/reference/gate-heading-length.js +79 -0
- package/dist/reference/gate-math.d.ts +32 -5
- package/dist/reference/gate-math.js +6 -2
- package/dist/reference/gate-prose-notation.d.ts +60 -0
- package/dist/reference/gate-prose-notation.js +125 -0
- package/dist/reference/keyed-float-validate.d.ts +5 -5
- package/dist/reference/keyed-jsx-floats.d.mts +7 -12
- package/dist/reference/keyed-jsx-floats.mjs +50 -25
- package/dist/reference/knowledge-checks-validate.d.ts +35 -0
- package/dist/reference/knowledge-checks-validate.js +78 -0
- package/dist/reference/knowledge-checks.d.mts +25 -0
- package/dist/reference/knowledge-checks.mjs +45 -0
- package/dist/reference/lab-math-scope.d.ts +9 -1
- package/dist/reference/lab-math-scope.js +32 -4
- package/dist/reference/math-glyphs.d.mts +42 -0
- package/dist/reference/math-glyphs.mjs +548 -40
- package/dist/reference/mdx-attributes.d.mts +13 -0
- package/dist/reference/mdx-attributes.mjs +38 -0
- package/dist/reference/mixed-content.d.ts +17 -0
- package/dist/reference/mixed-content.js +29 -0
- package/dist/reference/page-notation.d.ts +52 -0
- package/dist/reference/page-notation.js +171 -0
- package/dist/reference/raw-floats-validate.d.ts +18 -0
- package/dist/reference/raw-floats-validate.js +53 -0
- package/dist/reference/raw-floats.d.mts +18 -0
- package/dist/reference/raw-floats.mjs +129 -0
- package/dist/reference/rehype-heading-anchors.d.mts +1 -1
- package/dist/reference/rehype-heading-anchors.mjs +107 -1
- package/dist/reference/rehype-table-header-labels.d.mts +26 -0
- package/dist/reference/rehype-table-header-labels.mjs +152 -0
- package/dist/reference/remark-abbreviations.d.mts +7 -0
- package/dist/reference/remark-abbreviations.mjs +174 -0
- package/dist/reference/remark-citation.d.mts +5 -1
- package/dist/reference/remark-citation.mjs +0 -0
- package/dist/reference/remark-memo.d.mts +16 -0
- package/dist/reference/remark-memo.mjs +339 -0
- package/dist/reference/remark-notation.d.mts +57 -9
- package/dist/reference/remark-notation.mjs +354 -42
- package/dist/reference/remark-short-note.d.mts +4 -0
- package/dist/reference/remark-short-note.mjs +237 -0
- package/dist/reference/types.d.ts +16 -1
- package/dist/reference/units-vocab.js +4 -0
- package/docs/VERSIONING.md +53 -4
- package/docs/authoring/README.md +10 -0
- package/docs/authoring/agents.md +17 -2
- package/docs/authoring/components.md +138 -0
- package/docs/authoring/migrating.md +275 -7
- package/docs/authoring/notation.md +615 -1
- package/docs/authoring/structure-rules.md +69 -0
- package/docs/authoring/structure.md +120 -8
- package/docs/authoring/visualizations.md +36 -3
- package/package.json +5 -2
- package/src/compiler/astro.ts +208 -3
- package/src/compiler/cli.ts +29 -2
- package/src/compiler/collections.ts +45 -4
- package/src/compiler/course-config.ts +132 -0
- package/src/compiler/exports/standalone-html.ts +963 -0
- package/src/compiler/manifest.ts +281 -22
- package/src/components/HoverPanel.astro +45 -4
- package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
- package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
- package/src/components/assessments/AssessmentRunner.tsx +52 -17
- package/src/components/assessments/AssessmentSet.astro +98 -14
- package/src/components/assessments/RenderedContent.tsx +55 -0
- package/src/components/assessments/assessment-randomization.ts +16 -0
- package/src/components/diagram/Diagram.astro +409 -103
- package/src/components/disclosure/hover-preview.ts +202 -0
- package/src/components/disclosure/persisted-disclosure.ts +66 -0
- package/src/components/examples/CompactExamples.astro +2 -0
- package/src/components/figure/Figure.astro +279 -10
- package/src/components/memo/Memo.astro +453 -0
- package/src/components/memo/ShortNote.astro +223 -0
- package/src/components/notation/NotationFormula.astro +18 -1
- package/src/components/notation/NotationLayer.astro +260 -83
- package/src/components/notation/render-notation-math.ts +39 -2
- package/src/components/notation/safe-math-to-react.ts +50 -0
- package/src/components/reference/FloatRefLayer.astro +233 -15
- package/src/components/starlight/CollapsibleSections.astro +461 -0
- package/src/components/starlight/DiagramEnhancer.astro +385 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
- package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
- package/src/components/starlight/LayoutHeader.astro +50 -0
- package/src/components/starlight/LayoutSocialIcons.astro +41 -2
- package/src/components/starlight/LessonExportActions.astro +436 -0
- package/src/components/starlight/LessonFooter.astro +30 -3
- package/src/components/starlight/section-parts.ts +31 -0
- package/src/components/table/Table.astro +293 -7
- package/src/components/table/TableOverflowExpander.astro +489 -0
- package/src/content-config.ts +36 -1
- package/src/reference/base-abbreviations.mjs +60 -0
- package/src/reference/base-operators.mjs +40 -0
- package/src/reference/base-roman-labels.mjs +43 -0
- package/src/reference/consistency.ts +73 -2
- package/src/reference/diagrams-validate.ts +40 -0
- package/src/reference/equations.mjs +67 -14
- package/src/reference/gate-abbreviations.ts +128 -0
- package/src/reference/gate-heading-length.ts +131 -0
- package/src/reference/gate-math.ts +32 -7
- package/src/reference/gate-prose-notation.ts +224 -0
- package/src/reference/keyed-float-validate.ts +5 -5
- package/src/reference/keyed-jsx-floats.mjs +50 -25
- package/src/reference/knowledge-checks-validate.ts +130 -0
- package/src/reference/knowledge-checks.mjs +45 -0
- package/src/reference/lab-math-scope.ts +45 -3
- package/src/reference/math-glyphs.mjs +548 -40
- package/src/reference/mdx-attributes.mjs +38 -0
- package/src/reference/mixed-content.ts +69 -0
- package/src/reference/page-notation.ts +252 -0
- package/src/reference/raw-floats-validate.ts +103 -0
- package/src/reference/raw-floats.mjs +129 -0
- package/src/reference/rehype-heading-anchors.mjs +107 -1
- package/src/reference/rehype-table-header-labels.mjs +152 -0
- package/src/reference/remark-abbreviations.mjs +174 -0
- package/src/reference/remark-citation.mjs +0 -0
- package/src/reference/remark-memo.mjs +339 -0
- package/src/reference/remark-notation.mjs +354 -42
- package/src/reference/remark-short-note.mjs +237 -0
- package/src/reference/types.ts +16 -0
- package/src/reference/units-vocab.ts +4 -0
- package/src/styles/global.css +996 -50
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import katex from 'katex';
|
|
2
2
|
|
|
3
3
|
import { BASE_LIBRARY } from './base-library.mjs';
|
|
4
|
+
import { PARENTHESIZED_FUNCTIONS } from './base-operators.mjs';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Page-level glyph-map resolver (cleanup plan, D1 decision 2026-09-02).
|
|
@@ -181,6 +182,52 @@ function readBracedGroup(value, start) {
|
|
|
181
182
|
return undefined;
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
const MARKER_COMMANDS = [EXPLAIN_COMMAND, '\\slot'];
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Strip Explico's own `\explain{key}{latex}` / `\slot{key}{latex}` markers
|
|
189
|
+
* (see `katex-options.mjs`) down to the LaTeX they wrap, recursively. Lesson
|
|
190
|
+
* math — both a notation card's `formula` and an equation written directly
|
|
191
|
+
* in prose — carries these markers inline so every symbol is hoverable; a
|
|
192
|
+
* reader copying the equation out of this system wants the plain math
|
|
193
|
+
* underneath, not internal notation-key plumbing that means nothing to
|
|
194
|
+
* KaTeX, MathJax, or LaTeX anywhere else. Shared by `render-notation-math.ts`
|
|
195
|
+
* (a notation card's copy button) and `remark-notation.mjs` (a numbered
|
|
196
|
+
* display equation's copy button) — kept here, rather than in either, so
|
|
197
|
+
* neither module has to import the other.
|
|
198
|
+
*/
|
|
199
|
+
export function stripNotationMarkers(latex) {
|
|
200
|
+
let result = '';
|
|
201
|
+
let cursor = 0;
|
|
202
|
+
|
|
203
|
+
while (cursor < latex.length) {
|
|
204
|
+
const command = MARKER_COMMANDS.find(
|
|
205
|
+
(name) =>
|
|
206
|
+
latex.startsWith(name, cursor) &&
|
|
207
|
+
!isEscaped(latex, cursor) &&
|
|
208
|
+
!/[A-Za-z@]/.test(latex[cursor + name.length] ?? ''),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const keyGroup = command
|
|
212
|
+
? readBracedGroup(latex, skipWhitespace(latex, cursor + command.length))
|
|
213
|
+
: undefined;
|
|
214
|
+
const contentGroup = keyGroup
|
|
215
|
+
? readBracedGroup(latex, skipWhitespace(latex, keyGroup.end))
|
|
216
|
+
: undefined;
|
|
217
|
+
|
|
218
|
+
if (contentGroup) {
|
|
219
|
+
result += stripNotationMarkers(contentGroup.content);
|
|
220
|
+
cursor = contentGroup.end;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
result += latex[cursor];
|
|
225
|
+
cursor += 1;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
|
|
184
231
|
/**
|
|
185
232
|
* Every `\explain{key}{latex}` in the source (the escape hatch). Each key must
|
|
186
233
|
* already be in the page glyph table. The returned span points at the wrapped
|
|
@@ -322,7 +369,24 @@ function sequenceSignature(nodes) {
|
|
|
322
369
|
|
|
323
370
|
// --- source spans -----------------------------------------------------
|
|
324
371
|
|
|
325
|
-
|
|
372
|
+
/**
|
|
373
|
+
* KaTeX's lexer swallows trailing insignificant whitespace after a
|
|
374
|
+
* control-word command (`\epsilon`, `\mathrm`, …) into that token's own
|
|
375
|
+
* `loc.end` — including whitespace this resolver introduces itself when it
|
|
376
|
+
* blanks an `\explain{key}{` prefix down to spaces (`sourceWithoutExplainPrefixes`).
|
|
377
|
+
* A symbol's own text never legitimately ends in whitespace, so any span read
|
|
378
|
+
* off `loc` is trimmed back to its last non-space character before a caller
|
|
379
|
+
* treats it as the symbol's real boundary — otherwise a bare symbol sitting
|
|
380
|
+
* right before an `\explain{…}{…}` call gets a span that reaches past itself
|
|
381
|
+
* and into that call's own command text.
|
|
382
|
+
*/
|
|
383
|
+
function trimTrailingWhitespace(latex, start, end) {
|
|
384
|
+
let trimmed = end;
|
|
385
|
+
while (trimmed > start && /\s/.test(latex[trimmed - 1])) trimmed -= 1;
|
|
386
|
+
return trimmed;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function nodeSpan(node, latex) {
|
|
326
390
|
let start = Number.POSITIVE_INFINITY;
|
|
327
391
|
let end = Number.NEGATIVE_INFINITY;
|
|
328
392
|
const visited = new Set();
|
|
@@ -347,13 +411,12 @@ function nodeSpan(node) {
|
|
|
347
411
|
}
|
|
348
412
|
|
|
349
413
|
visit(node);
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
: undefined;
|
|
414
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) return undefined;
|
|
415
|
+
return { start, end: trimTrailingWhitespace(latex, start, end) };
|
|
353
416
|
}
|
|
354
417
|
|
|
355
|
-
function sequenceSpan(nodes) {
|
|
356
|
-
const spans = nodes.map(nodeSpan).filter(Boolean);
|
|
418
|
+
function sequenceSpan(nodes, latex) {
|
|
419
|
+
const spans = nodes.map((node) => nodeSpan(node, latex)).filter(Boolean);
|
|
357
420
|
if (spans.length === 0) return undefined;
|
|
358
421
|
return {
|
|
359
422
|
start: Math.min(...spans.map((span) => span.start)),
|
|
@@ -381,6 +444,8 @@ const contains = (outer, inner) =>
|
|
|
381
444
|
outer.start <= inner.start && inner.end <= outer.end;
|
|
382
445
|
const sameSpan = (left, right) =>
|
|
383
446
|
left.start === right.start && left.end === right.end;
|
|
447
|
+
const overlaps = (left, right) =>
|
|
448
|
+
left.start < right.end && right.start < left.end;
|
|
384
449
|
|
|
385
450
|
/**
|
|
386
451
|
* Walk the parse tree once, collecting: every maximal run of sibling nodes
|
|
@@ -388,7 +453,7 @@ const sameSpan = (left, right) =>
|
|
|
388
453
|
* its source span, and the spans of every sub/superscript (so a symbol nested
|
|
389
454
|
* in a script can still be marked).
|
|
390
455
|
*/
|
|
391
|
-
function collectSourceStructure(tree) {
|
|
456
|
+
function collectSourceStructure(tree, latex) {
|
|
392
457
|
const containers = [];
|
|
393
458
|
const identifiers = [];
|
|
394
459
|
const scriptSpans = [];
|
|
@@ -398,7 +463,7 @@ function collectSourceStructure(tree) {
|
|
|
398
463
|
function addContainer(nodes) {
|
|
399
464
|
const usable = nodes.filter(isNode);
|
|
400
465
|
if (usable.length === 0) return;
|
|
401
|
-
const span = sequenceSpan(usable);
|
|
466
|
+
const span = sequenceSpan(usable, latex);
|
|
402
467
|
if (!span) return;
|
|
403
468
|
const identity = `${span.start}:${span.end}:${usable.length}:${sequenceSignature(usable)}`;
|
|
404
469
|
if (seenContainers.has(identity)) return;
|
|
@@ -423,7 +488,7 @@ function collectSourceStructure(tree) {
|
|
|
423
488
|
IGNORED_MATH_TOKENS.has(fontTokenText(value));
|
|
424
489
|
|
|
425
490
|
if (!ignored && isIdentifierNode(value)) {
|
|
426
|
-
const span = nodeSpan(value);
|
|
491
|
+
const span = nodeSpan(value, latex);
|
|
427
492
|
if (span && !seenIdentifiers.has(`${span.start}:${span.end}`)) {
|
|
428
493
|
seenIdentifiers.add(`${span.start}:${span.end}`);
|
|
429
494
|
identifiers.push({
|
|
@@ -436,7 +501,7 @@ function collectSourceStructure(tree) {
|
|
|
436
501
|
|
|
437
502
|
if (value.type === 'supsub') {
|
|
438
503
|
for (const script of [value.sub, value.sup]) {
|
|
439
|
-
const span = nodeSpan(script);
|
|
504
|
+
const span = nodeSpan(script, latex);
|
|
440
505
|
if (span) scriptSpans.push(span);
|
|
441
506
|
}
|
|
442
507
|
}
|
|
@@ -485,19 +550,60 @@ function canonicalBaseSignature(tree) {
|
|
|
485
550
|
return identifierBaseSignature(head);
|
|
486
551
|
}
|
|
487
552
|
|
|
553
|
+
/**
|
|
554
|
+
* When a span sits exactly at the payload of an `\explain{key}{…}` / `\slot{key}{…}`
|
|
555
|
+
* marker — its whole content, brace to brace — peel the marker off, mirroring
|
|
556
|
+
* `stripNotationMarkers`. Signature matching sees straight through ordgroup
|
|
557
|
+
* *and* an already-explicit call (both are transparent wrapping for structural
|
|
558
|
+
* comparison), so a canonical container match can legitimately resolve to a
|
|
559
|
+
* span that, in the raw source, is entirely one explicit call; without this,
|
|
560
|
+
* `unwrapEnclosingGroupSpan` stops at the call's leading `\`, leaving a span
|
|
561
|
+
* that swallows the whole `\explain{key}{` command text as if it were plain
|
|
562
|
+
* LaTeX (see `notation.md`'s aligned-block/adjacency traps).
|
|
563
|
+
*/
|
|
564
|
+
function unwrapMarkerSpan(latex, span) {
|
|
565
|
+
const { start, end } = span;
|
|
566
|
+
const command = MARKER_COMMANDS.find(
|
|
567
|
+
(name) =>
|
|
568
|
+
latex.startsWith(name, start) &&
|
|
569
|
+
!isEscaped(latex, start) &&
|
|
570
|
+
!/[A-Za-z@]/.test(latex[start + name.length] ?? ''),
|
|
571
|
+
);
|
|
572
|
+
if (!command) return undefined;
|
|
573
|
+
|
|
574
|
+
const keyGroup = readBracedGroup(latex, skipWhitespace(latex, start + command.length));
|
|
575
|
+
const contentGroup = keyGroup
|
|
576
|
+
? readBracedGroup(latex, skipWhitespace(latex, keyGroup.end))
|
|
577
|
+
: undefined;
|
|
578
|
+
if (!contentGroup || contentGroup.end !== end) return undefined;
|
|
579
|
+
|
|
580
|
+
return { start: contentGroup.contentStart, end: contentGroup.contentEnd };
|
|
581
|
+
}
|
|
582
|
+
|
|
488
583
|
/**
|
|
489
584
|
* KaTeX puts source braces inside an ordgroup node's `loc`, but signatures
|
|
490
|
-
* treat ordgroups as transparent. Peel a fully-enclosing `{…}`
|
|
491
|
-
*
|
|
492
|
-
* `\frac
|
|
585
|
+
* treat ordgroups as transparent. Peel a fully-enclosing `{…}` (or an
|
|
586
|
+
* already-explicit `\explain{key}{…}`/`\slot{key}{…}` marker, {@link
|
|
587
|
+
* unwrapMarkerSpan}) off a matched span so `\frac{j}{m}` marks as
|
|
588
|
+
* `\frac{\explain{…}{j}}{…}`, not `\frac\explain{…}{{j}}…`.
|
|
493
589
|
*/
|
|
494
590
|
function unwrapEnclosingGroupSpan(latex, span) {
|
|
495
591
|
let { start, end } = span;
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
592
|
+
for (;;) {
|
|
593
|
+
if (latex[start] === '{') {
|
|
594
|
+
const group = readBracedGroup(latex, start);
|
|
595
|
+
if (group && group.end === end) {
|
|
596
|
+
start = group.contentStart;
|
|
597
|
+
end = group.contentEnd;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
const marker = unwrapMarkerSpan(latex, { start, end });
|
|
602
|
+
if (marker) {
|
|
603
|
+
({ start, end } = marker);
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
break;
|
|
501
607
|
}
|
|
502
608
|
return { start, end };
|
|
503
609
|
}
|
|
@@ -506,19 +612,26 @@ const FONT_WRAPPER_OPEN =
|
|
|
506
612
|
/\\(?:mathbb|mathcal|mathfrak|mathscr|mathrm|mathbf|mathit)\{$/;
|
|
507
613
|
|
|
508
614
|
/**
|
|
509
|
-
* KaTeX puts no `loc` on a `\mathbb{
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
615
|
+
* KaTeX puts no `loc` on a font node (`\mathbb{E}`, `\mathrm{SST}`, …), so a
|
|
616
|
+
* span derived from one begins at its inner content, not its own command
|
|
617
|
+
* text. When a matched span sits exactly inside a font command's braces —
|
|
618
|
+
* covering that group's entire content, of any length — widen it to wrap the
|
|
619
|
+
* whole token (`\mathbb{E}`, `\mathrm{SST}`) so the injected `\explain{…}{…}`
|
|
620
|
+
* stays brace-balanced. (Originally restricted to a single-letter body —
|
|
621
|
+
* `\mathbb{E}` — which left a multi-letter roman label like `\mathrm{SST}`
|
|
622
|
+
* wrapped as `\mathrm{\explain{…}{SST}}` instead of the correct
|
|
623
|
+
* `\explain{…}{\mathrm{SST}}`.)
|
|
513
624
|
*/
|
|
514
625
|
function expandFontWrapperSpan(latex, span) {
|
|
515
626
|
const open = latex.slice(0, span.start).match(FONT_WRAPPER_OPEN);
|
|
516
627
|
if (!open) return span;
|
|
517
628
|
const group = readBracedGroup(latex, span.start - 1);
|
|
518
|
-
if (!group || group.
|
|
629
|
+
if (!group || group.contentStart !== span.start || group.contentEnd !== span.end) {
|
|
630
|
+
return span;
|
|
631
|
+
}
|
|
519
632
|
return {
|
|
520
633
|
start: span.start - open[0].length,
|
|
521
|
-
end:
|
|
634
|
+
end: group.end,
|
|
522
635
|
};
|
|
523
636
|
}
|
|
524
637
|
|
|
@@ -532,6 +645,33 @@ function isSafeScriptNesting(inner, outer, scriptSpans) {
|
|
|
532
645
|
|
|
533
646
|
// --- glyph table + matching -----------------------------------------
|
|
534
647
|
|
|
648
|
+
/**
|
|
649
|
+
* Whether a definition's own canonical LaTeX has content worth indexing for
|
|
650
|
+
* bare-occurrence matching — as opposed to a degenerate form (bare punctuation
|
|
651
|
+
* or a relation like `=`) that would over-match unrelated page text if
|
|
652
|
+
* registered. This is deliberately *not* `collectSourceStructure`'s
|
|
653
|
+
* `identifiers` list: that list is built for scanning *page* math, where a
|
|
654
|
+
* `\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
|
|
656
|
+
* gate the *definition's own* registration wrongly excluded every glyph whose
|
|
657
|
+
* whole declared form is a roman label (`\mathrm{SSR}`, `\mathrm{SST}`, …):
|
|
658
|
+
* such a definition produced zero `identifiers`, so it never entered
|
|
659
|
+
* `bySignature`/`byBase` and could never auto-resolve at a bare occurrence,
|
|
660
|
+
* regardless of where that occurrence sat on the page. A named operator
|
|
661
|
+
* (`\operatorname{SE}`) is real content the same way; a bare `=` or `+` is not.
|
|
662
|
+
*/
|
|
663
|
+
function hasBindableGlyph(value) {
|
|
664
|
+
if (Array.isArray(value)) return value.some(hasBindableGlyph);
|
|
665
|
+
if (!isNode(value)) return false;
|
|
666
|
+
if (isIdentifierNode(value)) return true;
|
|
667
|
+
if (value.type === 'font' && FONT_COMMANDS.has(value.font)) return true;
|
|
668
|
+
if (value.type === 'op' || value.type === 'operatorname') return true;
|
|
669
|
+
for (const key of SOURCE_CHILD_KEYS) {
|
|
670
|
+
if (hasBindableGlyph(value[key])) return true;
|
|
671
|
+
}
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
|
|
535
675
|
/**
|
|
536
676
|
* Index the page glyph table by structural signature. Because one glyph carries
|
|
537
677
|
* one meaning per page, each signature maps to exactly one key; a genuine
|
|
@@ -586,8 +726,7 @@ function buildGlyphIndex(definitions) {
|
|
|
586
726
|
byKey.set(key, entry);
|
|
587
727
|
|
|
588
728
|
const signature = sequenceSignature(tree);
|
|
589
|
-
|
|
590
|
-
if (hasIdentifier && tree.length > 0) {
|
|
729
|
+
if (hasBindableGlyph(tree) && tree.length > 0) {
|
|
591
730
|
const clash = bySignature.get(signature);
|
|
592
731
|
if (clash && clash !== key) {
|
|
593
732
|
fail(
|
|
@@ -614,7 +753,7 @@ function buildGlyphIndex(definitions) {
|
|
|
614
753
|
}
|
|
615
754
|
|
|
616
755
|
/** Bind whole canonical forms: greedy longest match, left to right, per run. */
|
|
617
|
-
function matchCanonicalForms(latex, containers, glyphs) {
|
|
756
|
+
function matchCanonicalForms(latex, strippedSource, containers, glyphs) {
|
|
618
757
|
const bindings = [];
|
|
619
758
|
const claimed = [];
|
|
620
759
|
|
|
@@ -630,7 +769,7 @@ function matchCanonicalForms(latex, containers, glyphs) {
|
|
|
630
769
|
const nodes = container.slice(cursor, cursor + length);
|
|
631
770
|
const key = glyphs.bySignature.get(sequenceSignature(nodes));
|
|
632
771
|
if (key === undefined) continue;
|
|
633
|
-
const rawSpan = sequenceSpan(nodes);
|
|
772
|
+
const rawSpan = sequenceSpan(nodes, strippedSource);
|
|
634
773
|
if (!rawSpan) continue;
|
|
635
774
|
const span = expandFontWrapperSpan(
|
|
636
775
|
latex,
|
|
@@ -768,6 +907,243 @@ function unresolvedIdentifiers(latex, identifiers, bindings) {
|
|
|
768
907
|
.sort((left, right) => left.start - right.start || left.end - right.end);
|
|
769
908
|
}
|
|
770
909
|
|
|
910
|
+
// --- N13: nonlinear functions take a parenthesized argument --------------
|
|
911
|
+
|
|
912
|
+
// Opening delimiters that visibly bracket a function's argument. A bare `{`
|
|
913
|
+
// (KaTeX `ordgroup`) does not count — it renders no visible mark at all, so
|
|
914
|
+
// `\sin{x}` reads identically to the bare `\sin x` this rule forbids.
|
|
915
|
+
const FUNCTION_ARG_OPENERS = new Set(['(', '[', '\\{']);
|
|
916
|
+
|
|
917
|
+
// Node types that plausibly start a bare (unparenthesized) argument right
|
|
918
|
+
// after a function name — a letter, a digit, an invisible `{…}` group,
|
|
919
|
+
// another function, an exponent/subscript structure, a fraction, a root, or
|
|
920
|
+
// a styled/labelled run (`\mathrm{…}`, `\color{…}{…}`, …). Anything else
|
|
921
|
+
// (a relation, a binary operator, closing punctuation, or nothing at all) is
|
|
922
|
+
// left alone: the function has no argument in this position to complain
|
|
923
|
+
// about.
|
|
924
|
+
const BARE_ARGUMENT_NODE_TYPES = new Set([
|
|
925
|
+
'mathord',
|
|
926
|
+
'textord',
|
|
927
|
+
'ordgroup',
|
|
928
|
+
'op',
|
|
929
|
+
'operatorname',
|
|
930
|
+
'supsub',
|
|
931
|
+
'genfrac',
|
|
932
|
+
'sqrt',
|
|
933
|
+
'font',
|
|
934
|
+
'accent',
|
|
935
|
+
'accentUnder',
|
|
936
|
+
'overline',
|
|
937
|
+
'underline',
|
|
938
|
+
'phantom',
|
|
939
|
+
'raisebox',
|
|
940
|
+
'href',
|
|
941
|
+
'color',
|
|
942
|
+
'styling',
|
|
943
|
+
'mclass',
|
|
944
|
+
]);
|
|
945
|
+
|
|
946
|
+
/** `\,`, `\;`, `\!`, `~`, … — spacing that sits between a function and its
|
|
947
|
+
* argument without being the argument itself. */
|
|
948
|
+
function isSpacingNode(node) {
|
|
949
|
+
return isNode(node) && node.type === 'kern';
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/** True when `node` is a visible parenthesis/bracket group opening here. */
|
|
953
|
+
function opensFunctionArgument(node) {
|
|
954
|
+
if (!isNode(node)) return false;
|
|
955
|
+
if (node.type === 'leftright') return true;
|
|
956
|
+
return (
|
|
957
|
+
node.type === 'atom' &&
|
|
958
|
+
node.family === 'open' &&
|
|
959
|
+
FUNCTION_ARG_OPENERS.has(node.text)
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* The bare command name (`sin`, `log`, …) when `node` is a call to one of
|
|
965
|
+
* {@link PARENTHESIZED_FUNCTIONS} — either the `op` node itself, or one
|
|
966
|
+
* wrapped in a `supsub` for `\sin^2 x` / `\log_2 x`.
|
|
967
|
+
*/
|
|
968
|
+
function parenthesizedFunctionName(node) {
|
|
969
|
+
if (!isNode(node)) return null;
|
|
970
|
+
if (node.type === 'op' && typeof node.name === 'string') {
|
|
971
|
+
const name = node.name.replace(/^\\/, '');
|
|
972
|
+
return PARENTHESIZED_FUNCTIONS.has(name) ? name : null;
|
|
973
|
+
}
|
|
974
|
+
if (node.type === 'supsub') return parenthesizedFunctionName(node.base);
|
|
975
|
+
return null;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Every `\sin x`-shaped violation of N13 in one already-parsed KaTeX tree: a
|
|
980
|
+
* {@link PARENTHESIZED_FUNCTIONS} name immediately followed, in its own
|
|
981
|
+
* sibling run, by something that reads as a bare argument rather than a
|
|
982
|
+
* parenthesized (or bracketed) one. Mirrors `collectOperators`'s tree walk,
|
|
983
|
+
* but needs sibling context a plain node visitor doesn't carry, so it walks
|
|
984
|
+
* array bodies explicitly instead of reusing that helper.
|
|
985
|
+
*
|
|
986
|
+
* @returns {string[]} one function name per violation, in document order.
|
|
987
|
+
*/
|
|
988
|
+
function findUnparenthesizedFunctionCalls(tree) {
|
|
989
|
+
const violations = [];
|
|
990
|
+
|
|
991
|
+
const visitSiblings = (nodes) => {
|
|
992
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
993
|
+
const name = parenthesizedFunctionName(nodes[i]);
|
|
994
|
+
if (!name) continue;
|
|
995
|
+
let j = i + 1;
|
|
996
|
+
while (j < nodes.length && isSpacingNode(nodes[j])) j += 1;
|
|
997
|
+
const next = j < nodes.length ? nodes[j] : undefined;
|
|
998
|
+
if (opensFunctionArgument(next)) continue;
|
|
999
|
+
if (isNode(next) && BARE_ARGUMENT_NODE_TYPES.has(next.type)) {
|
|
1000
|
+
violations.push(name);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
const visit = (value) => {
|
|
1006
|
+
if (Array.isArray(value)) {
|
|
1007
|
+
visitSiblings(value);
|
|
1008
|
+
for (const child of value) visit(child);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
if (!isNode(value)) return;
|
|
1012
|
+
for (const key of Object.keys(value)) {
|
|
1013
|
+
if (key === 'loc') continue;
|
|
1014
|
+
visit(value[key]);
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
visit(tree);
|
|
1019
|
+
return violations;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// --- N15: nested visible delimiters need \left/\right sizing -------------
|
|
1023
|
+
|
|
1024
|
+
// The four visible delimiter glyphs this rule regulates, keyed by their
|
|
1025
|
+
// KaTeX source text. `{`/`}` bare is invisible LaTeX grouping, not a visible
|
|
1026
|
+
// mark, so only the escaped, visible `\{`/`\}` counts — the same distinction
|
|
1027
|
+
// `FUNCTION_ARG_OPENERS` above already draws.
|
|
1028
|
+
const NESTED_DELIM_OPEN = new Map([
|
|
1029
|
+
['(', 'paren'],
|
|
1030
|
+
['[', 'bracket'],
|
|
1031
|
+
['\\{', 'brace'],
|
|
1032
|
+
]);
|
|
1033
|
+
const NESTED_DELIM_CLOSE = new Map([
|
|
1034
|
+
[')', 'paren'],
|
|
1035
|
+
[']', 'bracket'],
|
|
1036
|
+
['\\}', 'brace'],
|
|
1037
|
+
]);
|
|
1038
|
+
// Recognised on either side of a `\left…\right` pair so a mixed pair like
|
|
1039
|
+
// `\left.\right|` (an evaluation bar with an invisible left side) still
|
|
1040
|
+
// counts as already-sized.
|
|
1041
|
+
const NESTED_DELIM_CHARS = new Set(['(', ')', '[', ']', '\\{', '\\}', '|']);
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Every N15 violation in one already-parsed KaTeX tree: a bare `(`, `[`,
|
|
1045
|
+
* `\{`, or `|` that either sits inside another of these four delimiters, or
|
|
1046
|
+
* itself contains one, without either side switching to the `\left`/`\right`
|
|
1047
|
+
* auto-sizing form. A `\left…\right` pair is never itself reported — it is
|
|
1048
|
+
* the fix the rule asks for — but a bare delimiter one level inside it still
|
|
1049
|
+
* needs its own `\left`/`\right`, since sizing one level of a nest does not
|
|
1050
|
+
* size the rest.
|
|
1051
|
+
*
|
|
1052
|
+
* Mirrors {@link findUnparenthesizedFunctionCalls}'s tree walk, but needs one
|
|
1053
|
+
* stack carried across the whole recursion (nesting here is "does an
|
|
1054
|
+
* ancestor node in the tree still have an open delimiter", not sibling
|
|
1055
|
+
* adjacency), so it walks the tree itself rather than reusing a sibling-only
|
|
1056
|
+
* visitor.
|
|
1057
|
+
*
|
|
1058
|
+
* `|` has no distinct open/close glyph, so a bare `|` toggles: the first one
|
|
1059
|
+
* at the current depth opens a pipe scope, the next one closes it — matching
|
|
1060
|
+
* how `\lvert x \rvert`-less absolute value is actually written in this
|
|
1061
|
+
* corpus (N9's `\lvert…\rvert` guidance is about *outside*-math prose; a bare
|
|
1062
|
+
* `|` pair is unremarkable inside `$…$` on its own).
|
|
1063
|
+
*
|
|
1064
|
+
* @returns {{ type: string, char: string }[]} one entry per delimiter
|
|
1065
|
+
* instance that must become `\left`/`\right`, in document order.
|
|
1066
|
+
*/
|
|
1067
|
+
function findNestedBareDelimiters(tree) {
|
|
1068
|
+
const stack = [];
|
|
1069
|
+
const violations = [];
|
|
1070
|
+
|
|
1071
|
+
const flag = (entry) => {
|
|
1072
|
+
if (entry.reported) return;
|
|
1073
|
+
entry.reported = true;
|
|
1074
|
+
violations.push({ type: entry.type, char: entry.char });
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
const pushOpen = (type, char, bare) => {
|
|
1078
|
+
const entry = { type, char, bare, reported: false };
|
|
1079
|
+
if (stack.length > 0) {
|
|
1080
|
+
const parent = stack[stack.length - 1];
|
|
1081
|
+
// The parent already contains a nested delimiter: if it is bare, it
|
|
1082
|
+
// now needs sizing too, regardless of whether this new one is sized.
|
|
1083
|
+
if (parent.bare) flag(parent);
|
|
1084
|
+
// This one is nested inside another delimiter's span: if it is bare,
|
|
1085
|
+
// it needs sizing, regardless of whether the parent is already sized.
|
|
1086
|
+
if (bare) flag(entry);
|
|
1087
|
+
}
|
|
1088
|
+
stack.push(entry);
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
const visit = (node) => {
|
|
1092
|
+
if (Array.isArray(node)) {
|
|
1093
|
+
for (const child of node) visit(child);
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
if (!isNode(node)) return;
|
|
1097
|
+
|
|
1098
|
+
// A visible delimiter only counts in math mode — the same character
|
|
1099
|
+
// inside `\text{…}` is display text, not a sizing delimiter (it parses
|
|
1100
|
+
// to a plain `textord` in text mode, never reaching this branch).
|
|
1101
|
+
if (node.type === 'atom' && node.mode === 'math') {
|
|
1102
|
+
if (node.family === 'open' && NESTED_DELIM_OPEN.has(node.text)) {
|
|
1103
|
+
pushOpen(NESTED_DELIM_OPEN.get(node.text), node.text, true);
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
if (node.family === 'close' && NESTED_DELIM_CLOSE.has(node.text)) {
|
|
1107
|
+
stack.pop();
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
if (node.type === 'textord' && node.mode === 'math' && node.text === '|') {
|
|
1112
|
+
const top = stack[stack.length - 1];
|
|
1113
|
+
if (top && top.type === 'pipe') {
|
|
1114
|
+
stack.pop();
|
|
1115
|
+
} else {
|
|
1116
|
+
pushOpen('pipe', '|', true);
|
|
1117
|
+
}
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
if (node.type === 'leftright') {
|
|
1121
|
+
// Already sized. Still an enclosing scope for whatever nests inside it
|
|
1122
|
+
// (a bare delimiter one level in still needs its own sizing), but only
|
|
1123
|
+
// when a tracked glyph is actually on one of its two sides — an
|
|
1124
|
+
// unrelated pair like `\left\langle…\right\rangle` is transparent to
|
|
1125
|
+
// this rule, matching the "with each other" scope of the four glyphs
|
|
1126
|
+
// it names.
|
|
1127
|
+
if (NESTED_DELIM_CHARS.has(node.left) || NESTED_DELIM_CHARS.has(node.right)) {
|
|
1128
|
+
pushOpen('sized', node.left, false);
|
|
1129
|
+
visit(node.body);
|
|
1130
|
+
stack.pop();
|
|
1131
|
+
} else {
|
|
1132
|
+
visit(node.body);
|
|
1133
|
+
}
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
for (const key of Object.keys(node)) {
|
|
1138
|
+
if (key === 'loc') continue;
|
|
1139
|
+
visit(node[key]);
|
|
1140
|
+
}
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
visit(tree);
|
|
1144
|
+
return violations;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
771
1147
|
/**
|
|
772
1148
|
* Resolve and annotate one KaTeX expression against a page's flat glyph table.
|
|
773
1149
|
* Source offsets in the result always refer to the original, unmodified LaTeX.
|
|
@@ -782,13 +1158,62 @@ export function resolveMathGlyphs(latex, definitions) {
|
|
|
782
1158
|
|
|
783
1159
|
const glyphs = buildGlyphIndex(definitions);
|
|
784
1160
|
const explicitCalls = findExplainCalls(latex, glyphs.byKey);
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1161
|
+
// The source KaTeX actually parses — `\explain{key}{` prefixes blanked to
|
|
1162
|
+
// spaces, same length as `latex`. Every `.loc` offset below comes from
|
|
1163
|
+
// parsing *this* string, so it (not `latex`) is what trailing-whitespace
|
|
1164
|
+
// trimming must check: a blanked prefix reads as spaces here but as real
|
|
1165
|
+
// command text in `latex`.
|
|
1166
|
+
const strippedSource = sourceWithoutExplainPrefixes(latex, explicitCalls);
|
|
1167
|
+
const tree = parseMath(strippedSource, 'lesson math');
|
|
1168
|
+
|
|
1169
|
+
const parenViolations = findUnparenthesizedFunctionCalls(tree);
|
|
1170
|
+
if (parenViolations.length > 0) {
|
|
1171
|
+
const names = [...new Set(parenViolations)].map((name) => `\\${name}`).join(', ');
|
|
1172
|
+
fail(
|
|
1173
|
+
'unparenthesized-function',
|
|
1174
|
+
`${names} used without a parenthesized argument in ${JSON.stringify(latex)}. ` +
|
|
1175
|
+
`A nonlinear function always takes a parenthesized argument, even a single ` +
|
|
1176
|
+
`symbol — write \\sin(x), not \\sin x (rule N13).`,
|
|
1177
|
+
{ violations: parenViolations },
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
const nestingViolations = findNestedBareDelimiters(tree);
|
|
1181
|
+
if (nestingViolations.length > 0) {
|
|
1182
|
+
const shown = [...new Set(nestingViolations.map(({ char }) => char))]
|
|
1183
|
+
.map((char) => `\\left${char}`)
|
|
1184
|
+
.join(', ');
|
|
1185
|
+
fail(
|
|
1186
|
+
'nested-bare-delimiter',
|
|
1187
|
+
`${shown} nested with another (, [, {, or | in ${JSON.stringify(latex)}. ` +
|
|
1188
|
+
`(, [, { and | can be used bare only when not nested — once one of ` +
|
|
1189
|
+
`them is nested inside another, every level of the nest must use the ` +
|
|
1190
|
+
`sizing form (\\left(/\\right), \\left[/\\right], \\left\\{/\\right\\}, ` +
|
|
1191
|
+
`\\left|/\\right|) instead of the bare character (rule N15).`,
|
|
1192
|
+
{ violations: nestingViolations },
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
const { containers, identifiers, scriptSpans } = collectSourceStructure(
|
|
1196
|
+
tree,
|
|
1197
|
+
strippedSource,
|
|
788
1198
|
);
|
|
789
|
-
const { containers, identifiers, scriptSpans } = collectSourceStructure(tree);
|
|
790
1199
|
|
|
791
|
-
|
|
1200
|
+
// A canonical container match can be found by seeing through transparent
|
|
1201
|
+
// ordgroup/font wrapping into a structural sibling of an explicit
|
|
1202
|
+
// `\explain{…}{…}` call — if it merely touches that call's span without
|
|
1203
|
+
// being fully nested inside it, the match is spurious (the explicit call
|
|
1204
|
+
// already accounts for that content) and injecting it would double-wrap or
|
|
1205
|
+
// straddle the call's own braces (N-partial-wrap; see `notation.md`).
|
|
1206
|
+
const canonical = matchCanonicalForms(
|
|
1207
|
+
latex,
|
|
1208
|
+
strippedSource,
|
|
1209
|
+
containers,
|
|
1210
|
+
glyphs,
|
|
1211
|
+
).filter(
|
|
1212
|
+
(binding) =>
|
|
1213
|
+
!explicitCalls.some(
|
|
1214
|
+
(call) => overlaps(binding, call) && !contains(call, binding),
|
|
1215
|
+
),
|
|
1216
|
+
);
|
|
792
1217
|
const covered = [...canonical, ...explicitCalls];
|
|
793
1218
|
const base = matchBaseGlyphs(
|
|
794
1219
|
latex,
|
|
@@ -798,12 +1223,27 @@ export function resolveMathGlyphs(latex, definitions) {
|
|
|
798
1223
|
scriptSpans,
|
|
799
1224
|
);
|
|
800
1225
|
|
|
801
|
-
const bindings = [...canonical, ...base, ...explicitCalls]
|
|
802
|
-
(
|
|
803
|
-
left
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1226
|
+
const bindings = [...canonical, ...base, ...explicitCalls]
|
|
1227
|
+
.sort(
|
|
1228
|
+
(left, right) =>
|
|
1229
|
+
left.start - right.start ||
|
|
1230
|
+
right.end - left.end ||
|
|
1231
|
+
(left.level === 'explicit' ? -1 : 1),
|
|
1232
|
+
)
|
|
1233
|
+
// An author-written `\explain{key}{…}` that also happens to match its own
|
|
1234
|
+
// canonical or base glyph form resolves twice at the identical span: once
|
|
1235
|
+
// as the explicit call, once as the auto-match. Both carry the same key,
|
|
1236
|
+
// so the ambiguous-span check below (which only objects when the keys
|
|
1237
|
+
// *differ*) lets both through, and `injectScopeBindings` would then wrap
|
|
1238
|
+
// the span in `\explain{key}{…}` a second time, nested around itself.
|
|
1239
|
+
// Keep only the first of a same-span-same-key run; the sort above already
|
|
1240
|
+
// orders the explicit binding ahead of an auto-matched one at that span.
|
|
1241
|
+
.filter(
|
|
1242
|
+
(binding, index, sorted) =>
|
|
1243
|
+
index === 0 ||
|
|
1244
|
+
!sameSpan(sorted[index - 1], binding) ||
|
|
1245
|
+
sorted[index - 1].key !== binding.key,
|
|
1246
|
+
);
|
|
807
1247
|
|
|
808
1248
|
for (const [i, left] of bindings.entries()) {
|
|
809
1249
|
for (const right of bindings.slice(i + 1)) {
|
|
@@ -842,6 +1282,14 @@ export function resolveMathGlyphs(latex, definitions) {
|
|
|
842
1282
|
* Used by the `operator-explained` consistency check (rule N8); the glyph
|
|
843
1283
|
* resolver still skips operator bodies whole.
|
|
844
1284
|
*
|
|
1285
|
+
* The span handed in is raw lesson source, so it may still carry
|
|
1286
|
+
* `\explain{key}{…}` / `\slot{key}{…}` markers around other symbols in the
|
|
1287
|
+
* same expression — `\explain` is not valid KaTeX, so parsing it unstripped
|
|
1288
|
+
* throws and this function used to swallow that as "no operators here" for
|
|
1289
|
+
* the whole span, silently hiding any `\operatorname{…}` alongside the
|
|
1290
|
+
* marker. `stripNotationMarkers` removes just the marker wrapper first so
|
|
1291
|
+
* the scan sees plain, parseable math.
|
|
1292
|
+
*
|
|
845
1293
|
* @param {string} latex
|
|
846
1294
|
* @returns {Array<{ name: string, source: 'named' | 'operatorname' }>}
|
|
847
1295
|
*/
|
|
@@ -850,7 +1298,7 @@ export function collectOperators(latex) {
|
|
|
850
1298
|
|
|
851
1299
|
let tree;
|
|
852
1300
|
try {
|
|
853
|
-
tree = parseMath(latex, 'operator scan');
|
|
1301
|
+
tree = parseMath(stripNotationMarkers(latex), 'operator scan');
|
|
854
1302
|
} catch (error) {
|
|
855
1303
|
// A span that will not parse is the math gate's problem, not this check's.
|
|
856
1304
|
// A KaTeX version-pin failure is nobody's to swallow — let it through.
|
|
@@ -920,3 +1368,63 @@ export function collectOperators(latex) {
|
|
|
920
1368
|
visit(tree);
|
|
921
1369
|
return operators;
|
|
922
1370
|
}
|
|
1371
|
+
|
|
1372
|
+
// --- named roman labels ----------------------------------------------
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Every multi-letter `\mathrm{…}` label in one KaTeX expression (`KE`, `PV`,
|
|
1376
|
+
* `opt`) — the upright-label counterpart to {@link collectOperators}. A
|
|
1377
|
+
* single-letter body (`\mathrm{d}`, the N7 differential) is not collected:
|
|
1378
|
+
* that one is a fixed typographic operator, not a named quantity a reader
|
|
1379
|
+
* would need explained.
|
|
1380
|
+
*
|
|
1381
|
+
* Pure read: nothing here rewrites the source. Used by the
|
|
1382
|
+
* `roman-label-explained` consistency check (rule N10); the glyph resolver
|
|
1383
|
+
* still skips `\mathrm{…}` bodies whole ([N3](../../docs/authoring/notation.md#n3)).
|
|
1384
|
+
*
|
|
1385
|
+
* Same `stripNotationMarkers` preprocessing as {@link collectOperators}, and
|
|
1386
|
+
* for the same reason: an unstripped `\explain{…}{…}` elsewhere in the span
|
|
1387
|
+
* fails the raw KaTeX parse and used to silently drop every label in it.
|
|
1388
|
+
*
|
|
1389
|
+
* @param {string} latex
|
|
1390
|
+
* @returns {Array<{ name: string }>}
|
|
1391
|
+
*/
|
|
1392
|
+
export function collectRomanLabels(latex) {
|
|
1393
|
+
if (typeof latex !== 'string' || latex.trim() === '') return [];
|
|
1394
|
+
|
|
1395
|
+
let tree;
|
|
1396
|
+
try {
|
|
1397
|
+
tree = parseMath(stripNotationMarkers(latex), 'roman-label scan');
|
|
1398
|
+
} catch (error) {
|
|
1399
|
+
if (error instanceof GlyphResolutionError && error.code === 'invalid-latex') {
|
|
1400
|
+
return [];
|
|
1401
|
+
}
|
|
1402
|
+
throw error;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
const seen = new Set();
|
|
1406
|
+
const labels = [];
|
|
1407
|
+
const visit = (value) => {
|
|
1408
|
+
if (Array.isArray(value)) {
|
|
1409
|
+
for (const child of value) visit(child);
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
if (!isNode(value)) return;
|
|
1413
|
+
|
|
1414
|
+
if (value.type === 'font' && value.font === ROMAN_LABEL_FONT) {
|
|
1415
|
+
const name = fontBodyText(value.body);
|
|
1416
|
+
if (typeof name === 'string' && name.length >= 2 && !seen.has(name)) {
|
|
1417
|
+
seen.add(name);
|
|
1418
|
+
labels.push({ name });
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
for (const key of Object.keys(value)) {
|
|
1423
|
+
if (key === 'loc') continue;
|
|
1424
|
+
visit(value[key]);
|
|
1425
|
+
}
|
|
1426
|
+
};
|
|
1427
|
+
|
|
1428
|
+
visit(tree);
|
|
1429
|
+
return labels;
|
|
1430
|
+
}
|