UNPKG

@felisdiligens/md-table-tools

Version:

MultiMarkdown table tools

516 lines (442 loc) 20 kB
import { Table, TableCaption, TableCaptionPosition, TableCell, TableCellMerge, TableRow, TextAlignment } from "./table.js"; import { ParsingError, TableParser } from "./tableParser.js"; import { TableRenderer } from "./tableRenderer.js"; import stringWidth from "string-width"; /* Specification: https://fletcher.github.io/MultiMarkdown-6/syntax/tables.html */ const rowRegex = /^\|(.+)\|$/ const separatorRegex = /^\|([\s\.]*:?[\-=\.]+:?\+?[\s\.]*\|)+$/; const captionRegex = /^(\[.+\]){1,2}$/; enum ParsingState { BeforeTable, TopCaption, Header, Separator, Row, BottomCaption, AfterTable } export class MultiMarkdownTableParser implements TableParser { public parse(table: string): Table { let parsedTable = new Table(); let state = ParsingState.BeforeTable; let startNewSection = false; let hasSeparator = false; let beforeTable = []; let afterTable = []; let isMultiline = false; let wasMultiline = false; // Parse line by line: for (let line of table.split("\n")) { /* Determine parsing state and prepare: */ // Reset values: wasMultiline = isMultiline; isMultiline = false; // Check if we are in the table: if (state == ParsingState.BeforeTable && (line.match(/[^|\\`]\|/g) || line.trim().match(captionRegex))) { if (line.trim().match(captionRegex)) state = ParsingState.TopCaption; else state = ParsingState.Header; } // Check if we are no longer in the table: if (state != ParsingState.BeforeTable && state != ParsingState.AfterTable && !( // If not: ((line.trim().endsWith("\\") || wasMultiline || line.replace(/\`[^\`]*\`/g, "").match(/[^|\\`]\|/g)) /* && !line.startsWith("[") && !line.endsWith("]") */) || // row // ↑ What was I thinking? (line.trim().match(captionRegex) && (state == ParsingState.TopCaption || parsedTable.caption == null)) || // valid caption (line.trim() === "" && !startNewSection && state != ParsingState.Separator))) { // single empty line allowed (except after separator) state = ParsingState.AfterTable; if (startNewSection) afterTable.push(""); } // If not inside table: if (state == ParsingState.BeforeTable) { beforeTable.push(line); continue; // Skip the rest } else if (state == ParsingState.AfterTable) { afterTable.push(line); continue; // Skip the rest } // Is empty line? if (line === "") { if (startNewSection) throw new ParsingError("Invalid table: No more than one empty line allowed."); if (state == ParsingState.Row) startNewSection = true; continue; } // Format table line: line = line.trim(); if (!line.match(captionRegex)) { if (!line.startsWith("|")) { line = "|" + line; } if (line.endsWith("\\")) { isMultiline = true; line = line.substring(0, line.length - 1).trim(); } if (!line.endsWith("|") || (line.charAt(line.length - 3) != "\\" && line.endsWith("\\|"))) { // Check if last pipe is escaped ('\|') line = line + "|"; } if (!line.match(rowRegex)) { throw new ParsingError(`Invalid row: ${line}`); } } // Is separator? if ((state == ParsingState.TopCaption || state == ParsingState.Header) && line.match(separatorRegex)) { state = ParsingState.Separator; } // Is header? else if (state == ParsingState.TopCaption && line.match(rowRegex)) { state = ParsingState.Header; } // Is bottom caption? else if ((state == ParsingState.Separator || state == ParsingState.Row) && line.match(captionRegex)) { state = ParsingState.BottomCaption; } // If separator has been parsed last iteration: else if (state == ParsingState.Separator) { state = ParsingState.Row; } /* Parse line depending on parsing state: */ if (state == ParsingState.Header || state == ParsingState.Row) { let tableRow = new TableRow(); if (state == ParsingState.Header) { tableRow.isHeader = true; } else { tableRow.startsNewSection = startNewSection; tableRow.isMultiline = isMultiline; startNewSection = false; } parsedTable.addRow(-1, tableRow); // Parse each character: let cellContent = ""; let colIndex = 0; let slashEscaped = false; let fenceEscaped = false; for (let char of line.substring(1, line.length)) { if (!slashEscaped && !fenceEscaped && char == "|") { let tableColumn = parsedTable.getColumn(colIndex); if (!tableColumn) tableColumn = parsedTable.addColumn(); let cell = new TableCell(parsedTable, tableRow, tableColumn); parsedTable.addCell(cell); if (cellContent.trim() == "^^") { cell.merged = TableCellMerge.above; } else if (cellContent === "") { cell.merged = TableCellMerge.left; } else { cell.setText( cellContent .trim() .replace(/(<\s*[bB][rR]\s*\/?>)/g, "\n") ); } cellContent = ""; colIndex++; } else if (!slashEscaped && char == "\\") { slashEscaped = true; } else { if (!slashEscaped && char == "\`" && !isMultiline && !wasMultiline) fenceEscaped = !fenceEscaped; if (slashEscaped) cellContent += "\\"; cellContent += char; slashEscaped = false; } } // Insert empty cells if missing: for (; colIndex < parsedTable.columnCount(); colIndex++) { let cell = new TableCell(parsedTable, tableRow, parsedTable.getColumn(colIndex)); parsedTable.addCell(cell); } } else if (state == ParsingState.Separator) { hasSeparator = true; let colIndex = 0; let alignment = TextAlignment.default; let wrappable = false; let separator = false; for (let char of line.substring(1, line.length)) { if (char == "|") { let tableColumn = parsedTable.getColumn(colIndex); if (!tableColumn) tableColumn = parsedTable.addColumn(); tableColumn.textAlign = alignment; tableColumn.wrappable = wrappable; alignment = TextAlignment.default; separator = false; wrappable = false; colIndex++; } else if (char == ":") { if (!separator) { alignment = TextAlignment.left; } else { if (alignment == TextAlignment.left) alignment = TextAlignment.center; else alignment = TextAlignment.right; } } else if (char == "-" || char == "=") { separator = true; if (alignment == TextAlignment.right || wrappable) throw new ParsingError("Invalid separator"); } else if (char == "+") { // "If the separator line ends with +, then cells in that column will be wrapped when exporting to LaTeX if they are long enough." wrappable = true; } // char == "." => idk ??? } } else if (state == ParsingState.TopCaption || state == ParsingState.BottomCaption) { // "If you have a caption before and after the table, only the first match will be used." if (parsedTable.caption != null) continue; let caption = new TableCaption(); caption.position = state == ParsingState.TopCaption ? TableCaptionPosition.top : TableCaptionPosition.bottom; let split = line.split(/[\[\]]+/).filter(s => s.trim() !== ""); caption.text = split[0] .trim() .replace(/(<\s*[bB][rR]\s*\/?>)/g, "\n"); if (split.length > 1) caption.label = split[1] .trim() .replace(/\s+/g, "-"); parsedTable.caption = caption; } else { throw new ParsingError(`Not implemented ParsingState: ${state}`); } } if (!hasSeparator) throw new ParsingError("No separator row found."); parsedTable.beforeTable = beforeTable.join("\n"); parsedTable.afterTable = afterTable.join("\n"); return parsedTable.update(); } } export class MinifiedMultiMarkdownTableRenderer implements TableRenderer { public constructor( public renderOutsideTable = true) { } public render(table: Table): string { const headerRows = table.getHeaderRows(); const normalRows = table.getNormalRows(); let result: string[] = []; if (this.renderOutsideTable && table.beforeTable.trim() !== "") result.push(table.beforeTable); // Caption (if position is top): if (table.caption && table.caption.position == TableCaptionPosition.top) { result.push(this.renderCaption(table.caption)); } // Header: if (headerRows.length > 0) for (const row of headerRows) result.push(this.renderRow(table, row)); // Separator: result.push(this.renderSeparator(table)); // Rows: for (const row of normalRows) { if (row.startsNewSection) result.push(""); result.push(this.renderRow(table, row)); } // Caption (if position is bottom): if (table.caption && table.caption.position == TableCaptionPosition.bottom) { result.push(this.renderCaption(table.caption)); } if (this.renderOutsideTable && table.afterTable.trim() !== "") result.push(table.afterTable); return result.join("\n"); } private renderCaption(caption: TableCaption): string { let result: string[] = []; if (caption.text.length > 0) { result.push(`[${caption.text}]`); if (caption.label.length > 0) { result.push(`[${caption.label}]`); } } return result.join(""); } private renderSeparator(table: Table): string { let result: string[] = []; table.getColumns().forEach((col, i) => { let chunk; switch (col.textAlign) { case TextAlignment.left: chunk = ":-"; break; case TextAlignment.center: chunk = ":-:"; break; case TextAlignment.right: chunk = "-:"; break; case TextAlignment.default: default: chunk = "-"; break; } result.push(chunk + (col.wrappable ? "+" : "")); }); return result.join("|"); } private renderRow(table: Table, row: TableRow): string { let result: string = ""; let cells = table.getCellsInRow(row); cells.forEach((cell, i) => { if (cell.merged == TableCellMerge.left) { result += "|"; } else if (cell.merged == TableCellMerge.above) { result += "^^|"; } else if (i == 0 && cell.text.trim() === "") { result += "| |"; } else if (cell.text.trim() === "") { result += " |"; } else { let text = cell.text.trim().replace(/\r?\n/g, "<br>"); result += `${text}|`; } // Last cell: if (i == cells.length - 1 && cell.text.trim() != "" && cell.merged != TableCellMerge.left) result = result.substring(0, result.length - 1); // Omit last '|' if possible }); if (row.isMultiline) result += " \\"; return result; } } export class PrettyMultiMarkdownTableRenderer implements TableRenderer { public constructor( public renderOutsideTable = true) { } public render(table: Table): string { const headerRows = table.getHeaderRows(); const normalRows = table.getNormalRows(); const columnWidths = this.determineColumnWidths(table); let result: string[] = []; if (this.renderOutsideTable && table.beforeTable.trim() !== "") result.push(table.beforeTable); // Caption (if position is top): if (table.caption && table.caption.position == TableCaptionPosition.top) { result.push(this.renderCaption(table.caption)); } // Header: if (headerRows.length > 0) for (const row of headerRows) result.push(this.renderRow(table, row, columnWidths)); // Separator: result.push(this.renderSeparator(table, columnWidths)); // Rows: for (const row of normalRows) { if (row.startsNewSection) result.push(""); result.push(this.renderRow(table, row, columnWidths)); } // Caption (if position is bottom): if (table.caption && table.caption.position == TableCaptionPosition.bottom) { result.push(this.renderCaption(table.caption)); } if (this.renderOutsideTable && table.afterTable.trim() !== "") result.push(table.afterTable); return result.join("\n"); } private renderCaption(caption: TableCaption): string { let result: string[] = []; if (caption.text.length > 0) { result.push(`[${caption.text}]`); if (caption.label.length > 0) { result.push(`[${caption.getLabel()}]`); } } return result.join(""); } private renderSeparator(table: Table, columnWidths: number[]): string { let result: string[] = []; table.getColumns().forEach((col, i) => { let width = columnWidths[i]; switch (col.textAlign) { case TextAlignment.left: if (col.wrappable) result.push(`:${"-".repeat(width)}+`); else result.push(`:${"-".repeat(width + 1)}`); break; case TextAlignment.center: if (col.wrappable) result.push(`:${"-".repeat(width - 1)}:+`); else result.push(`:${"-".repeat(width)}:`); break; case TextAlignment.right: if (col.wrappable) result.push(`${"-".repeat(width)}:+`); else result.push(`${"-".repeat(width + 1)}:`); break; case TextAlignment.default: default: if (col.wrappable) result.push(`${"-".repeat(width + 1)}+`); else result.push("-".repeat(width + 2)); break; } }); return `|${result.join("|")}|`; } private renderRow(table: Table, row: TableRow, columnWidths: number[]): string { let result: string[] = []; table.getCellsInRow(row).forEach((cell, i) => { let colspan = cell.getColspan(); let cellWidth = columnWidths[i]; if (colspan > 1) { for (let col = i + 1; col < i + colspan; col++) cellWidth += columnWidths[col]; cellWidth += colspan * 2 - 2; // + Math.floor((colspan - 1) / 2); } result.push(this.renderCell(cell, colspan, cellWidth)); }); return `|${result.join("|")}|` + (row.isMultiline ? " \\" : ""); } private renderCell(cell: TableCell, colspan: number = 1, cellWidth: number = -1): string { if (cell.merged == TableCellMerge.left) return ""; let text = cell.merged == TableCellMerge.above ? "^^" : cell.text.replace(/\r?\n/g, "<br>"); const textLength = stringWidth(text); switch (cell.column.textAlign) { case TextAlignment.center: return `${" ".repeat(Math.max(0, Math.floor((cellWidth - textLength + colspan - 1) / 2)))} ${text} ${" ".repeat(Math.max(0, Math.ceil((cellWidth - textLength - colspan + 1) / 2)))}`; case TextAlignment.right: return `${" ".repeat(Math.max(0, cellWidth - textLength))} ${text} `; case TextAlignment.left: case TextAlignment.default: default: return ` ${text} ${" ".repeat(Math.max(0, cellWidth - textLength))}`; } } private determineColumnWidths(table: Table): number[] { let columnWidths: number[] = Array.from({length: table.columnCount()}, () => 0); for (let colIndex = table.columnCount() - 1; colIndex >= 0; colIndex--) { const column = table.getColumn(colIndex); let width = 0; for (const cell of table.getCellsInColumn(column)) { let colspan = cell.getColspan(); let textWidth = cell.merged == TableCellMerge.above ? 2 : stringWidth(cell.text.replace(/\r?\n/g, "<br>")); if (colspan == 1) { width = Math.max(textWidth, width); } else { let leftoverWidth = columnWidths.slice(colIndex + 1, colIndex + colspan).reduce((pv, cv) => pv + cv); // let combinedWidth = width + leftoverWidth; width = Math.max(textWidth - leftoverWidth, width); } } columnWidths.splice(colIndex, 1, width); } return columnWidths; } }