| 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 |