diff --git a/src/core/math-delims.ts b/src/core/math-delims.ts index 662c209..56592ab 100644 --- a/src/core/math-delims.ts +++ b/src/core/math-delims.ts @@ -229,6 +229,16 @@ export function transformMathDelimiters(markdown: string): string { * balanced `\(…\)` / `\[…\]` spans are touched — the same scanner rules as * transformMathDelimiters (escaped delimiters are literal, unbalanced * delimiters pass through untouched). + * + * ASYMMETRIC NEWLINE RULE (owner ruling 2026-09-27, Option A): inline spans + * restore all six debris chars — real newlines/tabs inside INLINE math + * essentially never occur (options and inline math are single-line by + * nature). Display spans restore five and KEEP real newlines — multi-line + * display math (aligned environments, equation systems) is common, and a + * real line break is byte-identical on the wire to sloppy `\neq`/`\nu`/ + * `\neg`/`\nabla` (JSON `\n` resolves to a newline either way). Corrupting + * legitimate content is strictly worse than failing to repair that rarest + * slice, so display-span n-command sloppiness stays broken by design. */ const ESCAPE_DEBRIS: Record = { "\b": "\\b", @@ -241,6 +251,11 @@ const ESCAPE_DEBRIS: Record = { const DEBRIS_RE = /[\b\t\n\v\f\r]/; +/** Display spans keep real newlines (multi-line equations) — debris there is + * everything except \n (a lone real \r never occurs in authored text, and + * `\right`/`\rho`/`\rangle` corruption is common in display math). */ +const DISPLAY_DEBRIS_RE = /[\b\t\v\f\r]/; + export function restoreMathEscapes(text: string): string { if (!DEBRIS_RE.test(text)) return text; const out: string[] = []; @@ -256,7 +271,8 @@ export function restoreMathEscapes(text: string): string { const closeAt = findClose(text, i + open.length, close); if (closeAt !== -1) { const inner = text.slice(i + open.length, closeAt); - out.push(open + inner.replace(DEBRIS_RE, (c) => ESCAPE_DEBRIS[c]!) + close); + const debris = isDisplay ? DISPLAY_DEBRIS_RE : DEBRIS_RE; + out.push(open + inner.replace(debris, (c) => ESCAPE_DEBRIS[c]!) + close); i = closeAt + close.length; continue; } diff --git a/tests/tool-parts.test.ts b/tests/tool-parts.test.ts index 25ae9dc..d91b84d 100644 --- a/tests/tool-parts.test.ts +++ b/tests/tool-parts.test.ts @@ -216,3 +216,53 @@ describe("LaTeX-escape restoration (the JSON-debris fix, owner ruling 2026-09-26 assert.equal(parsed.question, "\\(\\x0corall"); }); }); + +describe("display-math newline rule (Option A, owner ruling 2026-09-27)", () => { + // TS literal below: \\[ is \[, \\forall is intact \forall, \n is a REAL newline, \\\\ is the LaTeX \\ line break. + const MULTILINE = "Compare:\n\\[\n\\forall x, & f(x) = x^2 \\\\\n& g(x) = x^3\n\\]"; + + it("real newlines inside display math are kept — multi-line equations pass through untouched", () => { + const parsed = parseQuizInput({ + question: MULTILINE, + options: [{ label: "f grows slower" }, { label: "g grows slower" }], + correctAnswer: "f grows slower", + explanation: "", + }); + assert.ok(parsed); + assert.equal(parsed.question, MULTILINE); + }); + + it("non-newline debris inside display math still restores (the \\right CR case)", () => { + const parsed = parseQuizInput({ + question: "Evaluate \\[\\left( a \right)\\] carefully.", + options: [{ label: "a" }, { label: "not a" }], + correctAnswer: "a", + explanation: "", + }); + assert.ok(parsed); + assert.equal(parsed.question, "Evaluate \\[\\left( a \\right)\\] carefully."); + }); + + it("sloppy \\neq in display math stays broken — the accepted cost (newline kept, byte-identical to a real line break)", () => { + const debris = "Is \\[p \neq q\\] valid?"; + const parsed = parseQuizInput({ + question: debris, + options: [{ label: "yes" }, { label: "no" }], + correctAnswer: "no", + explanation: "", + }); + assert.ok(parsed); + assert.equal(parsed.question, debris); + }); + + it("inline spans still restore newline debris (inline math is single-line by nature)", () => { + const parsed = parseQuizInput({ + question: "Is \\(p \neq q\\) valid?", + options: [{ label: "yes" }, { label: "no" }], + correctAnswer: "no", + explanation: "", + }); + assert.ok(parsed); + assert.equal(parsed.question, "Is \\(p \\neq q\\) valid?"); + }); +});