返回 DeepSeek-Reasonix
youngDiagrams.ts
根目录 / desktop / frontend / src / components / youngDiagrams.ts
1 // Translate `\yng` (ytableau package) and `\young` (youngtab package)
2 // macros into KaTeX-compatible `\begin{array}...\end{array}` forms.
3 // KaTeX 0.17 does not include either macro package, so without this
4 // pass the macros fail with `Undefined control sequence` and the chat
5 // surfaces the raw LaTeX source as a red error block.
6 //
7 // `\yng(2,1)` — empty (2,1) Young diagram
8 // `\yng(2,1,3)` — empty Young diagram with three rows
9 // `\yng(2,1){a&b\\c\\d&e}` — same shape, cells filled by row/col
10 // (rows separated by `\\`, cells by `&`)
11 // `\young(ab,c)` — labelled boxes (youngtab syntax)
12 //
13 // Cells are `\square` (a Unicode white-square, rendered by KaTeX as
14 // `mord amsrm` with a real visible glyph) by default so the diagram
15 // has the same width as a filled one AND is actually visible. Earlier
16 // versions used `\hphantom{x}` for invisible placeholder width, which
17 // renders to *no visible glyph* — correct typesetting but the user
18 // sees nothing on screen and the chat looks empty.
19 //
20 // The translator is stateful: it tracks `$…$` math delimiters so it
21 // only wraps *bare* `\yng`/`\young` in `$…$`. When the macros are
22 // already inside a math block (e.g. `$\yng(2,1)$`), it just expands
23 // the inner form without adding extra delimiters — the inner call in
24 // the math content is enough.
25
26 const MAX_ROWS = 64;
27 const MAX_CELLS = 512;
28 const EMPTY_CELL = "\\square";
29 const SKEW_CELL = "\\hphantom{\\boxed{x}}";
30
31 function boxedCell(cell: string): string {
32 return `\\boxed{${cell}}`;
33 }
34
35 function splitAtTopLevel(s: string, sep: string): string[] {
36 const out: string[] = [];
37 let depth = 0;
38 let buf = "";
39 for (let i = 0; i < s.length; i++) {
40 const ch = s[i];
41 if (ch === "{") depth++;
42 else if (ch === "}") depth = Math.max(0, depth - 1);
43 if (depth === 0 && s.startsWith(sep, i)) {
44 out.push(buf);
45 buf = "";
46 i += sep.length - 1;
47 continue;
48 }
49 buf += ch;
50 }
51 out.push(buf);
52 return out;
53 }
54
55 function parseShape(s: string, sep: "comma" | "space"): number[] | null {
56 const re = sep === "comma" ? /\s*,\s*/ : /\s+/;
57 const parts = s.trim().split(re).filter(Boolean);
58 if (parts.length === 0 || parts.length > MAX_ROWS) return null;
59
60 const rows: number[] = [];
61 let totalCells = 0;
62 for (const part of parts) {
63 const token = part.trim();
64 if (!/^\d+$/.test(token)) return null;
65 const n = Number(token);
66 if (!Number.isSafeInteger(n) || n <= 0) return null;
67 totalCells += n;
68 if (totalCells > MAX_CELLS) return null;
69 rows.push(n);
70 }
71 return rows;
72 }
73
74 function renderRows(cells: string[][]): string {
75 const arrRows = cells.map((row) => row.join(" \\! "));
76 // Use `{l}` (left) instead of `{c}` (centered): a Young diagram has
77 // every row's first cell at the same horizontal position — the
78 // shorter rows just have fewer cells to the right. `{c}` would
79 // centre each row relative to the widest row, which doesn't look
80 // like a Young diagram.
81 return (
82 "\\begin{array}{l}" +
83 arrRows.join(" \\\\[-0.525em] ") +
84 "\\end{array}"
85 );
86 }
87
88 function expandShape(rows: number[], content: string | undefined): string | null {
89 const maxN = rows.length === 0 ? 0 : Math.max(...rows);
90 // 2D array of cell content. Each cell is `\square` by default
91 // (visible Unicode white-square) so the diagram has uniform width
92 // AND is actually visible to the reader.
93 const cells: string[][] = Array.from({ length: rows.length }, () =>
94 Array(maxN).fill(EMPTY_CELL),
95 );
96
97 if (content) {
98 // Parse content: rows separated by `\\`, cells separated by `&`.
99 // The content may contain nested `{...}` (e.g. `\frac{a}{b}`), so
100 // we split on `\\` and `&` at brace-depth 0 only.
101 const contentRows = splitAtTopLevel(content, "\\\\");
102 for (let i = 0; i < contentRows.length && i < rows.length; i++) {
103 const cs = splitAtTopLevel(contentRows[i], "&");
104 for (let j = 0; j < cs.length && j < rows[i]; j++) {
105 const c = cs[j].trim();
106 cells[i][j] = c === "" ? EMPTY_CELL : boxedCell(c);
107 }
108 }
109 }
110
111 // Use per-row negative spacing `\\\\[-0.525em]` between rows instead of the
112 // default `\\`. The default katex display math baseline-to-baseline
113 // spacing is 1.2em, but the `\square` glyph is only 0.675em tall
114 // (measured from the katex strut of a single `\square`). With the
115 // default spacing, the gap between the bottom of one row's square
116 // and the top of the next is 1.2 − 0.675 = 0.525em of visible white
117 // space — the diagram looks like a column of disconnected boxes.
118 // `\\\\[-0.525em]` reduces the baseline gap to exactly 0.675em so
119 // adjacent squares touch with zero visible gap. This is a *per-row
120 // spacing* fix, not a per-cell vertical shift: the offset is
121 // symmetric across the diagram, so all rows stay aligned.
122 return renderRows(cells.map((row, ri) => row.slice(0, rows[ri])));
123 }
124
125 function readBalancedGroup(s: string, openBrace: number): { content: string; end: number } | null {
126 let depth = 1;
127 let i = openBrace + 1;
128 while (i < s.length && depth > 0) {
129 if (s[i] === "{") depth++;
130 else if (s[i] === "}") depth--;
131 if (depth === 0) return { content: s.slice(openBrace + 1, i), end: i + 1 };
132 i++;
133 }
134 return null;
135 }
136
137 function readLatexCell(row: string, start: number): { cell: string; end: number } {
138 if (row[start] === "\\") {
139 let end = start + 1;
140 while (end < row.length && /[A-Za-z]/.test(row[end])) end++;
141 if (end === start + 1 && end < row.length) end++;
142 while (row[end] === "{") {
143 const group = readBalancedGroup(row, end);
144 if (!group) break;
145 end = group.end;
146 }
147 return { cell: row.slice(start, end), end };
148 }
149
150 if (row[start] === "{") {
151 const group = readBalancedGroup(row, start);
152 if (group) return { cell: group.content, end: group.end };
153 }
154
155 return { cell: row[start], end: start + 1 };
156 }
157
158 function parseYoungTableau(s: string): string[][] | null {
159 const rawRows = splitAtTopLevel(s, ",");
160 if (rawRows.length === 0 || rawRows.length > MAX_ROWS) return null;
161
162 const rows: string[][] = [];
163 let totalCells = 0;
164 for (const rawRow of rawRows) {
165 const row: string[] = [];
166 for (let i = 0; i < rawRow.length;) {
167 if (/\s/.test(rawRow[i])) {
168 i++;
169 continue;
170 }
171 if (rawRow[i] === ":") {
172 row.push(SKEW_CELL);
173 totalCells++;
174 if (totalCells > MAX_CELLS) return null;
175 i++;
176 continue;
177 }
178 const token = readLatexCell(rawRow, i);
179 const cell = token.cell.trim();
180 if (cell) {
181 row.push(boxedCell(cell));
182 totalCells++;
183 if (totalCells > MAX_CELLS) return null;
184 }
185 i = token.end;
186 }
187 rows.push(row);
188 }
189
190 if (totalCells === 0) return null;
191 return rows;
192 }
193
194 function expandYoungMacro(isYng: boolean, shapeText: string, content: string | undefined): string | null {
195 if (isYng) {
196 const rows = parseShape(shapeText, "comma");
197 return rows ? expandShape(rows, content) : null;
198 }
199
200 // Keep compatibility with the existing PR's `\young(2 1)` shape shorthand,
201 // but treat comma-separated `\young(ab,c)` as the actual youngtab labelled
202 // tableau syntax.
203 if (!shapeText.includes(",") && /^\s*\d+(?:\s+\d+)+\s*$/.test(shapeText)) {
204 const rows = parseShape(shapeText, "space");
205 return rows ? expandShape(rows, undefined) : null;
206 }
207
208 const rows = parseYoungTableau(shapeText);
209 return rows ? renderRows(rows) : null;
210 }
211
212 function readYoungStart(src: string, i: number): { isYng: boolean; openIdx: number } | null {
213 if (src.startsWith("\\young", i)) {
214 let openIdx = i + "\\young".length;
215 while (/\s/.test(src[openIdx] ?? "")) openIdx++;
216 if (src[openIdx] === "(") return { isYng: false, openIdx };
217 }
218 if (src.startsWith("\\yng", i)) {
219 let openIdx = i + "\\yng".length;
220 while (/\s/.test(src[openIdx] ?? "")) openIdx++;
221 if (src[openIdx] === "(") return { isYng: true, openIdx };
222 }
223 return null;
224 }
225
226 function findClosingParen(src: string, openIdx: number): number {
227 let depth = 1;
228 for (let i = openIdx + 1; i < src.length; i++) {
229 if (src[i] === "(") depth++;
230 else if (src[i] === ")") depth--;
231 if (depth === 0) return i;
232 }
233 return -1;
234 }
235
236 // Find the end of a `\yng(…)` or `\young(…)` call, including optional
237 // `{…}` content. Returns the index just past the entire macro call,
238 // or -1 if the open-paren has no matching close.
239 function findYoungCallEnd(src: string, openIdx: number): number {
240 const closeIdx = findClosingParen(src, openIdx);
241 if (closeIdx < 0) return -1;
242 const afterClose = closeIdx + 1;
243 if (src[afterClose] === "{") {
244 const group = readBalancedGroup(src, afterClose);
245 return group ? group.end : -1;
246 }
247 return afterClose;
248 }
249
250 /**
251 * Walk the input, replacing `\yng(…)` / `\young(…)` with the equivalent
252 * KaTeX-compatible `\begin{array}{l}…\end{array}`. If the macro is
253 * outside any `$…$` math block, wrap the result in `$…$` so remark-math
254 * actually parses it as math. If the macro is already inside a math
255 * block (the existing common case), just substitute the expanded form.
256 */
257 export interface YoungDiagramExpansion {
258 source: string;
259 rendered: string;
260 }
261
262 export function expandYoungDiagrams(
263 src: string,
264 mapExpansion?: (expansion: YoungDiagramExpansion) => string,
265 ): string {
266 let out = "";
267 let i = 0;
268 // Track whether we're inside a math block. We use a small stack-like
269 // counter (depth): 0 = prose, 1 = inline math `$…$`, 2 = display
270 // math `$$…$$`. We bump on `$`, decrement on `$`, and clamp at 0/2.
271 // For our wrapping decision, depth>0 means "leave the macro alone,
272 // it's already in math".
273 let depth = 0;
274
275 while (i < src.length) {
276 const ch = src[i];
277
278 if (ch === "$") {
279 if (isEscapedDollar(src, i)) {
280 out += ch;
281 i += 1;
282 continue;
283 }
284 if (depth === 0 && isCurrencyLikeDollar(src, i) && !opensYoungMathSpan(src, i)) {
285 out += ch;
286 i += 1;
287 continue;
288 }
289 // Look ahead for `$$` (display) vs single `$` (inline).
290 if (src[i + 1] === "$") {
291 // Display math: toggle 0↔2.
292 depth = depth === 0 ? 2 : depth === 2 ? 0 : depth;
293 out += "$$";
294 i += 2;
295 } else {
296 // Inline math: toggle 0↔1. If we're in display math (depth=2),
297 // a single `$` doesn't end it — we need another `$` to do that,
298 // so single `$` inside display math is left alone (depth stays 2).
299 if (depth === 0) depth = 1;
300 else if (depth === 1) depth = 0;
301 // depth === 2: single `$` inside `$$…$$` is literal, depth unchanged.
302 out += ch;
303 i += 1;
304 }
305 continue;
306 }
307
308 // Look for \yng( or \young( and decide whether to wrap or just substitute.
309 const youngStart = readYoungStart(src, i);
310 if (youngStart) {
311 const callEnd = findYoungCallEnd(src, youngStart.openIdx);
312 if (callEnd > 0) {
313 const closeIdx = findClosingParen(src, youngStart.openIdx);
314 if (closeIdx < 0) {
315 out += ch;
316 i++;
317 continue;
318 }
319 const shapeText = src.slice(youngStart.openIdx + 1, closeIdx);
320 let content: string | undefined;
321 let contentStart = closeIdx + 1;
322 if (src[contentStart] === "{") {
323 const group = readBalancedGroup(src, contentStart);
324 if (group) content = group.content;
325 }
326 const expanded = expandYoungMacro(youngStart.isYng, shapeText, content);
327 if (!expanded) {
328 out += src.slice(i, callEnd);
329 i = callEnd;
330 continue;
331 }
332 const replacement = mapExpansion?.({
333 source: src.slice(i, callEnd),
334 rendered: expanded,
335 }) ?? expanded;
336
337 // Wrap in `$…$` only if we're outside math. Inside math, the
338 // surrounding `$`/`$$` already supplies the math delimiters.
339 if (depth === 0) {
340 const leadingSep = out.endsWith("$") && !isEscapedDollar(out, out.length - 1) ? " " : "";
341 const trailingSep = src[callEnd] === "$" && !isEscapedDollar(src, callEnd) ? " " : "";
342 out += leadingSep + "$" + replacement + "$" + trailingSep;
343 } else {
344 out += replacement;
345 }
346 i = callEnd;
347 continue;
348 }
349 }
350
351 out += ch;
352 i++;
353 }
354 return out;
355 }
356
357 function isEscapedDollar(src: string, i: number): boolean {
358 let slashCount = 0;
359 for (let j = i - 1; j >= 0 && src[j] === "\\"; j--) slashCount++;
360 return slashCount % 2 === 1;
361 }
362
363 function isCurrencyLikeDollar(src: string, i: number): boolean {
364 return /\d/.test(src[i + 1] ?? "") || /[\d%]/.test(src[i - 1] ?? "");
365 }
366
367 function opensYoungMathSpan(src: string, dollarIdx: number): boolean {
368 if (src[dollarIdx + 1] === "$") return false;
369
370 let closeIdx = -1;
371 for (let i = dollarIdx + 1; i < src.length && src[i] !== "\n"; i++) {
372 if (src[i] === "$" && !isEscapedDollar(src, i)) {
373 closeIdx = i;
374 break;
375 }
376 }
377 if (closeIdx < 0) return false;
378
379 for (let i = dollarIdx + 1; i < closeIdx; i++) {
380 if (readYoungStart(src, i)) return true;
381 }
382 return false;
383 }
384
384 lines TYPESCRIPT