UNPKG

symfony-style-console

Version:

Use the style and utilities of the Symfony Console in Node.js

635 lines (634 loc) 21.4 kB
import { sprintf, range, strPad, lengthWithoutDecoration, removeDecoration, countOccurences, arrayReplaceRecursive, arrayFill, chunkString, arrContains } from './Helper'; import TableCell from './TableCell'; import TableSeparator from './TableSeparator'; import TableStyle from './TableStyle'; /** * Provides helpers to display a table. * * @author Fabien Potencier <fabien@symfony.com> * * Original PHP Class * * @author Саша Стаменковић <umpirsky@gmail.com> * @author Abdellatif Ait boudad <a.aitboudad@gmail.com> * @author Max Grigorian <maxakawizard@gmail.com> * * @author Florian Reuschel <florian@loilo.de> * * Port to TypeScript */ export default class Table { constructor(output) { /** * Table headers. */ this.headers = []; /** * Table rows. */ this.rows = []; /** * Column widths cache. */ this.effectiveColumnWidths = []; /** * Column Styles */ this.columnStyles = []; /** * User set column widths */ this.columnWidths = []; this.output = output; if (!Table.styles) { Table.styles = Table.initStyles(); } } /** * Sets a style definition. * * @param name The style name * @param style A TableStyle instance */ static setStyleDefinition(name, style) { if (!Table.styles) { Table.styles = Table.initStyles(); } Table.styles[name] = style; } /** * Gets a style definition by name. * * @param name The style name * @return TableStyle */ static getStyleDefinition(name) { if (!Table.styles) { Table.styles = Table.initStyles(); } if (Table.styles[name]) { return Table.styles[name]; } throw new Error(`Style "${name}" is not defined.`); } static initStyles() { const borderless = new TableStyle(); borderless .setHorizontalBorderChar('=') .setVerticalBorderChar(' ') .setCrossingChar(' '); const compact = new TableStyle(); compact .setHorizontalBorderChar('') .setVerticalBorderChar(' ') .setCrossingChar('') .setCellRowContentFormat('%s'); const styleGuide = new TableStyle(); styleGuide .setHorizontalBorderChar('-') .setVerticalBorderChar(' ') .setCrossingChar(' ') .setCellHeaderFormat('%s'); return { default: new TableStyle(), borderless: borderless, compact: compact, 'symfony-style-guide': styleGuide }; } /** * Sets table style. * * @param name The style name or a TableStyle instance * @return this */ setStyle(name) { this.style = this.resolveStyle(name); return this; } /** * Gets the current table style. * * @return TableStyle */ getStyle() { return this.style; } /** * Sets table column style. * * @param columnIndex Column index * @param name The style name or a TableStyle instance * * @return this */ setColumnStyle(columnIndex, name) { columnIndex = Math.round(columnIndex); this.columnStyles[columnIndex] = this.resolveStyle(name); return this; } /** * Gets the current style for a column. * * If style was not set, it returns the global table style. * * @param columnIndex Column index * * @return TableStyle */ getColumnStyle(columnIndex) { if (this.columnStyles[columnIndex]) { return this.columnStyles[columnIndex]; } return this.getStyle(); } /** * Sets the minimum width of a column. * * @param columnIndex Column index * @param width Minimum column width in characters * * @return this */ setColumnWidth(columnIndex, width) { this.columnWidths[Math.round(columnIndex)] = Math.round(width); return this; } /** * Sets the minimum width of all columns. * * @param widths * * @return this */ setColumnWidths(widths) { this.columnWidths = []; for (let index = 0; index < widths.length; index++) { if (typeof widths[index] === 'undefined') continue; const width = widths[index]; this.setColumnWidth(index, width); } return this; } setHeaders(headers) { const isNestedRows = (headers) => !(headers.length && !Array.isArray(headers[0])); if (!isNestedRows(headers)) { headers = [headers]; } this.headers = headers; return this; } setRows(rows) { this.rows = []; return this.addRows(rows); } addRows(rows) { for (const row of rows) { this.addRow(row); } return this; } addRow(row) { if (row instanceof TableSeparator) { this.rows.push(row); return this; } if (!Array.isArray(row)) { throw new Error('A row must be an array or a TableSeparator instance.'); } this.rows.push(row); return this; } setRow(column, row) { this.rows[column] = row; return this; } /** * Renders table to output. * * Example: * +---------------+-----------------------+------------------+ * | ISBN | Title | Author | * +---------------+-----------------------+------------------+ * | 99921-58-10-7 | Divine Comedy | Dante Alighieri | * | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | * | 960-425-059-0 | The Lord of the Rings | J. R. R. Tolkien | * +---------------+-----------------------+------------------+ */ render() { this.calculateNumberOfColumns(); const rows = this.buildTableRows(this.rows); const headers = this.buildTableRows(this.headers); this.calculateColumnsWidth(headers.concat(rows)); this.renderRowSeparator(); if (headers.length) { for (const header of headers) { this.renderRow(header, this.style.getCellHeaderFormat()); this.renderRowSeparator(); } } for (const row of rows) { if (row instanceof TableSeparator) { this.renderRowSeparator(); } else { this.renderRow(row, this.style.getCellRowFormat()); } } if (rows.length) { this.renderRowSeparator(); } this.cleanup(); } /** * Gets number of columns by row. * * @param row * * @return int */ getNumberOfColumns(row) { let columns = row.filter(cell => typeof cell !== 'undefined').length; for (const column of row) { columns += column instanceof TableCell ? column.getColspan() - 1 : 0; } return columns; } /** * Gets list of columns for the given row. * * @param array row * * @return array */ getRowColumns(row) { let columns = range(0, this.numberOfColumns - 1); for (let cellKey = 0; cellKey < row.length; cellKey++) { if (typeof row[cellKey] === 'undefined') continue; const cell = row[cellKey]; if (cell instanceof TableCell && cell.getColspan() > 1) { // exclude grouped columns. const diffRange = range(cellKey + 1, cellKey + cell.getColspan() - 1); columns = columns.filter(column => !arrContains(diffRange, column)); } } return columns; } /** * Gets column width. * * @return int */ getColumnSeparatorWidth() { return sprintf(this.style.getBorderFormat(), this.style.getVerticalBorderChar()).length; } /** * Gets cell width. * * @param row * @param column * * @return int */ getCellWidth(row, column) { let cellWidth = 0; if (row[column]) { const cell = row[column]; const cellStr = String(cell); cellWidth = lengthWithoutDecoration(this.output.getFormatter(), cellStr); } const columnWidth = this.columnWidths[column] || 0; return Math.max(cellWidth, columnWidth); } /** * Renders horizontal header separator. * * Example: +-----+-----------+-------+ */ renderRowSeparator() { const count = this.numberOfColumns; if (!count) { return; } if (!this.style.getHorizontalBorderChar() && !this.style.getCrossingChar()) { return; } let markup = this.style.getCrossingChar(); for (let column = 0; column < count; ++column) { markup += this.style .getHorizontalBorderChar() .repeat(this.effectiveColumnWidths[column]) + this.style.getCrossingChar(); } this.output.writeln(sprintf(this.style.getBorderFormat(), markup)); } /** * Renders vertical column separator. */ renderColumnSeparator() { return sprintf(this.style.getBorderFormat(), this.style.getVerticalBorderChar()); } /** * Renders table row. * * Example: | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | * * @param row * @param cellFormat */ renderRow(row, cellFormat) { if (!row.length) { return; } let rowContent = this.renderColumnSeparator(); for (const column of this.getRowColumns(row)) { rowContent += this.renderCell(row, column, cellFormat); rowContent += this.renderColumnSeparator(); } this.output.writeln(rowContent); } /** * Renders table cell with padding. * * @param row * @param column * @param cellFormat */ renderCell(row, column, cellFormat) { const cell = row[column] || ''; const cellStr = String(cell); let width = this.effectiveColumnWidths[column]; if (cell instanceof TableCell && cell.getColspan() > 1) { // add the width of the following columns(numbers of colspan). for (const nextColumn of range(column + 1, column + cell.getColspan() - 1)) { width += this.getColumnSeparatorWidth() + this.effectiveColumnWidths[nextColumn]; } } const style = this.getColumnStyle(column); if (cell instanceof TableSeparator) { return sprintf(style.getBorderFormat(), style.getHorizontalBorderChar().repeat(width)); } width += cellStr.length - lengthWithoutDecoration(this.output.getFormatter(), cellStr); const content = sprintf(style.getCellRowContentFormat(), cell); return sprintf(cellFormat, strPad(content, width, style.getPaddingChar(), style.getPadType())); } /** * Calculate number of columns for this table. */ calculateNumberOfColumns() { if (null != this.numberOfColumns) { return; } const columns = [0]; for (const row of this.headers.concat(this.rows)) { if (row instanceof TableSeparator) { continue; } columns.push(this.getNumberOfColumns(row)); } this.numberOfColumns = Math.max(...columns); } buildTableRows(rows) { let unmergedRows = []; rows = rows.slice(0); for (let rowKey = 0; rowKey < rows.length; ++rowKey) { if (typeof rows[rowKey] === 'undefined') continue; if (rows[rowKey] instanceof TableSeparator) continue; rows = this.fillNextRows(rows, rowKey); const row = rows[rowKey]; // Remove any new line breaks and replace it with a new line for (let column = 0; column < row.length; column++) { if (typeof row[column] === 'undefined') continue; const cell = row[column]; const cellStr = String(cell); if (cellStr.includes('\n')) { continue; } const lines = cellStr.split('\n'); for (let lineKey = 0; lineKey < lines.length; lineKey++) { let line = lines[lineKey]; if (cell instanceof TableCell) { line = new TableCell(line, { colspan: cell.getColspan() }); } if (0 === lineKey) { row[column] = line; } else { if (!Array.isArray(unmergedRows[rowKey])) unmergedRows[rowKey] = []; if (!Array.isArray(unmergedRows[rowKey][lineKey])) unmergedRows[rowKey][lineKey] = []; unmergedRows[rowKey][lineKey][column] = line; } } } } let tableRows = []; for (let rowKey = 0; rowKey < rows.length; rowKey++) { if (typeof rows[rowKey] === 'undefined') continue; if (rows[rowKey] instanceof TableSeparator) continue; const row = rows[rowKey]; tableRows.push(this.fillCells(row)); if (unmergedRows[rowKey]) { tableRows = tableRows.concat(unmergedRows[rowKey]); } } return tableRows; } /** * fill rows that contains rowspan > 1. * * @param inputRows * @param line * * @return array */ fillNextRows(inputRows, line) { let unmergedRows = ([] = []); if (inputRows[line] instanceof TableSeparator) return inputRows.slice(0); const rows = inputRows.slice(0); for (let column = 0; column < rows[line].length; column++) { const cell = rows[line][column]; if (cell instanceof TableCell && cell.getRowspan() > 1) { const cellStr = String(cell); let nbLines = cell.getRowspan() - 1; let lines = [cellStr]; if (cellStr.includes('\n')) { lines = cellStr.split('\n'); nbLines = lines.length > nbLines ? countOccurences(cellStr, '\n') : nbLines; rows[line][column] = new TableCell(lines[0], { colspan: cell.getColspan() }); delete lines[0]; } // create a two dimensional array (rowspan x colspan) unmergedRows = arrayReplaceRecursive(arrayFill(line + 1, nbLines, []), unmergedRows); for (let unmergedRowKey = 0; unmergedRowKey < unmergedRows.length; unmergedRowKey++) { if (typeof unmergedRows[unmergedRowKey] === 'undefined') continue; const unmergedRow = unmergedRows[unmergedRowKey]; const value = lines[unmergedRowKey - line] || ''; unmergedRows[unmergedRowKey][column] = new TableCell(value, { colspan: cell.getColspan() }); if (nbLines === unmergedRowKey - line) { break; } } } } for (let unmergedRowKey = 0; unmergedRowKey < unmergedRows.length; unmergedRowKey++) { if (typeof unmergedRows[unmergedRowKey] === 'undefined') continue; const unmergedRow = unmergedRows[unmergedRowKey]; // we need to know if unmergedRow will be merged or inserted into rows if (typeof rows[unmergedRowKey] !== 'undefined' && Array.isArray(rows[unmergedRowKey]) && this.getNumberOfColumns(rows[unmergedRowKey]) + this.getNumberOfColumns(unmergedRows[unmergedRowKey]) <= this.numberOfColumns) { for (let cellKey = 0; cellKey < unmergedRow.length; cellKey++) { if (typeof unmergedRow[cellKey] === 'undefined') continue; const cell = unmergedRow[cellKey]; // insert cell into row at cellKey position rows[unmergedRowKey].splice(cellKey, 0, cell); } } else { const row = this.copyRow(rows, unmergedRowKey - 1); for (let column = 0; column < unmergedRow.length; column++) { if (typeof unmergedRow[column] === 'undefined') continue; const cell = unmergedRow[column]; const cellStr = String(cell); if (cellStr.length) { row[column] = unmergedRow[column]; } } rows.splice(unmergedRowKey, 0, row); } } return rows; } /** * fill cells for a row that contains colspan > 1. * * @param row * * @return array */ fillCells(row) { const newRow = []; for (let column = 0; column < row.length; column++) { if (typeof row[column] === 'undefined') continue; const cell = row[column]; newRow.push(cell); if (cell instanceof TableCell && cell.getColspan() > 1) { for (const position of range(column + 1, column + cell.getColspan() - 1)) { // insert empty value at column position newRow.push(''); } } } return newRow || row; } /** * @param rows * @param line * * @return array */ copyRow(rows, line) { const row = rows[line].slice(0); for (let cellKey = 0; cellKey < row.length; cellKey++) { if (typeof row[cellKey] === 'undefined') continue; const cellValue = row[cellKey]; row[cellKey] = ''; if (cellValue instanceof TableCell) { row[cellKey] = new TableCell('', { colspan: cellValue.getColspan() }); } } return row; } /** * Calculates columns widths. * * @param array rows */ calculateColumnsWidth(rows) { rows = rows.slice(0); for (let column = 0; column < this.numberOfColumns; ++column) { const lengths = []; for (let row of rows) { if (row instanceof TableSeparator) { continue; } row = row.slice(0); for (let i = 0; i < row.length; i++) { if (typeof row[i] === 'undefined') continue; const cell = row[i]; if (cell instanceof TableCell) { const textContent = removeDecoration(this.output.getFormatter(), String(cell)); const textLength = textContent.length; if (textLength > 0) { const contentColumns = chunkString(textContent, Math.ceil(textLength / cell.getColspan())); if (contentColumns === false) { throw new Error(`Could not chunk string: ${textContent}`); } for (let position = 0; position < contentColumns.length; position++) { if (typeof contentColumns[position] === 'undefined') continue; const content = contentColumns[position]; row[i + position] = content; } } } } lengths.push(this.getCellWidth(row, column)); } this.effectiveColumnWidths[column] = Math.max(...lengths) + this.style.getCellRowContentFormat().length - 2; } } /** * Called after rendering to cleanup cache data. */ cleanup() { this.effectiveColumnWidths = []; this.numberOfColumns = null; } resolveStyle(name) { if (name instanceof TableStyle) { return name; } if (Table.styles[name]) { return Table.styles[name]; } throw new Error(`Style "${name}" is not defined.`); } }