// SPDX-License-Identifier: AGPL-3.0-or-later import { columnWidthPercents, maxContentPx, minContentPx, type Piece, PX, TABLE_BUDGET_PX, TABLE_FLOOR_CAP_SHARE, TABLE_MAX_CELL_CHARS, TABLE_MAX_COLUMNS, TABLE_MAX_IDENT_CHARS, TABLE_WIDE_TIER_PX, } from '../src/table/DocsTableMetrics.ts'; export { columnWidthPercents, PX, TABLE_BUDGET_PX, TABLE_FLOOR_CAP_SHARE, TABLE_MAX_CELL_CHARS, TABLE_MAX_COLUMNS, TABLE_MAX_IDENT_CHARS, TABLE_WIDE_TIER_PX, }; const CANONICAL_SHAPES = new Set([ 'field|type|description', 'status|body|condition', 'value|name|description', 'value|description', ]); const PARALLEL_SENTINELS = new Set(['unset', 'empty', 'none', 'required', 'n/a', '-', 'same target', 'no default']); export interface DocsTable { readonly line: number; readonly endLine: number; readonly columns: number; readonly rows: number; readonly header: ReadonlyArray; readonly canonical: boolean; readonly minDemandPx: number; readonly columnMinPx: ReadonlyArray; readonly columnMaxPx: ReadonlyArray; readonly worstCellChars: number; readonly worstCellColumn: number; readonly worstCellText: string; readonly col1IdentChars: number; readonly col1IdentText: string; readonly parallel: boolean; readonly nonParallelReason: string; } function splitRow(line: string): Array { const cells: Array = []; let current = ''; const body = line.trim().replace(/^\|/u, '').replace(/\|$/u, ''); for (let index = 0; index < body.length; index += 1) { const character = body[index]; if (character === '\\' && index + 1 < body.length) { current += body[index + 1]; index += 1; continue; } if (character === '|') { cells.push(current); current = ''; continue; } current += character; } cells.push(current); return cells.map((cell) => cell.trim()); } function isDelimiter(line: string): boolean { const trimmed = line.trim(); if (!trimmed.startsWith('|')) { return false; } return /^\|[\s:|-]+\|?$/u.test(trimmed) && trimmed.includes('-'); } function stripInline(text: string): string { return text .replace(/!\[([^\]]*)\]\([^)]*\)/gu, '$1') .replace(/\[([^\]]*)\]\([^)]*\)/gu, '$1') .replace(/([^<]*)<\/sup>/gu, '$1') .replace(//giu, ' ') .replace(/<[^>]+>/gu, '') .replace(/\*\*([^*]*)\*\*/gu, '$1') .replace(/\*([^*]*)\*/gu, '$1') .replace(/ /gu, ' ') .replace(/</gu, '<') .replace(/>/gu, '>') .replace(/&/gu, '&'); } function pieces(cell: string): Array { const out: Array = []; const pattern = /`([^`]*)`/gu; let last = 0; let match: RegExpExecArray | null = pattern.exec(cell); while (match != null) { if (match.index > last) { out.push({text: stripInline(cell.slice(last, match.index)), code: false}); } out.push({text: match[1], code: true}); last = pattern.lastIndex; match = pattern.exec(cell); } if (last < cell.length) { out.push({text: stripInline(cell.slice(last)), code: false}); } return out.filter((piece) => piece.text.length > 0); } function renderedText(parts: ReadonlyArray): string { return parts.map((piece) => piece.text).join(''); } function cellKind(cell: string): string { const stripped = cell.replace(/[^<]*<\/sup>/gu, ''); const parts = pieces(stripped); if (parts.length === 0) { return 'empty'; } if (parts.every((piece) => piece.code)) { return 'code'; } if (parts.every((piece) => !piece.code)) { return /[.!?]\s|[.!?]$/u.test(stripped) ? 'sentence' : 'phrase'; } return 'mixed'; } export function extractTables(source: string): Array { const lines = source.split('\n'); const tables: Array = []; let fenced = false; let index = 0; while (index < lines.length) { if (/^\s*(?:```|~~~)/u.test(lines[index])) { fenced = !fenced; index += 1; continue; } if (fenced) { index += 1; continue; } if (!lines[index].trim().startsWith('|') || index + 1 >= lines.length || !isDelimiter(lines[index + 1])) { index += 1; continue; } const header = splitRow(lines[index]); const start = index; index += 2; const body: Array> = []; while (index < lines.length && lines[index].trim().startsWith('|')) { body.push(splitRow(lines[index])); index += 1; } const columns = header.length; const columnMinPx = new Array(columns).fill(0); const columnMaxPx = new Array(columns).fill(0); let worstCellChars = 0; let worstCellColumn = 0; let worstCellText = ''; let col1IdentChars = 0; let col1IdentText = ''; for (const [rowIndex, row] of [header, ...body].entries()) { const isHeader = rowIndex === 0; for (let column = 0; column < columns; column += 1) { const parts = pieces(isHeader ? (row[column] ?? '').toUpperCase() : (row[column] ?? '')); const minimum = minContentPx(parts); const maximum = maxContentPx(parts, isHeader); if (maximum > columnMaxPx[column]) { columnMaxPx[column] = maximum; } if (minimum > columnMinPx[column]) { columnMinPx[column] = minimum; } const text = renderedText(parts); if (!isHeader && text.length > worstCellChars) { worstCellChars = text.length; worstCellColumn = column; worstCellText = text; } if (column === 0 && !isHeader) { for (const run of text.split(/(?<=\s)|(?<=-)(?!\d)/u)) { const token = run.trim(); if (token.length > col1IdentChars) { col1IdentChars = token.length; col1IdentText = token; } } } } } const headerShape = header.map((cell) => cell.trim().toLowerCase()).join('|'); let parallel = true; let nonParallelReason = ''; for (let column = 0; column < columns && parallel; column += 1) { if (headerShape === 'status|body|condition' && column === 1) { continue; } const shape = body .map((row) => (row[column] ?? '').trim()) .filter((cell) => cell.length > 0 && !PARALLEL_SENTINELS.has(cell.toLowerCase())) .map(cellKind); const kinds = new Set(shape); kinds.delete('empty'); const prose = kinds.has('sentence') || kinds.has('phrase') || kinds.has('mixed'); if (kinds.has('code') && prose && shape.length > 2) { parallel = false; nonParallelReason = `column ${(column + 1).toString()} mixes code-only cells with prose cells`; } } tables.push({ line: start + 1, endLine: index, columns, rows: body.length, header, canonical: CANONICAL_SHAPES.has(headerShape), minDemandPx: Math.round(columnMinPx.reduce((a, b) => a + b, 0) + columns * PX.cellPad), columnMinPx: columnMinPx.map((value) => Math.round(value)), columnMaxPx: columnMaxPx.map((value) => Math.round(value)), worstCellChars, worstCellColumn, worstCellText, col1IdentChars, col1IdentText, parallel, nonParallelReason, }); } return tables; }