UNPKG

@felisdiligens/md-table-tools

Version:

MultiMarkdown table tools

484 lines (413 loc) 16 kB
/** indicates how text is aligned in a column */ export enum TextAlignment { left = "left", center = "center", right = "right", default = "start" } /** indicates how a cell is merged with a neighboring cell */ export enum TableCellMerge { above, left, none } /** indicates the placement of the table caption */ export enum TableCaptionPosition { top = "top", bottom = "bottom" } export class IndexOutOfBoundsError extends Error { constructor(msg: string) { super(msg); // Set the prototype explicitly. Object.setPrototypeOf(this, IndexOutOfBoundsError.prototype); } } export class TableCaption { public constructor( public text = "", public label = "", public position = TableCaptionPosition.top) { } public getLabel(): string { // "If you have a caption, you can also have a label, allowing you to create anchors pointing to the table. If there is no label, then the caption acts as the label" if (typeof this.label === 'string' && this.label.trim() !== "") return this.label.trim().replace(/\s/g, "-"); return this.text.trim().toLowerCase().replace(/\s/g, "-").replace(/[^a-zA-Z0-9]/g, ""); } } export class TableCell { public text: string; public table: Table; public row: TableRow; public column: TableColumn; public merged: TableCellMerge; public isHeader: boolean; public textAlign: TextAlignment; public constructor(table: Table, row: TableRow, column: TableColumn) { this.text = ""; this.table = table; this.row = row; this.column = column; this.merged = TableCellMerge.none; this.isHeader = false; this.textAlign = TextAlignment.default; } public isHeaderCell(): boolean { return this.row.isHeader || this.isHeader; } public getTextAlignment(): TextAlignment { if (this.textAlign != TextAlignment.default) return this.textAlign; return this.column.textAlign; } public setText(text: string) { this.text = text; } public getColspan(): number { if (this.merged != TableCellMerge.left) { let col = this.table.indexOfColumn(this.column) + 1; if (col > this.table.columnCount()) return 1; let colspan = 1; let cells = this.table.getCellsInRow(this.row); for (; col < this.table.columnCount(); col++) { if (cells[col].merged == TableCellMerge.left) colspan++; else break; } return colspan; } return 1; } public getRowspan(): number { if (this.merged != TableCellMerge.above) { let row = this.table.indexOfRow(this.row) + 1; if (row > this.table.rowCount()) return 1; let rowspan = 1; let cells = this.table.getCellsInColumn(this.column); for (; row < this.table.rowCount(); row++) { if (cells[row].merged == TableCellMerge.above) rowspan++; else break; } return rowspan; } return 1; } } export class TableRow { public cells: TableCell[]; public constructor( public index: number = 0, public isHeader: boolean = false, /** Only pertains to MultiMarkdown multiline feature. Ignored by other parsers/renderers. See Table.mergeMultilineRows() */ public isMultiline: boolean = false, public startsNewSection: boolean = false) { this.cells = []; } public updateCells(table: Table) { if (table.columnCount() != this.cells.length) this.cells = table.getCells().filter(cell => cell.row == this); this.cells = this.cells.sort((a, b) => a.column.index - b.column.index); } public getCell(index: number): TableCell { return this.cells.at(index); } public getCells(): TableCell[] { return this.cells; } } export class TableColumn { public cells: TableCell[]; public constructor( public index: number = 0, public textAlign: TextAlignment = TextAlignment.default, public wrappable: boolean = false) { this.cells = []; } public updateCells(table: Table) { if (table.rowCount() != this.cells.length) this.cells = table.getCells().filter(cell => cell.column == this); this.cells = this.cells.sort((a, b) => a.row.index - b.row.index); } public getCell(index: number): TableCell { return this.cells.at(index); } public getCells(): TableCell[] { return this.cells; } } export class Table { private cells: TableCell[]; private rows: TableRow[]; private columns: TableColumn[]; public caption: TableCaption; /** Text before the table */ public beforeTable: string; /** Text after the table */ public afterTable: string; public constructor(rowNum: number = 0, colNum: number = 0) { this.cells = []; this.rows = Array.from({length: rowNum}, (_, i: number) => new TableRow(i)); this.columns = Array.from({length: colNum}, (_, i: number) => new TableColumn(i)); this.caption = null; this.beforeTable = ""; this.afterTable = ""; } /** * Adds a TableRow to the table. * @param index Insert row at index. -1 means it's appended. * @param row (optional) * @returns The added row. */ public addRow(index: number = -1, row: TableRow = new TableRow()): TableRow { if (index < 0) { row.index = this.rows.push(row) - 1; } else { row.index = index; this.rows.splice(index, 0, row); } return row; } /** * Adds a TableColumn to the table. * @param index Insert column at index. -1 means it's appended. * @param col (optional) * @returns The added column. */ public addColumn(index: number = -1, col: TableColumn = new TableColumn()): TableColumn { if (index < 0) { col.index = this.columns.push(col); } else { col.index = index; this.columns.splice(index, 0, col); } return col; } /** Gets the row at index. Negative index counts back from the end. Returns undefined if out-of-bounds. */ public getRow(index: number): TableRow { return this.rows.at(index); } /** Gets the index of the row. -1 if it hasn't been found. */ public indexOfRow(row: TableRow): number { return this.rows.indexOf(row); } /** Gets the column at index. Negative index counts back from the end. Returns undefined if out-of-bounds. */ public getColumn(index: number): TableColumn { return this.columns.at(index); } /** Gets the index of the column. -1 if it hasn't been found. */ public indexOfColumn(col: TableColumn): number { return this.columns.indexOf(col); } /** * Removes the given column. Also removes all cells within the column. * @param col Either index or object reference. */ public removeColumn(col: number | TableColumn) { let colObj = typeof col === "number" ? this.columns.at(col) : col; let columnCells = this.getCellsInColumn(colObj); this.cells = this.cells.filter(cell => !columnCells.includes(cell)); this.columns = this.columns.filter(c => c != colObj); } /** * Removes the given row. Also removes all cells within the row. * @param row Either index or object reference. */ public removeRow(row: number | TableRow) { let rowObj = typeof row === "number" ? this.rows.at(row) : row; let rowCells = this.getCellsInRow(rowObj); this.cells = this.cells.filter(cell => !rowCells.includes(cell)); this.rows = this.rows.filter(r => r != rowObj); } /** * Moves the given column to the new index. * @param col Either index or object reference. * @param newIndex The new index of the given column. * @throws {IndexOutOfBoundsError} Can't move column outside of table. */ public moveColumn(col: number | TableColumn, newIndex: number) { let colObj = typeof col === "number" ? this.columns.at(col) : col; if (colObj === undefined || newIndex >= this.columnCount() || newIndex < 0) throw new IndexOutOfBoundsError("(IndexOutOfBoundsError) Can't move column outside of table."); this.columns.splice(colObj.index, 1); this.columns.splice(newIndex, 0, colObj); colObj.index = newIndex; } /** * Moves the given row to the new index. * @param row Either index or object reference. * @param newIndex The new index of the given row. * @throws {IndexOutOfBoundsError} Can't move row outside of table. */ public moveRow(row: number | TableRow, newIndex: number) { let rowObj = typeof row === "number" ? this.rows.at(row) : row; if (rowObj === undefined || newIndex >= this.rowCount() || newIndex < 0) throw new IndexOutOfBoundsError("(IndexOutOfBoundsError) Can't move row outside of table."); this.rows.splice(rowObj.index, 1); this.rows.splice(newIndex, 0, rowObj); rowObj.index = newIndex; } /** Returns a list of all rows that are headers. */ public getHeaderRows(): TableRow[] { return this.rows.filter(r => r.isHeader); } /** Returns a list of all rows that aren't headers. */ public getNormalRows(): TableRow[] { return this.rows.filter(r => !r.isHeader); } /** Retruns all rows in the table, from top to bottom, including header rows. */ public getRows(): TableRow[] { return this.rows; } /** Returns all columns in the table, from left to right. */ public getColumns(): TableColumn[] { return this.columns; } /** Returns all cells in the table. Isn't necessarily sorted! */ public getCells(): TableCell[] { return this.cells; } /** * Returns all cells within the given row. * See also: {@link TableRow.getCells()} * @param row Either index or object reference. */ public getCellsInRow(row: number | TableRow): TableCell[] { return (typeof row === "number" ? this.rows[row] : row).cells; } /** * Returns all cells within the given column. * See also: {@link TableColumn.getCells()} * @param column Either index or object reference. */ public getCellsInColumn(column: number | TableColumn): TableCell[] { return (typeof column === "number" ? this.columns[column] : column).cells; } /** Returns the cell at row and column. */ private getCellByObjs(rowObj: TableRow, columnObj: TableColumn): TableCell { // Intersection of row / column: for (const cell of rowObj.cells) { if (columnObj.cells.includes(cell)) return cell; } let newCell = new TableCell(this, rowObj, columnObj); this.addCell(newCell); return newCell; } /** * Returns the cell at row and column. * If the cell doesn't already exist, it will be created. * @param row Either index or object reference. * @param column Either index or object reference. * @returns The cell at row and column. */ public getCell(row: number | TableRow, column: number | TableColumn): TableCell { return this.getCellByObjs( typeof row === "number" ? this.rows.at(row) : row, typeof column === "number" ? this.columns.at(column) : column ); } /** * Adds the cell to the Table and the cell's respective TableRow and TableColumn. * (Be careful not to add a cell with row/column that already exist. Otherwise, the added cell will be overshadowed and not be used.) */ public addCell(cell: TableCell) { this.cells.push(cell); cell.row.cells.push(cell); cell.column.cells.push(cell); } /** Returns the total amount of rows in the table, including the header rows. */ public rowCount(): number { return this.rows.length; } /** Returns the total amount of columns in the table. */ public columnCount(): number { return this.columns.length; } /** * → Ensures that all table cells exist. * → Updates indices and sorts the cells within rows and columns. * → Tries to find invalid configurations and sanitize them. * * Call this method after altering the table. */ public update(): Table { // Iterate over the entire table: let columnObj: TableColumn; let rowObj: TableRow; for (let colIndex = 0; colIndex < this.columns.length; colIndex++) { // Update the column's index: columnObj = this.columns[colIndex]; columnObj.index = colIndex; for (let rowIndex = 0; rowIndex < this.rows.length; rowIndex++) { // Update the row's index: rowObj = this.rows[rowIndex]; rowObj.index = rowIndex; // Use "getCellByObjs" to ensure that the cell gets created, if it doesn't exist already: this.getCellByObjs(rowObj, columnObj); } } // Update the column's 'cells' array: for (const column of this.columns) column.updateCells(this); // Update the row's 'cells' array: for (const row of this.rows) row.updateCells(this); this.sanitize(); return this; } /** Tries to find invalid configurations and sanitize them. */ private sanitize(): Table { if (this.getNormalRows().length > 0) { // Cannot merge cell above if in first row: for (const cell of this.getCellsInRow(this.getNormalRows()[0])) { if (cell.merged == TableCellMerge.above) cell.merged = TableCellMerge.none; } this.getNormalRows()[0].startsNewSection = false; } for (const cell of this.cells) { // Cannot merge cell left if in first column: if (cell.column == this.columns[0] && cell.merged == TableCellMerge.left) cell.merged = TableCellMerge.none; // Cannot merge cell above if in first row: if ((cell.row == this.rows[0] || cell.row.startsNewSection) && cell.merged == TableCellMerge.above) cell.merged = TableCellMerge.none; } return this; } /** * Merges multiline rows (from MultiMarkdown feature) into "normal" rows. * This will destroy MultiMarkdown formatting! Only use when rendering into different formats. */ public mergeMultilineRows(): Table { let newRows: TableRow[] = []; let merging = false; let actualRowIndex = 0; this.getRows().forEach((row, index) => { if (merging) { row.getCells().forEach((cell, index) => { const parentCell = newRows[actualRowIndex - 1].getCell(index); parentCell.setText(parentCell.text + "\n" + cell.text); }); } else { row.index = actualRowIndex; newRows.push(row); actualRowIndex++; } if (!merging && row.isMultiline) { merging = true; } else if (merging && !row.isMultiline) { merging = false; } row.isMultiline = false; }); this.rows = newRows; this.update(); return this; } }