nice-table
Version:
A non-overflowing `console.table` alternative with customization options.
293 lines (257 loc) • 8.91 kB
text/typescript
import wrapAnsi from 'wrap-ansi';
import stringWidth from 'string-width';
export type HorizontalAlignment = 'left' | 'middle' | 'right';
export type VerticalAlignment = 'top' | 'middle' | 'bottom';
export type ColumnSizing = 'stretch' | 'even';
export type TableOptions = {
maxWidth: number;
columnSizing: ColumnSizing;
horizontalAlignment: HorizontalAlignment;
verticalAlignment: VerticalAlignment;
fullWidth: boolean;
throwIfTooSmall: boolean;
indexColumn: boolean;
stringify: (value: unknown) => string;
};
export function createTable<T extends object>(
items: T[],
keys: (keyof T)[],
{
horizontalAlignment = 'middle',
verticalAlignment = 'middle',
fullWidth = false,
columnSizing = 'stretch',
maxWidth = 80,
indexColumn = false,
throwIfTooSmall = true,
stringify = String,
}: Partial<TableOptions> = {},
): string {
let columnCount = keys.length;
const columnNames = keys.map((key) => stringify(key));
const tableContent = items.map((item) => keys.map((key) => stringify(item[key])));
if (indexColumn) {
columnNames.unshift('(index)');
tableContent.forEach((row, i) => row.unshift(i.toString()));
columnCount++;
}
const minimumWidth = 4 * columnCount + 1;
if (maxWidth < minimumWidth) {
const message = `The table does not fit. The width should be set to at least ${
4 * columnCount + 1
} (4 * ColumnCount + 1) for this table to fit (received width ${maxWidth}).`;
if (throwIfTooSmall) {
throw new Error(message);
} else {
return message;
}
}
const availableColumnWidth = maxWidth - columnCount - 1; // each key/column has a │ on the left, and there is one │ at the end of each row
const averageColumnWidth = Math.floor(availableColumnWidth / columnCount);
const columnWidths = columnNames.map(
(columnName, i) =>
Math.max(stringWidth(columnName), ...tableContent.map((row) => stringWidth(row[i]))) + 2,
);
let overflow = columnWidths.reduce((a, b) => a + b, 0) - availableColumnWidth;
switch (columnSizing) {
case 'stretch': {
if (overflow > 0) {
shrinkColumns(columnWidths, averageColumnWidth, overflow);
overflow = 0;
}
break;
}
case 'even': {
const maxColumnWidth = Math.max(...columnWidths);
for (let i = 0; i < columnWidths.length; i++) {
columnWidths[i] = maxColumnWidth;
}
overflow = maxColumnWidth * columnCount - availableColumnWidth;
if (overflow > 0) {
shrinkColumns(columnWidths, averageColumnWidth, overflow);
overflow = 0;
}
break;
}
default:
throw new Error(`Unknown column sizing '${columnSizing}'`);
}
if (fullWidth && overflow < 0) {
growColumns(columnWidths, averageColumnWidth, -overflow);
overflow = 0;
}
for (const columnWidth of columnWidths) {
if (columnWidth < 3) {
throw new Error(
'Table does not fit. Please file a bug report as this error should not happen: https://github.com/timvandam/nice-table/issues/new',
);
}
}
return [
createTableTop(columnWidths),
createTableColumnNames(columnNames, columnWidths, horizontalAlignment, verticalAlignment),
createTableRows(tableContent, columnWidths, horizontalAlignment, verticalAlignment),
createTableBottom(columnWidths),
]
.filter((part) => part.trim().length > 0)
.join('\n');
}
const TOP_LEFT = '┌';
const TOP_RIGHT = '┐';
const TOP_MIDDLE = '┬';
const BOTTOM_LEFT = '└';
const BOTTOM_RIGHT = '┘';
const BOTTOM_MIDDLE = '┴';
const LEFT_MIDDLE = '├';
const RIGHT_MIDDLE = '┤';
const MIDDLE = '┼';
const HORIZONTAL = '─';
const VERTICAL = '│';
function createTableTop(columnWidths: number[]) {
return (
TOP_LEFT + columnWidths.map((size) => HORIZONTAL.repeat(size)).join(TOP_MIDDLE) + TOP_RIGHT
);
}
function createTableMiddleLine(columnWidths: number[]) {
return (
LEFT_MIDDLE + columnWidths.map((size) => HORIZONTAL.repeat(size)).join(MIDDLE) + RIGHT_MIDDLE
);
}
function createTableBottom(columnWidths: number[]) {
return (
BOTTOM_LEFT +
columnWidths.map((size) => HORIZONTAL.repeat(size)).join(BOTTOM_MIDDLE) +
BOTTOM_RIGHT
);
}
function centerText(str: string, width: number) {
const strLength = stringWidth(str);
const leftPadding = Math.floor((width - strLength) / 2);
const rightPadding = width - strLength - leftPadding;
return ' '.repeat(leftPadding) + str + ' '.repeat(rightPadding);
}
function createRow(
cells: string[],
columnWidths: number[],
horizontalAlignment: HorizontalAlignment,
) {
return (
VERTICAL +
columnWidths
.map((columnWidth, i) => {
if (horizontalAlignment === 'left') {
return (' ' + cells[i]).padEnd(columnWidth, ' ');
} else if (horizontalAlignment === 'right') {
return (cells[i] + ' ').padStart(columnWidth, ' ');
} else {
return centerText(cells[i], columnWidth);
}
})
.join(VERTICAL) +
VERTICAL
);
}
function createMultiLineRows(
row: string[],
columnWidths: number[],
horizontalAlignment: HorizontalAlignment,
verticalAlignment: VerticalAlignment,
) {
const cells: string[][] = [];
let rowHeight = 0;
// Split single cells into multiple cells if they are too long
for (let i = 0; i < columnWidths.length; i++) {
const columnWidth = columnWidths[i];
const cellLines = wrapAnsi(row[i], columnWidth - 2, {
hard: true,
trim: true,
wordWrap: true,
}).split('\n');
cells.push(cellLines);
rowHeight = Math.max(rowHeight, cellLines.length);
}
// Align cells vertically
if (verticalAlignment !== 'top') {
for (let i = 0; i < cells.length; i++) {
const padding =
{
top: 0,
middle: Math.floor((rowHeight - cells[i].length) / 2),
bottom: rowHeight - cells[i].length,
}[verticalAlignment] ?? 0;
cells[i].unshift(...Array(padding).fill(''));
}
}
const cellRows: string[][] = [];
for (let i = 0; i < rowHeight; i++) {
cellRows.push(cells.map((cell) => cell[i] ?? ''));
}
return cellRows.map((row) => createRow(row, columnWidths, horizontalAlignment)).join('\n');
}
function createTableRows(
tableContent: string[][],
columnWidths: number[],
horizontalAlignment: HorizontalAlignment,
verticalAlignment: VerticalAlignment,
) {
return tableContent
.map((row) => createMultiLineRows(row, columnWidths, horizontalAlignment, verticalAlignment))
.join('\n');
}
function createTableColumnNames(
columnNames: string[],
columnWidths: number[],
horizontalAlignment: HorizontalAlignment,
verticalAlignment: VerticalAlignment,
) {
return (
createMultiLineRows(columnNames, columnWidths, horizontalAlignment, verticalAlignment) +
'\n' +
createTableMiddleLine(columnWidths)
);
}
/**
* Shrink columns to fit the table width.
* Can lead to negative widths columns if the minimum width is too low (taking $AvailableWidth / ColumnCount$ always works).
*/
function shrinkColumns(columnWidths: number[], minimumWidth: number, overflow: number) {
const bigColumnIndices = Array.from({ length: columnWidths.length }, (_, i) => i).filter(
(i) => columnWidths[i] > minimumWidth,
);
let bigColumnsWidth = bigColumnIndices.reduce(
(totalWidth, columnIndex) => totalWidth + columnWidths[columnIndex],
0,
);
const shrinkFactor = 1 - overflow / (bigColumnsWidth - bigColumnIndices.length * minimumWidth);
for (const columnIndex of bigColumnIndices.slice(0, -1)) {
const currentColumnWidth = columnWidths[columnIndex];
const fixedWidth = Math.max(minimumWidth, Math.floor(shrinkFactor * currentColumnWidth));
const widthReduction = currentColumnWidth - fixedWidth;
columnWidths[columnIndex] = fixedWidth;
overflow -= widthReduction;
bigColumnsWidth -= widthReduction;
}
columnWidths[bigColumnIndices[bigColumnIndices.length - 1]] -= overflow;
}
/**
* Grow small columns by a certain amount.
*/
function growColumns(columnWidths: number[], minimumWidth: number, growth: number) {
const smallColumnIndices = Array.from({ length: columnWidths.length }, (_, i) => i).filter(
(i) => columnWidths[i] < minimumWidth,
);
let smallColumnsWidth = smallColumnIndices.reduce(
(totalWidth, columnIndex) => totalWidth + columnWidths[columnIndex],
0,
);
const growFactor = 1 + growth / smallColumnsWidth;
for (const columnIndex of smallColumnIndices.slice(0, -1)) {
const currentColumnWidth = columnWidths[columnIndex];
const fixedWidth = Math.floor(growFactor * currentColumnWidth);
const widthGrowth = fixedWidth - currentColumnWidth;
columnWidths[columnIndex] = fixedWidth;
growth -= widthGrowth;
smallColumnsWidth += widthGrowth;
}
columnWidths[smallColumnIndices[smallColumnIndices.length - 1]] += growth;
}