import { formatText } from './parseTextFormats'; import { parseLists, renderList } from './parseLists'; import { parseMediaEmbeds } from './parseMediaEmbeds'; type HorizontalAlign = 'left' | 'center' | 'right'; type VerticalAlign = 'top' | 'middle' | 'bottom'; interface ColorValue { light: string; dark?: string; } interface TableCell { content: string; colspan: number; rowspan: number; align?: HorizontalAlign; valign?: VerticalAlign; bgcolor?: ColorValue; color?: ColorValue; width?: string; height?: string; nopad?: boolean; keepall?: boolean; isPlaceholder?: boolean; } interface TableRow { cells: TableCell[]; bgcolor?: ColorValue; color?: ColorValue; keepall?: boolean; } interface ColAttributes { colBgColor?: ColorValue; colColor?: ColorValue; colKeepall?: boolean; } interface TableBlock { rows: TableRow[]; width?: string; bgcolor?: ColorValue; color?: ColorValue; bordercolor?: ColorValue; colBgColors?: (ColorValue | undefined)[]; colColors?: (ColorValue | undefined)[]; colKeepall?: boolean[]; startLine: number; endLine: number; } const TABLE_START_PATTERN = /^\|\|.+\|\|$/; class RowspanTracker { private spans = new Map(); decrementColumn(colIdx: number): boolean { const remaining = this.spans.get(colIdx) || 0; if (remaining > 0) { this.spans.set(colIdx, remaining - 1); return true; } return false; } setColumnSpan(colIdx: number, rowspan: number): void { if (rowspan > 1) this.spans.set(colIdx, rowspan - 1); } } const parseColor = (str: string): ColorValue => { const [light, dark] = str.split(',').map(s => s.trim()); return dark ? { light, dark } : { light }; }; const normalizeSize = (size: string): string => (size.match(/px|%/) ? size : size + 'px'); function extractTableAttributes( lines: string[], tableStartIndex: number, block: TableBlock, processedLines: Set ): void { for (let i = tableStartIndex - 1; i >= 0; i--) { const line = lines[i].trim(); if (!line) continue; const match = line.match(/^<(tablewidth|tablebgcolor|tablecolor|tablebordercolor)=([^>]+)>$/); if (match) { const [, attr, value] = match; if (attr === 'tablewidth') block.width = normalizeSize(value); else if (attr === 'tablebgcolor') block.bgcolor = parseColor(value); else if (attr === 'tablecolor') block.color = parseColor(value); else if (attr === 'tablebordercolor') block.bordercolor = parseColor(value); processedLines.add(i); } else { break; } } } export function parseTables(lines: string[]): { blocks: TableBlock[]; processedLines: Set; } { const blocks: TableBlock[] = []; const processedLines = new Set(); for (let i = 0; i < lines.length; i++) { if (TABLE_START_PATTERN.test(lines[i])) { const block: TableBlock = { rows: [], startLine: i, endLine: i }; const tracker = new RowspanTracker(); extractTableAttributes(lines, i, block, processedLines); while (i < lines.length && lines[i].trim().match(/^\|\|.*\|\|$/)) { processedLines.add(i); const row = parseRow(lines[i].trim(), tracker, block, i === block.startLine); block.rows.push(row); block.endLine = i++; } blocks.push(block); i--; } } return { blocks, processedLines }; } const CELL_HANDLERS: [RegExp, (m: RegExpMatchArray, c: TableCell, ca?: ColAttributes) => void][] = [ [ /^<-(\d+)>\s*/, (m, c) => { c.colspan = parseInt(m[1]); }, ], [ /^<\^\|1>\s*/, (_, c) => { c.valign = 'top'; }, ], [ /^<\|1>\s*/, (_, c) => { c.valign = 'middle'; }, ], [ /^\s*/, (_, c) => { c.valign = 'bottom'; }, ], [ /^<\|([2-9]\d*|[1-9]\d+)>\s*/, (m, c) => { c.rowspan = parseInt(m[1]); }, ], [ /^<\(>\s*/, (_, c) => { c.align = 'left'; }, ], [ /^<:>\s*/, (_, c) => { c.align = 'center'; }, ], [ /^<\)>\s*/, (_, c) => { c.align = 'right'; }, ], [ /^]+)>\s*/, (m, c) => { c.bgcolor = parseColor(m[1]); }, ], [ /^]+)>\s*/, (m, c) => { c.color = parseColor(m[1]); }, ], [ /^\s*/, (m, c) => { c.width = normalizeSize(m[1]); }, ], [ /^\s*/, (m, c) => { c.height = normalizeSize(m[1]); }, ], [ /^\s*/, (_, c) => { c.nopad = true; }, ], [ /^\s*/, (_, c) => { c.keepall = true; }, ], [ /^]+)>\s*/, (m, _, ca) => { if (ca) ca.colBgColor = parseColor(m[1]); }, ], [ /^]+)>\s*/, (m, _, ca) => { if (ca) ca.colColor = parseColor(m[1]); }, ], [ /^\s*/, (_, __, ca) => { if (ca) ca.colKeepall = true; }, ], ]; function parseRow( line: string, tracker: RowspanTracker, block: TableBlock, isFirstRow: boolean ): TableRow { const row: TableRow = { cells: [] }; let content = line.slice(2, -2); const rowPatterns = [ [ /^]+)>\s*/, (m: RegExpMatchArray) => { row.bgcolor = parseColor(m[1]); }, ], [ /^]+)>\s*/, (m: RegExpMatchArray) => { row.color = parseColor(m[1]); }, ], [ /^\s*/, () => { row.keepall = true; }, ], ]; let hasMatch = true; while (hasMatch && content) { hasMatch = false; for (const [pattern, handler] of rowPatterns) { const match = content.match(pattern as RegExp); if (match) { (handler as (m: RegExpMatchArray) => void)(match); content = content.slice(match[0].length); hasMatch = true; break; } } } const cellContents = content.split('||').filter(c => c.trim()); let columnIndex = 0; for (const cellContent of cellContents) { while (tracker.decrementColumn(columnIndex)) { row.cells.push({ content: '', colspan: 1, rowspan: 1, isPlaceholder: true }); columnIndex++; } const cell: TableCell = { content: cellContent.trim(), colspan: 1, rowspan: 1 }; const colAttrs: ColAttributes = {}; let remaining = cell.content; let hasMatch = true; while (hasMatch && remaining) { hasMatch = false; for (const [pattern, handler] of CELL_HANDLERS) { const match = remaining.match(pattern); if (match) { handler(match, cell, colAttrs); remaining = remaining.slice(match[0].length); hasMatch = true; break; } } } cell.content = remaining; if (isFirstRow && Object.keys(colAttrs).length) { ['BgColor', 'Color', 'Keepall'].forEach(attr => { const key = `col${attr}` as keyof ColAttributes; const value = colAttrs[key]; if (value !== undefined) { const blockKey = `col${attr}s` as keyof TableBlock; if (!block[blockKey]) { const arr: (ColorValue | boolean | undefined)[] = []; (block as unknown as Record)[blockKey] = arr; } const arr = (block as unknown as Record)[ blockKey ]; for (let i = 0; i < cell.colspan; i++) { arr[columnIndex + i] = attr === 'Keepall' ? true : value; } } }); } if (cell.rowspan > 1) tracker.setColumnSpan(columnIndex, cell.rowspan); row.cells.push(cell); columnIndex += cell.colspan; } while (tracker.decrementColumn(columnIndex++)) { row.cells.push({ content: '', colspan: 1, rowspan: 1, isPlaceholder: true }); } return row; } function renderCellContent(content: string): string { const lines = content.split('\n'); const { blocks: listBlocks } = parseLists(lines); const { blocks: mediaBlocks } = parseMediaEmbeds(lines); const allBlocks = [...listBlocks, ...mediaBlocks].sort((a, b) => a.startLine - b.startLine); if (!allBlocks.length) { return lines.map(line => formatText(line)).join('
'); } const segments: string[] = []; let lastLine = -1; for (const block of allBlocks) { const textBefore = lines .slice(lastLine + 1, block.startLine) .filter(l => l.trim()) .map(l => formatText(l)); if (textBefore.length) segments.push(...textBefore); if ('items' in block) { segments.push(renderList(block)); } else { segments.push(block.html); } lastLine = block.endLine; } const textAfter = lines .slice(lastLine + 1) .filter(l => l.trim()) .map(l => formatText(l)); if (textAfter.length) segments.push(...textAfter); return segments.join('
'); } export function renderTable(block: TableBlock): string { const attrs: string[] = []; const styles: string[] = []; const classes: string[] = []; if (block.width) { styles.push(`width: ${block.width}`, `max-width: ${block.width}`); classes.push('custom-width'); } const addColorStyle = (color: ColorValue | undefined, prefix: string) => { if (!color) return; styles.push(`--${prefix}: ${color.light}`); if (color.dark) styles.push(`--${prefix}-dark: ${color.dark}`); }; addColorStyle(block.bgcolor, 'table-bg'); addColorStyle(block.color, 'table-color'); addColorStyle(block.bordercolor, 'table-border'); if ( block.bgcolor || block.color || block.bordercolor || block.colBgColors?.some(c => c) || block.colColors?.some(c => c) || block.rows.some( row => row.bgcolor || row.color || row.cells.some(cell => cell.bgcolor || cell.color) ) ) { classes.push('custom-colors'); } const tableTag = buildTag('table', attrs, classes, styles); const rows = block.rows.map(row => renderRow(row, block)).join('\n'); return `
\n${tableTag}\n${rows}\n\n
`; } function renderRow(row: TableRow, block: TableBlock): string { const styles: string[] = []; const classes: string[] = []; const addColorStyle = (color: ColorValue | undefined, prefix: string) => { if (!color) return; styles.push(`--${prefix}: ${color.light}`); if (color.dark) styles.push(`--${prefix}-dark: ${color.dark}`); }; addColorStyle(row.bgcolor, 'row-bg'); addColorStyle(row.color, 'row-color'); if (row.keepall) classes.push('keepall'); if (row.bgcolor || row.color) classes.push('custom-colors'); const rowTag = buildTag('tr', [], classes, styles); let cells = ''; let colIdx = 0; for (const cell of row.cells) { if (cell.isPlaceholder) { colIdx++; continue; } const cellAttrs: string[] = []; const cellStyles: string[] = []; const cellClasses: string[] = []; if (cell.colspan > 1) cellAttrs.push(`colspan="${cell.colspan}"`); if (cell.rowspan > 1) cellAttrs.push(`rowspan="${cell.rowspan}"`); if (cell.align) cellStyles.push(`text-align: ${cell.align}`); if (cell.valign) cellStyles.push(`vertical-align: ${cell.valign}`); if (cell.width) cellStyles.push(`width: ${cell.width}`); if (cell.height) cellStyles.push(`height: ${cell.height}`); const bgColor = cell.bgcolor || block.colBgColors?.[colIdx]; const color = cell.color || block.colColors?.[colIdx]; const keepall = cell.keepall || row.keepall || block.colKeepall?.[colIdx]; const addCellColorStyle = (color: ColorValue | undefined, prefix: string) => { if (!color) return; cellStyles.push(`--${prefix}: ${color.light}`); if (color.dark) cellStyles.push(`--${prefix}-dark: ${color.dark}`); }; addCellColorStyle(bgColor, 'cell-bg'); addCellColorStyle(color, 'cell-color'); if (cell.nopad) cellClasses.push('nopad'); if (keepall) cellClasses.push('keepall'); if (bgColor || color) cellClasses.push('custom-colors'); const cellTag = buildTag('td', cellAttrs, cellClasses, cellStyles); cells += `\n${cellTag}${renderCellContent(cell.content.replace(/\[br\]/g, '\n'))}`; colIdx += cell.colspan; } return `${rowTag}${cells}\n`; } function buildTag(name: string, attrs: string[], classes: string[], styles: string[]): string { let tag = `<${name}`; if (attrs.length) tag += ' ' + attrs.join(' '); if (classes.length) tag += ` class="${classes.join(' ')}"`; if (styles.length) tag += ` style="${styles.join('; ')}"`; return tag + '>'; }