import { buildTabularSnippet } from "./table-generator"; export type TableData = { cells: { text: string; latex: boolean }[][]; align: ("l" | "c" | "r")[]; decimals: number | null; booktabs: boolean; float: boolean; caption: string; label: string;}; // eslint-disable-next-line lingui/no-unlocalized-strings -- versioned source marker, not UI copyconst START = "% lattice-table-v1 ";// eslint-disable-next-line lingui/no-unlocalized-strings -- versioned source marker, not UI copyconst END = "% /lattice-table"; export function parseTableData(text: string, delimiter: "," | "\t"): string[][] | null { if (!text || text.length > 100_000) return null; const rows: string[][] = [[]]; let value = "", quoted = false, closed = false; const push = () => { rows[rows.length - 1].push(value); value = ""; closed = false; }; const source = text.replace(/\r\n?/g, "\n").replace(/^\uFEFF/, ""); for (let i = 0; i < source.length; i++) { const char = source[i]; if (quoted) { if (char === '"' && source[i + 1] === '"') { value += '"'; i++; } else if (char === '"') { quoted = false; closed = true; } else value += char; } else if (char === delimiter) push(); else if (char === "\n") { push(); rows.push([]); } else if (char === '"' && !value && !closed) quoted = true; else if (closed || char === '"') return null; else value += char; if (rows.length > 21 || rows[rows.length - 1].length > 20) return null; } if (quoted) return null; push(); if (source.endsWith("\n")) rows.pop(); const width = Math.max(...rows.map((row) => row.length)); if (rows.length > 20 || width > 20) return null; return rows.map((row) => Array.from({ length: width }, (_, col) => row[col] ?? ""));} function escapeTableText(text: string): string { const escapes: Record<string, string> = { "\\": "\\textbackslash{}", "&": "\\&", "%": "\\%", "$": "\\$", "#": "\\#", "_": "\\_", "{": "\\{", "}": "\\}", "~": "\\textasciitilde{}", "^": "\\textasciicircum{}", }; return text.replace(/[\\&%$#_{}~^]/g, (char) => escapes[char]).replace(/[\r\n]+/g, " ");} export function newTableData(): TableData { return { // eslint-disable-next-line lingui/no-unlocalized-strings -- sample data written into source cells: [["Col 1", "Col 2", "Col 3"], ["", "", ""], ["", "", ""]].map((row) => row.map((text) => ({ text, latex: false }))), align: ["l", "l", "l"], decimals: null, booktabs: true, float: true, caption: "", label: "", };} export function validTableData(value: unknown): value is TableData { if (!value || typeof value !== "object") return false; const d = value as TableData; return Array.isArray(d.cells) && d.cells.length > 0 && d.cells.length <= 20 && Array.isArray(d.align) && d.align.length > 0 && d.align.length <= 20 && d.align.every((align) => ["l", "c", "r"].includes(align)) && d.cells.every((row) => Array.isArray(row) && row.length === d.align.length && row.every((cell) => cell && typeof cell.text === "string" && cell.text.length <= 10_000 && typeof cell.latex === "boolean" // Raw math/macros are opt-in, but structural TeX stays in the source editor. && (!cell.latex || !/[\r\n%&]|\\\\|\\(?:begin|end)\b/.test(cell.text)))) && (d.decimals === null || Number.isInteger(d.decimals) && d.decimals >= 0 && d.decimals <= 8) && typeof d.booktabs === "boolean" && typeof d.float === "boolean" && typeof d.caption === "string" && typeof d.label === "string" && /^[\w:.-]*$/.test(d.label);} export function tableSource(data: TableData): string { return buildTabularSnippet({ rows: data.cells.length, cols: data.align.length, ...data, caption: escapeTableText(data.caption), data: data.cells.map((row, r) => row.map((cell) => { if (cell.latex) return cell.text; let text = cell.text; if (r > 0 && data.decimals !== null && /^[+-]?(?:\d+\.?\d*|\.\d+)$/.test(text.trim())) { const number = Number(text); if (Number.isFinite(number)) text = number.toFixed(data.decimals); } return escapeTableText(text); })), }).insert;} export function managedTableSource(data: TableData): string { // Short comment lines stay below TeX's input-buffer limit even for large cells. const encoded = encodeURIComponent(JSON.stringify(data)); const metadata = encoded.match(/.{1,120}/g)!.join("\n% "); return `${START}${metadata}\n${tableSource(data)}${END}\n`;} /** Only the exact generated subset is editable. Metadata never authorizes * rewriting a hand-edited body, malformed block, or an arbitrary tabular. */export function tableAtCursor(source: string, cursor: number): { from: number; to: number; data: TableData | null;} | null { const marker = /^% lattice-table-v1 .*$(?:\n% [A-Za-z0-9%_.!~*'()-]+$)*/gm; for (const match of source.matchAll(marker)) { const from = match.index; const end = source.indexOf(`\n${END}`, from); const to = end < 0 ? source.length : end + 1 + END.length + (source[end + 1 + END.length] === "\n" ? 1 : 0); if (cursor < from || cursor >= to) continue; try { const data: unknown = JSON.parse(decodeURIComponent(match[0].slice(START.length).replace(/\n% /g, ""))); if (validTableData(data) && managedTableSource(data) === source.slice(from, to)) return { from, to, data }; } catch { /* Malformed or older metadata remains source-only. */ } return { from, to, data: null }; } // Detect unsupported table environments without interpreting or rewriting them. let depth = 0; for (const token of source.slice(0, cursor).matchAll(/\\(begin|end)\{(?:table\*?|tabular\*?)\}/g)) { depth = Math.max(0, depth + (token[1] === "begin" ? 1 : -1)); } if (depth) return { from: cursor, to: cursor, data: null }; return null;}
import { buildTabularSnippet } from "./table-generator";
export type TableData = {
cells: { text: string; latex: boolean }[][];
align: ("l" | "c" | "r")[];
decimals: number | null;
booktabs: boolean;
float: boolean;
caption: string;
label: string;
};
// eslint-disable-next-line lingui/no-unlocalized-strings -- versioned source marker, not UI copy
const START = "% lattice-table-v1 ";
// eslint-disable-next-line lingui/no-unlocalized-strings -- versioned source marker, not UI copy
const END = "% /lattice-table";
export function parseTableData(text: string, delimiter: "," | "\t"): string[][] | null {
if (!text || text.length > 100_000) return null;
const rows: string[][] = [[]];
let value = "", quoted = false, closed = false;
const push = () => { rows[rows.length - 1].push(value); value = ""; closed = false; };
const source = text.replace(/\r\n?/g, "\n").replace(/^\uFEFF/, "");
for (let i = 0; i < source.length; i++) {
const char = source[i];
if (quoted) {
if (char === '"' && source[i + 1] === '"') { value += '"'; i++; }
else if (char === '"') { quoted = false; closed = true; }
else value += char;
} else if (char === delimiter) push();
else if (char === "\n") { push(); rows.push([]); }
else if (char === '"' && !value && !closed) quoted = true;
else if (closed || char === '"') return null;
else value += char;
if (rows.length > 21 || rows[rows.length - 1].length > 20) return null;
}
if (quoted) return null;
push();
if (source.endsWith("\n")) rows.pop();
const width = Math.max(...rows.map((row) => row.length));
if (rows.length > 20 || width > 20) return null;
return rows.map((row) => Array.from({ length: width }, (_, col) => row[col] ?? ""));
}
function escapeTableText(text: string): string {
const escapes: Record<string, string> = {
"\\": "\\textbackslash{}", "&": "\\&", "%": "\\%", "$": "\\$", "#": "\\#",
"_": "\\_", "{": "\\{", "}": "\\}", "~": "\\textasciitilde{}", "^": "\\textasciicircum{}",
};
return text.replace(/[\\&%$#_{}~^]/g, (char) => escapes[char]).replace(/[\r\n]+/g, " ");
}
export function newTableData(): TableData {
return {
// eslint-disable-next-line lingui/no-unlocalized-strings -- sample data written into source
cells: [["Col 1", "Col 2", "Col 3"], ["", "", ""], ["", "", ""]].map((row) => row.map((text) => ({ text, latex: false }))),
align: ["l", "l", "l"], decimals: null, booktabs: true, float: true, caption: "", label: "",
};
}
export function validTableData(value: unknown): value is TableData {
if (!value || typeof value !== "object") return false;
const d = value as TableData;
return Array.isArray(d.cells) && d.cells.length > 0 && d.cells.length <= 20
&& Array.isArray(d.align) && d.align.length > 0 && d.align.length <= 20
&& d.align.every((align) => ["l", "c", "r"].includes(align))
&& d.cells.every((row) => Array.isArray(row) && row.length === d.align.length && row.every((cell) =>
cell && typeof cell.text === "string" && cell.text.length <= 10_000 && typeof cell.latex === "boolean"
// Raw math/macros are opt-in, but structural TeX stays in the source editor.
&& (!cell.latex || !/[\r\n%&]|\\\\|\\(?:begin|end)\b/.test(cell.text))))
&& (d.decimals === null || Number.isInteger(d.decimals) && d.decimals >= 0 && d.decimals <= 8)
&& typeof d.booktabs === "boolean" && typeof d.float === "boolean"
&& typeof d.caption === "string" && typeof d.label === "string" && /^[\w:.-]*$/.test(d.label);
}
export function tableSource(data: TableData): string {
return buildTabularSnippet({
rows: data.cells.length, cols: data.align.length, ...data,
caption: escapeTableText(data.caption),
data: data.cells.map((row, r) => row.map((cell) => {
if (cell.latex) return cell.text;
let text = cell.text;
if (r > 0 && data.decimals !== null && /^[+-]?(?:\d+\.?\d*|\.\d+)$/.test(text.trim())) {
const number = Number(text);
if (Number.isFinite(number)) text = number.toFixed(data.decimals);
}
return escapeTableText(text);
})),
}).insert;
}
export function managedTableSource(data: TableData): string {
// Short comment lines stay below TeX's input-buffer limit even for large cells.
const encoded = encodeURIComponent(JSON.stringify(data));
const metadata = encoded.match(/.{1,120}/g)!.join("\n% ");
return `${START}${metadata}\n${tableSource(data)}${END}\n`;
}
/** Only the exact generated subset is editable. Metadata never authorizes
* rewriting a hand-edited body, malformed block, or an arbitrary tabular. */
export function tableAtCursor(source: string, cursor: number): {
from: number; to: number; data: TableData | null;
} | null {
const marker = /^% lattice-table-v1 .*$(?:\n% [A-Za-z0-9%_.!~*'()-]+$)*/gm;
for (const match of source.matchAll(marker)) {
const from = match.index;
const end = source.indexOf(`\n${END}`, from);
const to = end < 0 ? source.length : end + 1 + END.length + (source[end + 1 + END.length] === "\n" ? 1 : 0);
if (cursor < from || cursor >= to) continue;
try {
const data: unknown = JSON.parse(decodeURIComponent(match[0].slice(START.length).replace(/\n% /g, "")));
if (validTableData(data) && managedTableSource(data) === source.slice(from, to)) return { from, to, data };
} catch { /* Malformed or older metadata remains source-only. */ }
return { from, to, data: null };
}
// Detect unsupported table environments without interpreting or rewriting them.
let depth = 0;
for (const token of source.slice(0, cursor).matchAll(/\\(begin|end)\{(?:table\*?|tabular\*?)\}/g)) {
depth = Math.max(0, depth + (token[1] === "begin" ? 1 : -1));
}
if (depth) return { from: cursor, to: cursor, data: null };
return null;
}