├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .README ├── demo.png ├── api │ ├── stream │ │ ├── streaming.gif │ │ ├── streaming-random.gif │ │ └── index.md │ ├── table │ │ ├── columns │ │ │ ├── index.md │ │ │ ├── width.md │ │ │ ├── verticalAlignment.md │ │ │ ├── truncate.md │ │ │ ├── alignment.md │ │ │ ├── padding.md │ │ │ └── wrapWord.md │ │ ├── columnDefault.md │ │ ├── index.md │ │ ├── drawVerticalLine.md │ │ ├── drawHorizontalLine.md │ │ ├── border.md │ │ ├── header.md │ │ ├── singleLine.md │ │ └── spanningCells.md │ └── getBorderCharacters.md ├── install.md ├── usage.md └── README.md ├── nyc.config.js ├── .editorconfig ├── .gitignore ├── .mocharc.js ├── .SANDBOX ├── test-streaming.js └── test-streaming-random.js ├── src ├── calculateOutputColumnWidths.ts ├── index.ts ├── calculateCellHeight.ts ├── stringifyTableData.ts ├── types │ ├── generated │ │ └── validators.d.ts │ ├── internal.ts │ └── api.ts ├── truncateTableData.ts ├── alignTableData.ts ├── schemas │ ├── streamConfig.json │ ├── config.json │ └── shared.json ├── wrapString.ts ├── makeRangeConfig.ts ├── padTableData.ts ├── validateConfig.ts ├── injectHeaderConfig.ts ├── drawRow.ts ├── validateTableData.ts ├── wrapCell.ts ├── drawTable.ts ├── calculateSpanningCellWidth.ts ├── calculateMaximumColumnWidths.ts ├── wrapWord.ts ├── makeStreamConfig.ts ├── table.ts ├── calculateRowHeights.ts ├── alignString.ts ├── validateSpanningCellConfig.ts ├── mapDataUsingRowHeights.ts ├── alignSpanningCell.ts ├── drawContent.ts ├── makeTableConfig.ts ├── getBorderCharacters.ts ├── createStream.ts ├── utils.ts └── spanningCellManager.ts ├── .eslintrc ├── test ├── README │ ├── usage.ts │ ├── api │ │ ├── table │ │ │ ├── column │ │ │ │ ├── width.ts │ │ │ │ ├── verticalAlignment.ts │ │ │ │ ├── truncate.ts │ │ │ │ ├── padding.ts │ │ │ │ ├── alignment.ts │ │ │ │ └── wrapWord.ts │ │ │ ├── drawVerticalLine.ts │ │ │ ├── drawHorizontalLine.ts │ │ │ ├── header.ts │ │ │ ├── border.ts │ │ │ ├── singleLine.ts │ │ │ └── spanningCells.ts │ │ ├── stream │ │ │ └── streaming.ts │ │ └── getBorderCharacters.ts │ └── demo.ts ├── stringifyTableData.ts ├── utils.ts ├── calculateMaximumColumnWidths.ts ├── calculateCellHeight.ts ├── validateConfig.ts ├── validateTableConfig.ts ├── validateStreamConfig.ts ├── validateSpanningCellConfig.ts ├── drawRow.ts ├── wrapString.ts ├── alignTableData.ts ├── calculateRowHeights.ts ├── calculateSpanningCellWidth.ts ├── padTableData.ts ├── truncateTableData.ts ├── validateTableData.ts ├── createStream.ts ├── getBorderCharacters.ts ├── makeStreamConfig.ts ├── mapDataUsingRowHeights.ts ├── spanningCellFixtures.ts ├── makeConfig.ts ├── wrapWord.ts ├── drawBorder.ts ├── drawHeader.ts ├── table.ts ├── tableConfigSamples.ts └── alignString.ts ├── tsconfig.json ├── LICENSE └── package.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: gajus 2 | patreon: gajus 3 | -------------------------------------------------------------------------------- /.README/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gajus/table/HEAD/.README/demo.png -------------------------------------------------------------------------------- /.README/api/stream/streaming.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gajus/table/HEAD/.README/api/stream/streaming.gif -------------------------------------------------------------------------------- /.README/api/stream/streaming-random.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gajus/table/HEAD/.README/api/stream/streaming-random.gif -------------------------------------------------------------------------------- /.README/api/table/columns/index.md: -------------------------------------------------------------------------------- 1 | ##### config.columns 2 | 3 | Type: `Column[] | { [columnIndex: number]: Column }` 4 | 5 | Column specific configurations. 6 | -------------------------------------------------------------------------------- /nyc.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "include": ["src"], 3 | "exclude": ["**/*.js"], 4 | "reporter": [ 5 | "lcov", 6 | "text", 7 | "text-summary" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | src/generated 5 | tsconfig.tsbuildinfo 6 | *.log 7 | .* 8 | !.editorconfig 9 | !.eslintrc 10 | !.gitignore 11 | !.README 12 | !.mocharc.js 13 | !.github 14 | -------------------------------------------------------------------------------- /.mocharc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extension": ["ts"], 3 | "require": "ts-node/register", 4 | "spec": ["./test/**/*.ts"], 5 | "exclude": "src/generated/validators.js", 6 | "forbid-only": true 7 | } 8 | 9 | -------------------------------------------------------------------------------- /.README/api/table/columnDefault.md: -------------------------------------------------------------------------------- 1 | ##### config.columnDefault 2 | 3 | Type: `Column`\ 4 | Default: `{}` 5 | 6 | The default configuration for all columns. Column-specific settings will overwrite the default values. 7 | -------------------------------------------------------------------------------- /.README/api/table/index.md: -------------------------------------------------------------------------------- 1 | ### table 2 | 3 | Returns the string in the table format 4 | 5 | **Parameters:** 6 | - **_data_:** The data to display 7 | - Type: `any[][]` 8 | - Required: `true` 9 | 10 | - **_config_:** Table configuration 11 | - Type: `object` 12 | - Required: `false` 13 | -------------------------------------------------------------------------------- /.SANDBOX/test-streaming.js: -------------------------------------------------------------------------------- 1 | import createStream from './../src/createStream'; 2 | 3 | let stream; 4 | 5 | stream = createStream({ 6 | columnDefault: { 7 | width: 50 8 | }, 9 | columnCount: 1 10 | }); 11 | 12 | setInterval(() => { 13 | stream.write([new Date()]); 14 | }, 500); 15 | -------------------------------------------------------------------------------- /src/calculateOutputColumnWidths.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TableConfig, 3 | } from './types/internal'; 4 | 5 | export const calculateOutputColumnWidths = (config: TableConfig): number[] => { 6 | return config.columns.map((col) => { 7 | return col.paddingLeft + col.width + col.paddingRight; 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /.README/install.md: -------------------------------------------------------------------------------- 1 | ## Install 2 | 3 | ```bash 4 | npm install table 5 | ``` 6 | 7 | [![Buy Me A Coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/gajus) 8 | [![Become a Patron](https://c5.patreon.com/external/logo/become_a_patron_button.png)](https://www.patreon.com/gajus) 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createStream, 3 | } from './createStream'; 4 | import { 5 | getBorderCharacters, 6 | } from './getBorderCharacters'; 7 | import { 8 | table, 9 | } from './table'; 10 | 11 | export { 12 | table, 13 | createStream, 14 | getBorderCharacters, 15 | }; 16 | 17 | export * from './types/api'; 18 | -------------------------------------------------------------------------------- /src/calculateCellHeight.ts: -------------------------------------------------------------------------------- 1 | import { 2 | wrapCell, 3 | } from './wrapCell'; 4 | 5 | /** 6 | * Calculates height of cell content in regard to its width and word wrapping. 7 | */ 8 | export const calculateCellHeight = (value: string, columnWidth: number, useWrapWord = false): number => { 9 | return wrapCell(value, columnWidth, useWrapWord).length; 10 | }; 11 | -------------------------------------------------------------------------------- /src/stringifyTableData.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Row, 3 | } from './types/internal'; 4 | import { 5 | normalizeString, 6 | } from './utils'; 7 | 8 | export const stringifyTableData = (rows: ReadonlyArray): Row[] => { 9 | return rows.map((cells) => { 10 | return cells.map((cell) => { 11 | return normalizeString(String(cell)); 12 | }); 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /src/types/generated/validators.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line filenames/match-regex 2 | import type { 3 | ValidateFunction, 4 | } from 'ajv/dist/types'; 5 | import type { 6 | TableUserConfig, 7 | StreamUserConfig, 8 | } from '../api'; 9 | 10 | declare const validators: { 11 | 'config.json': ValidateFunction, 12 | 'streamConfig.json': ValidateFunction, 13 | }; 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "project": "./tsconfig.json" 5 | }, 6 | "extends": [ 7 | "canonical", 8 | "canonical/typescript" 9 | ], 10 | "ignorePatterns": ["src/generated/**/*.js"], 11 | "rules": { 12 | "max-len": 0, 13 | "jsdoc/check-tag-names": [ 14 | "error", {"definedTags": ["internal"]} 15 | ], 16 | "filenames/match-exported": 0 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.README/usage.md: -------------------------------------------------------------------------------- 1 | ## Usage 2 | 3 | ```js 4 | import { table } from 'table'; 5 | 6 | // Using commonjs? 7 | // const { table } = require('table'); 8 | 9 | const data = [ 10 | ['0A', '0B', '0C'], 11 | ['1A', '1B', '1C'], 12 | ['2A', '2B', '2C'] 13 | ]; 14 | 15 | console.log(table(data)); 16 | ``` 17 | 18 | ``` 19 | ╔════╤════╤════╗ 20 | ║ 0A │ 0B │ 0C ║ 21 | ╟────┼────┼────╢ 22 | ║ 1A │ 1B │ 1C ║ 23 | ╟────┼────┼────╢ 24 | ║ 2A │ 2B │ 2C ║ 25 | ╚════╧════╧════╝ 26 | 27 | ``` 28 | -------------------------------------------------------------------------------- /test/README/usage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | table, 3 | } from '../../src'; 4 | import { 5 | expectTable, 6 | } from '../utils'; 7 | 8 | describe('README.md usage/', () => { 9 | it('basic', () => { 10 | const data = [ 11 | ['0A', '0B', '0C'], 12 | ['1A', '1B', '1C'], 13 | ['2A', '2B', '2C'], 14 | ]; 15 | 16 | const output = table(data); 17 | 18 | expectTable(output, ` 19 | ╔════╤════╤════╗ 20 | ║ 0A │ 0B │ 0C ║ 21 | ╟────┼────┼────╢ 22 | ║ 1A │ 1B │ 1C ║ 23 | ╟────┼────┼────╢ 24 | ║ 2A │ 2B │ 2C ║ 25 | ╚════╧════╧════╝ 26 | `); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/truncateTableData.ts: -------------------------------------------------------------------------------- 1 | import truncate from 'lodash.truncate'; 2 | import type { 3 | Row, 4 | } from './types/internal'; 5 | 6 | export const truncateString = (input: string, length: number): string => { 7 | return truncate(input, {length, 8 | omission: '…'}); 9 | }; 10 | 11 | /** 12 | * @todo Make it work with ASCII content. 13 | */ 14 | export const truncateTableData = (rows: Row[], truncates: number[]): Row[] => { 15 | return rows.map((cells) => { 16 | return cells.map((cell, cellIndex) => { 17 | return truncateString(cell, truncates[cellIndex]); 18 | }); 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /test/stringifyTableData.ts: -------------------------------------------------------------------------------- 1 | import { 2 | expect, 3 | } from 'chai'; 4 | import { 5 | stringifyTableData, 6 | } from '../src/stringifyTableData'; 7 | 8 | describe('stringifyTableData', () => { 9 | it('converts all cell values to strings', () => { 10 | const rows = [[null, undefined, true, false], 11 | [0, -3.141_59, Number.NaN, Number.POSITIVE_INFINITY], 12 | [['a', 'b'], {cd: 1}]]; 13 | 14 | expect(stringifyTableData(rows as never)).to.deep.equal([ 15 | ['null', 'undefined', 'true', 'false'], 16 | ['0', '-3.14159', 'NaN', 'Infinity'], 17 | ['a,b', '[object Object]'], 18 | ]); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | expect, 3 | } from 'chai'; 4 | 5 | export const openRed = '\u001b[31m'; 6 | export const closeRed = '\u001b[39m'; 7 | export const openBold = '\u001b[1m'; 8 | export const closeBold = '\u001b[22m'; 9 | 10 | export const stringToRed = (string: string) => { 11 | return openRed + string + closeRed; 12 | }; 13 | 14 | export const arrayToRed = (array: string[]) => { 15 | return array.map((string) => { 16 | return string === '' ? '' : stringToRed(string); 17 | }); 18 | }; 19 | 20 | export const expectTable = (result: string, expectedResult: string): void => { 21 | expect(result).to.equal(expectedResult.trim() + '\n'); 22 | }; 23 | -------------------------------------------------------------------------------- /.README/api/table/columns/width.md: -------------------------------------------------------------------------------- 1 | ###### config.columns[*].width 2 | 3 | Type: `number`\ 4 | Default: the maximum cell widths of the column 5 | 6 | Column width (excluding the paddings). 7 | 8 | ```js 9 | 10 | const data = [ 11 | ['0A', '0B', '0C'], 12 | ['1A', '1B', '1C'], 13 | ['2A', '2B', '2C'] 14 | ]; 15 | 16 | const config = { 17 | columns: { 18 | 1: { width: 10 } 19 | } 20 | }; 21 | 22 | console.log(table(data, config)); 23 | ``` 24 | 25 | ``` 26 | ╔════╤════════════╤════╗ 27 | ║ 0A │ 0B │ 0C ║ 28 | ╟────┼────────────┼────╢ 29 | ║ 1A │ 1B │ 1C ║ 30 | ╟────┼────────────┼────╢ 31 | ║ 2A │ 2B │ 2C ║ 32 | ╚════╧════════════╧════╝ 33 | ``` 34 | -------------------------------------------------------------------------------- /.README/api/table/columns/verticalAlignment.md: -------------------------------------------------------------------------------- 1 | ###### config.columns[*].verticalAlignment 2 | 3 | Type: `'top' | 'middle' | 'bottom'`\ 4 | Default: `'top'` 5 | 6 | Cell content vertical alignment 7 | 8 | ```js 9 | const data = [ 10 | ['A', 'B', 'C', 'DEF'], 11 | ]; 12 | 13 | const config = { 14 | columnDefault: { 15 | width: 1, 16 | }, 17 | columns: [ 18 | { verticalAlignment: 'top' }, 19 | { verticalAlignment: 'middle' }, 20 | { verticalAlignment: 'bottom' }, 21 | ], 22 | }; 23 | 24 | console.log(table(data, config)); 25 | ``` 26 | 27 | ``` 28 | ╔═══╤═══╤═══╤═══╗ 29 | ║ A │ │ │ D ║ 30 | ║ │ B │ │ E ║ 31 | ║ │ │ C │ F ║ 32 | ╚═══╧═══╧═══╧═══╝ 33 | ``` 34 | -------------------------------------------------------------------------------- /src/alignTableData.ts: -------------------------------------------------------------------------------- 1 | import { 2 | alignString, 3 | } from './alignString'; 4 | import type { 5 | BaseConfig, 6 | Row, 7 | } from './types/internal'; 8 | 9 | export const alignTableData = (rows: Row[], config: BaseConfig): Row[] => { 10 | return rows.map((row, rowIndex) => { 11 | return row.map((cell, cellIndex) => { 12 | const {width, alignment} = config.columns[cellIndex]; 13 | 14 | const containingRange = config.spanningCellManager?.getContainingRange({col: cellIndex, 15 | row: rowIndex}, {mapped: true}); 16 | if (containingRange) { 17 | return cell; 18 | } 19 | 20 | return alignString(cell, width, alignment); 21 | }); 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /src/schemas/streamConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "streamConfig.json", 3 | "$schema": "http://json-schema.org/draft-07/schema#", 4 | "type": "object", 5 | "properties": { 6 | "border": { 7 | "$ref": "shared.json#/definitions/borders" 8 | }, 9 | "columns": { 10 | "$ref": "shared.json#/definitions/columns" 11 | }, 12 | "columnDefault": { 13 | "$ref": "shared.json#/definitions/column" 14 | }, 15 | "columnCount": { 16 | "type": "integer", 17 | "minimum": 1 18 | }, 19 | "drawVerticalLine": { 20 | "typeof": "function" 21 | } 22 | }, 23 | "required": ["columnDefault", "columnCount"], 24 | "additionalProperties": false 25 | } 26 | -------------------------------------------------------------------------------- /src/wrapString.ts: -------------------------------------------------------------------------------- 1 | import slice from 'slice-ansi'; 2 | import stringWidth from 'string-width'; 3 | 4 | /** 5 | * Creates an array of strings split into groups the length of size. 6 | * This function works with strings that contain ASCII characters. 7 | * 8 | * wrapText is different from would-be "chunk" implementation 9 | * in that whitespace characters that occur on a chunk size limit are trimmed. 10 | * 11 | */ 12 | export const wrapString = (subject: string, size: number): string[] => { 13 | let subjectSlice = subject; 14 | 15 | const chunks: string[] = []; 16 | 17 | do { 18 | chunks.push(slice(subjectSlice, 0, size)); 19 | 20 | subjectSlice = slice(subjectSlice, size).trim(); 21 | } while (stringWidth(subjectSlice)); 22 | 23 | return chunks; 24 | }; 25 | -------------------------------------------------------------------------------- /.SANDBOX/test-streaming-random.js: -------------------------------------------------------------------------------- 1 | import createStream from './../src/createStream'; 2 | import _ from 'lodash'; 3 | 4 | let config, 5 | stream, 6 | i; 7 | 8 | config = { 9 | columnDefault: { 10 | width: 50 11 | }, 12 | columnCount: 3, 13 | columns: { 14 | 0: { 15 | width: 10, 16 | alignment: 'right' 17 | }, 18 | 1: { 19 | alignment: 'center', 20 | }, 21 | 2: { 22 | width: 10 23 | } 24 | } 25 | }; 26 | 27 | stream = createStream(config); 28 | 29 | i = 0; 30 | 31 | setInterval(() => { 32 | let random; 33 | 34 | random = _.sample('abcdefghijklmnopqrstuvwxyz', _.random(1, 30)).join(''); 35 | 36 | stream.write([i++, new Date(), random]); 37 | }, 500); -------------------------------------------------------------------------------- /test/README/api/table/column/width.ts: -------------------------------------------------------------------------------- 1 | import { 2 | table, 3 | } from '../../../../../src'; 4 | import { 5 | expectTable, 6 | } from '../../../../utils'; 7 | 8 | describe('README.md api/table/columns/', () => { 9 | it('width', () => { 10 | const data = [ 11 | ['0A', '0B', '0C'], 12 | ['1A', '1B', '1C'], 13 | ['2A', '2B', '2C'], 14 | ]; 15 | 16 | const config = { 17 | columns: { 18 | 1: { 19 | width: 10, 20 | }, 21 | }, 22 | }; 23 | 24 | expectTable(table(data, config), ` 25 | ╔════╤════════════╤════╗ 26 | ║ 0A │ 0B │ 0C ║ 27 | ╟────┼────────────┼────╢ 28 | ║ 1A │ 1B │ 1C ║ 29 | ╟────┼────────────┼────╢ 30 | ║ 2A │ 2B │ 2C ║ 31 | ╚════╧════════════╧════╝ 32 | `); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/makeRangeConfig.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CellUserConfig, SpanningCellConfig, 3 | } from './types/api'; 4 | import type { 5 | ColumnConfig, RangeConfig, 6 | } from './types/internal'; 7 | import { 8 | calculateRangeCoordinate, 9 | } from './utils'; 10 | 11 | export const makeRangeConfig = (spanningCellConfig: SpanningCellConfig, columnsConfig: ColumnConfig[]): RangeConfig => { 12 | const {topLeft, bottomRight} = calculateRangeCoordinate(spanningCellConfig); 13 | 14 | const cellConfig: Required = { 15 | ...columnsConfig[topLeft.col], 16 | ...spanningCellConfig, 17 | paddingRight: 18 | spanningCellConfig.paddingRight ?? 19 | columnsConfig[bottomRight.col].paddingRight, 20 | }; 21 | 22 | return {...cellConfig, 23 | bottomRight, 24 | topLeft}; 25 | }; 26 | -------------------------------------------------------------------------------- /src/padTableData.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BaseConfig, 3 | Row, 4 | } from './types/internal'; 5 | 6 | export const padString = (input: string, paddingLeft: number, paddingRight: number): string => { 7 | return ' '.repeat(paddingLeft) + input + ' '.repeat(paddingRight); 8 | }; 9 | 10 | export const padTableData = (rows: Row[], config: BaseConfig): Row[] => { 11 | return rows.map((cells, rowIndex) => { 12 | return cells.map((cell, cellIndex) => { 13 | const containingRange = config.spanningCellManager?.getContainingRange({col: cellIndex, 14 | row: rowIndex}, {mapped: true}); 15 | if (containingRange) { 16 | return cell; 17 | } 18 | 19 | const {paddingLeft, paddingRight} = config.columns[cellIndex]; 20 | 21 | return padString(cell, paddingLeft, paddingRight); 22 | }); 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /test/README/api/table/column/verticalAlignment.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TableUserConfig, 3 | } from '../../../../../src'; 4 | import { 5 | table, 6 | } from '../../../../../src'; 7 | import { 8 | expectTable, 9 | } from '../../../../utils'; 10 | 11 | describe('README.md api/table/columns', () => { 12 | it('/verticalAlignment', () => { 13 | const data = [ 14 | ['A', 'B', 'C', 'DEF'], 15 | ]; 16 | 17 | const config: TableUserConfig = { 18 | columnDefault: { 19 | width: 1, 20 | }, 21 | columns: [ 22 | {verticalAlignment: 'top'}, 23 | {verticalAlignment: 'middle'}, 24 | {verticalAlignment: 'bottom'}, 25 | ], 26 | }; 27 | 28 | expectTable(table(data, config), ` 29 | ╔═══╤═══╤═══╤═══╗ 30 | ║ A │ │ │ D ║ 31 | ║ │ B │ │ E ║ 32 | ║ │ │ C │ F ║ 33 | ╚═══╧═══╧═══╧═══╝`); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/README/api/table/column/truncate.ts: -------------------------------------------------------------------------------- 1 | import { 2 | table, 3 | } from '../../../../../src'; 4 | import { 5 | expectTable, 6 | } from '../../../../utils'; 7 | 8 | describe('README.md api/table/column/', () => { 9 | it('/truncate', () => { 10 | const data = [ 11 | ['Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus pulvinar nibh sed mauris convallis dapibus. Nunc venenatis tempus nulla sit amet viverra.'], 12 | ]; 13 | 14 | const config = { 15 | columns: [ 16 | { 17 | truncate: 100, 18 | width: 20, 19 | }, 20 | ], 21 | }; 22 | 23 | expectTable(table(data, config), ` 24 | ╔══════════════════════╗ 25 | ║ Lorem ipsum dolor si ║ 26 | ║ t amet, consectetur ║ 27 | ║ adipiscing elit. Pha ║ 28 | ║ sellus pulvinar nibh ║ 29 | ║ sed mauris convall… ║ 30 | ╚══════════════════════╝ 31 | `); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src", "test" 4 | ], 5 | "ts-node": { 6 | "sourceMap": true, 7 | "transpileOnly": true 8 | }, 9 | "compilerOptions": { 10 | "resolveJsonModule": true, 11 | "incremental": true, 12 | "target": "ES2018", 13 | "module": "commonjs", 14 | "lib": ["ES2018"], 15 | "allowJs": true, 16 | "declaration": true, 17 | "sourceMap": true, 18 | "outDir": "dist", 19 | "isolatedModules": true, 20 | "strict": true, 21 | "alwaysStrict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noImplicitReturns": true, 25 | "noFallthroughCasesInSwitch": true, 26 | "moduleResolution": "node", 27 | "typeRoots": ["src/types/external", "node_modules/@types"], 28 | "esModuleInterop": true, 29 | "stripInternal": true, 30 | "forceConsistentCasingInFileNames": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/README/api/table/column/padding.ts: -------------------------------------------------------------------------------- 1 | import { 2 | table, 3 | } from '../../../../../src'; 4 | import { 5 | expectTable, 6 | } from '../../../../utils'; 7 | 8 | describe('README.md api/table/columns/', () => { 9 | it('/padding', () => { 10 | const data = [ 11 | ['0A', 'AABBCC', '0C'], 12 | ['1A', '1B', '1C'], 13 | ['2A', '2B', '2C'], 14 | ]; 15 | 16 | const config = { 17 | columns: [ 18 | { 19 | paddingLeft: 3, 20 | }, 21 | { 22 | paddingRight: 3, 23 | width: 2, 24 | }, 25 | ], 26 | }; 27 | 28 | expectTable(table(data, config), ` 29 | ╔══════╤══════╤════╗ 30 | ║ 0A │ AA │ 0C ║ 31 | ║ │ BB │ ║ 32 | ║ │ CC │ ║ 33 | ╟──────┼──────┼────╢ 34 | ║ 1A │ 1B │ 1C ║ 35 | ╟──────┼──────┼────╢ 36 | ║ 2A │ 2B │ 2C ║ 37 | ╚══════╧══════╧════╝ 38 | `); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/validateConfig.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ErrorObject, 3 | ValidateFunction, 4 | } from 'ajv/dist/types'; 5 | import validators from './generated/validators'; 6 | import type { 7 | TableUserConfig, 8 | } from './types/api'; 9 | 10 | export const validateConfig = (schemaId: 'config.json' | 'streamConfig.json', config: TableUserConfig): void => { 11 | const validate = validators[schemaId] as ValidateFunction; 12 | if (!validate(config) && validate.errors) { 13 | // eslint-disable-next-line promise/prefer-await-to-callbacks 14 | const errors = validate.errors.map((error: ErrorObject) => { 15 | return { 16 | message: error.message, 17 | params: error.params, 18 | schemaPath: error.schemaPath, 19 | }; 20 | }); 21 | 22 | /* eslint-disable no-console */ 23 | console.log('config', config); 24 | console.log('errors', errors); 25 | /* eslint-enable no-console */ 26 | 27 | throw new Error('Invalid config.'); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /test/calculateMaximumColumnWidths.ts: -------------------------------------------------------------------------------- 1 | import { 2 | expect, 3 | } from 'chai'; 4 | import { 5 | calculateMaximumColumnWidths, 6 | } from '../src/calculateMaximumColumnWidths'; 7 | 8 | describe('calculateMaximumColumnWidths', () => { 9 | context('all cells have different width', () => { 10 | it('describes each cell contents width', () => { 11 | const cellWidths = calculateMaximumColumnWidths([[ 12 | 'a', 13 | 'aaa', 14 | 'aaaaaa', 15 | ]]); 16 | 17 | expect(cellWidths[0]).to.equal(1, 'first column'); 18 | expect(cellWidths[1]).to.equal(3, 'second column'); 19 | expect(cellWidths[2]).to.equal(6, 'third column'); 20 | }); 21 | }); 22 | context('cell contains newline characters', () => { 23 | it('picks the longest line length', () => { 24 | const cellWidths = calculateMaximumColumnWidths([[ 25 | 'aaaa\naa', 26 | ]]); 27 | 28 | expect(cellWidths[0]).to.equal(4); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/calculateCellHeight.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-nested-callbacks */ 2 | 3 | import { 4 | expect, 5 | } from 'chai'; 6 | import { 7 | calculateCellHeight, 8 | } from '../src/calculateCellHeight'; 9 | 10 | describe('calculateCellHeight', () => { 11 | describe('value', () => { 12 | it('contains newlines', () => { 13 | expect(calculateCellHeight('a\nb\nc', 10)).to.equal(3); 14 | }); 15 | it('contains newlines and will be wrapped', () => { 16 | expect(calculateCellHeight('aa\nbbb\nc', 2)).to.equal(4); 17 | }); 18 | }); 19 | describe('context width', () => { 20 | context('is lesser than the column width', () => { 21 | it('has height 1', () => { 22 | expect(calculateCellHeight('foo', 10)).to.equal(1); 23 | }); 24 | }); 25 | context('is 2 and half times greater than the column width', () => { 26 | it('has height 3', () => { 27 | expect(calculateCellHeight('aabbc', 2)).to.equal(3); 28 | }); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/README/api/table/drawVerticalLine.ts: -------------------------------------------------------------------------------- 1 | import { 2 | table, 3 | } from '../../../../src'; 4 | import type { 5 | TableUserConfig, 6 | } from '../../../../src'; 7 | import { 8 | expectTable, 9 | } from '../../../utils'; 10 | 11 | describe('README.md api/table', () => { 12 | it('drawVerticalLine', () => { 13 | const data = [ 14 | ['0A', '0B', '0C'], 15 | ['1A', '1B', '1C'], 16 | ['2A', '2B', '2C'], 17 | ['3A', '3B', '3C'], 18 | ['4A', '4B', '4C'], 19 | ]; 20 | 21 | const options: TableUserConfig = { 22 | drawVerticalLine: (index, size) => { 23 | return index === 0 || index === size; 24 | }, 25 | }; 26 | 27 | const output = table(data, options); 28 | 29 | expectTable(output, ` 30 | ╔════════════╗ 31 | ║ 0A 0B 0C ║ 32 | ╟────────────╢ 33 | ║ 1A 1B 1C ║ 34 | ╟────────────╢ 35 | ║ 2A 2B 2C ║ 36 | ╟────────────╢ 37 | ║ 3A 3B 3C ║ 38 | ╟────────────╢ 39 | ║ 4A 4B 4C ║ 40 | ╚════════════╝ 41 | `); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/README/api/table/drawHorizontalLine.ts: -------------------------------------------------------------------------------- 1 | import { 2 | table, 3 | } from '../../../../src'; 4 | import type { 5 | TableUserConfig, 6 | } from '../../../../src'; 7 | import { 8 | expectTable, 9 | } from '../../../utils'; 10 | 11 | describe('README.md api/table', () => { 12 | it('drawHorizontalLine', () => { 13 | const data = [ 14 | ['0A', '0B', '0C'], 15 | ['1A', '1B', '1C'], 16 | ['2A', '2B', '2C'], 17 | ['3A', '3B', '3C'], 18 | ['4A', '4B', '4C'], 19 | ]; 20 | 21 | const options: TableUserConfig = { 22 | drawHorizontalLine: (index, size) => { 23 | return index === 0 || index === 1 || index === size - 1 || index === size; 24 | }, 25 | }; 26 | 27 | const output = table(data, options); 28 | 29 | expectTable(output, ` 30 | ╔════╤════╤════╗ 31 | ║ 0A │ 0B │ 0C ║ 32 | ╟────┼────┼────╢ 33 | ║ 1A │ 1B │ 1C ║ 34 | ║ 2A │ 2B │ 2C ║ 35 | ║ 3A │ 3B │ 3C ║ 36 | ╟────┼────┼────╢ 37 | ║ 4A │ 4B │ 4C ║ 38 | ╚════╧════╧════╝ 39 | `); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /.README/api/table/columns/truncate.md: -------------------------------------------------------------------------------- 1 | ###### config.columns[*].truncate 2 | 3 | Type: `number`\ 4 | Default: `Infinity` 5 | 6 | The number of characters is which the content will be truncated. 7 | To handle a content that overflows the container width, `table` package implements [text wrapping](#config.columns[*].wrapWord). However, sometimes you may want to truncate content that is too long to be displayed in the table. 8 | 9 | ```js 10 | const data = [ 11 | ['Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus pulvinar nibh sed mauris convallis dapibus. Nunc venenatis tempus nulla sit amet viverra.'] 12 | ]; 13 | 14 | const config = { 15 | columns: [ 16 | { 17 | width: 20, 18 | truncate: 100 19 | } 20 | ] 21 | }; 22 | 23 | console.log(table(data, config)); 24 | ``` 25 | 26 | ``` 27 | ╔══════════════════════╗ 28 | ║ Lorem ipsum dolor si ║ 29 | ║ t amet, consectetur ║ 30 | ║ adipiscing elit. Pha ║ 31 | ║ sellus pulvinar nibh ║ 32 | ║ sed mauris convall… ║ 33 | ╚══════════════════════╝ 34 | ``` 35 | -------------------------------------------------------------------------------- /.README/api/table/drawVerticalLine.md: -------------------------------------------------------------------------------- 1 | ##### config.drawVerticalLine 2 | 3 | Type: `(lineIndex: number, columnCount: number) => boolean`\ 4 | Default: `() => true` 5 | 6 | It is used to tell whether to draw a vertical line. This callback is called for each vertical border of the table. 7 | If the table has `n` columns, then the `index` parameter is alternatively received all numbers in range `[0, n]` inclusively. 8 | 9 | ```js 10 | const data = [ 11 | ['0A', '0B', '0C'], 12 | ['1A', '1B', '1C'], 13 | ['2A', '2B', '2C'], 14 | ['3A', '3B', '3C'], 15 | ['4A', '4B', '4C'] 16 | ]; 17 | 18 | const config = { 19 | drawVerticalLine: (lineIndex, columnCount) => { 20 | return lineIndex === 0 || lineIndex === columnCount; 21 | } 22 | }; 23 | 24 | console.log(table(data, config)); 25 | 26 | ``` 27 | 28 | ``` 29 | ╔════════════╗ 30 | ║ 0A 0B 0C ║ 31 | ╟────────────╢ 32 | ║ 1A 1B 1C ║ 33 | ╟────────────╢ 34 | ║ 2A 2B 2C ║ 35 | ╟────────────╢ 36 | ║ 3A 3B 3C ║ 37 | ╟────────────╢ 38 | ║ 4A 4B 4C ║ 39 | ╚════════════╝ 40 | 41 | ``` 42 | -------------------------------------------------------------------------------- /.README/api/table/columns/alignment.md: -------------------------------------------------------------------------------- 1 | ###### config.columns[*].alignment 2 | 3 | Type: `'center' | 'justify' | 'left' | 'right'`\ 4 | Default: `'left'` 5 | 6 | Cell content horizontal alignment 7 | 8 | ```js 9 | const data = [ 10 | ['0A', '0B', '0C', '0D 0E 0F'], 11 | ['1A', '1B', '1C', '1D 1E 1F'], 12 | ['2A', '2B', '2C', '2D 2E 2F'], 13 | ]; 14 | 15 | const config = { 16 | columnDefault: { 17 | width: 10, 18 | }, 19 | columns: [ 20 | { alignment: 'left' }, 21 | { alignment: 'center' }, 22 | { alignment: 'right' }, 23 | { alignment: 'justify' } 24 | ], 25 | }; 26 | 27 | console.log(table(data, config)); 28 | ``` 29 | 30 | ``` 31 | ╔════════════╤════════════╤════════════╤════════════╗ 32 | ║ 0A │ 0B │ 0C │ 0D 0E 0F ║ 33 | ╟────────────┼────────────┼────────────┼────────────╢ 34 | ║ 1A │ 1B │ 1C │ 1D 1E 1F ║ 35 | ╟────────────┼────────────┼────────────┼────────────╢ 36 | ║ 2A │ 2B │ 2C │ 2D 2E 2F ║ 37 | ╚════════════╧════════════╧════════════╧════════════╝ 38 | ``` 39 | -------------------------------------------------------------------------------- /src/injectHeaderConfig.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SpanningCellConfig, 3 | TableUserConfig, 4 | } from './types/api'; 5 | import type { 6 | Row, 7 | } from './types/internal'; 8 | 9 | export const injectHeaderConfig = (rows: Row[], config: TableUserConfig): [Row[], SpanningCellConfig[]] => { 10 | let spanningCellConfig = config.spanningCells ?? []; 11 | const headerConfig = config.header; 12 | const adjustedRows = [...rows]; 13 | 14 | if (headerConfig) { 15 | spanningCellConfig = spanningCellConfig.map(({row, ...rest}) => { 16 | return {...rest, 17 | row: row + 1}; 18 | }); 19 | 20 | const {content, ...headerStyles} = headerConfig; 21 | 22 | spanningCellConfig.unshift({alignment: 'center', 23 | col: 0, 24 | colSpan: rows[0].length, 25 | paddingLeft: 1, 26 | paddingRight: 1, 27 | row: 0, 28 | wrapWord: false, 29 | ...headerStyles}); 30 | 31 | adjustedRows.unshift([content, ...Array.from({length: rows[0].length - 1}).fill('')]); 32 | } 33 | 34 | return [adjustedRows, 35 | spanningCellConfig]; 36 | }; 37 | -------------------------------------------------------------------------------- /test/validateConfig.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-nested-callbacks */ 2 | 3 | import { 4 | expect, 5 | } from 'chai'; 6 | import { 7 | validateConfig, 8 | } from '../src/validateConfig'; 9 | 10 | describe('validateConfig', () => { 11 | context('given invalid config', () => { 12 | it('throws an error', () => { 13 | expect(() => { 14 | validateConfig('config.json', {x: 1} as never); 15 | }).to.be.throw( 16 | Error, 17 | 'Invalid config.', 18 | ); 19 | }); 20 | }); 21 | 22 | context('given valid config', () => { 23 | it('does not throw an error', () => { 24 | expect(() => { 25 | validateConfig('config.json', { 26 | columnDefault: { 27 | width: 50, 28 | }, 29 | columns: { 30 | 0: { 31 | alignment: 'left', 32 | width: 10, 33 | }, 34 | }, 35 | drawHorizontalLine: () => { 36 | return false; 37 | }, 38 | singleLine: true, 39 | }); 40 | }).to.be.not.throw(); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/drawRow.ts: -------------------------------------------------------------------------------- 1 | import { 2 | drawContent, 3 | } from './drawContent'; 4 | import type { 5 | SpanningCellManager, 6 | } from './spanningCellManager'; 7 | import type { 8 | DrawVerticalLine, 9 | } from './types/api'; 10 | import type { 11 | BodyBorderConfig, 12 | Row, 13 | } from './types/internal'; 14 | 15 | export type DrawRowConfig = { 16 | border: BodyBorderConfig, 17 | drawVerticalLine: DrawVerticalLine, 18 | spanningCellManager?: SpanningCellManager, 19 | rowIndex?: number, 20 | }; 21 | 22 | export const drawRow = (row: Row, config: DrawRowConfig): string => { 23 | const {border, drawVerticalLine, rowIndex, spanningCellManager} = config; 24 | 25 | return drawContent({ 26 | contents: row, 27 | drawSeparator: drawVerticalLine, 28 | elementType: 'cell', 29 | rowIndex, 30 | separatorGetter: (index, columnCount) => { 31 | if (index === 0) { 32 | return border.bodyLeft; 33 | } 34 | 35 | if (index === columnCount) { 36 | return border.bodyRight; 37 | } 38 | 39 | return border.bodyJoin; 40 | }, 41 | spanningCellManager, 42 | }) + '\n'; 43 | }; 44 | -------------------------------------------------------------------------------- /src/validateTableData.ts: -------------------------------------------------------------------------------- 1 | import { 2 | normalizeString, 3 | } from './utils'; 4 | 5 | export const validateTableData = (rows: ReadonlyArray): void => { 6 | if (!Array.isArray(rows)) { 7 | throw new TypeError('Table data must be an array.'); 8 | } 9 | 10 | if (rows.length === 0) { 11 | throw new Error('Table must define at least one row.'); 12 | } 13 | 14 | if (rows[0].length === 0) { 15 | throw new Error('Table must define at least one column.'); 16 | } 17 | 18 | const columnNumber = rows[0].length; 19 | 20 | for (const row of rows) { 21 | if (!Array.isArray(row)) { 22 | throw new TypeError('Table row data must be an array.'); 23 | } 24 | 25 | if (row.length !== columnNumber) { 26 | throw new Error('Table must have a consistent number of cells.'); 27 | } 28 | 29 | for (const cell of row) { 30 | // eslint-disable-next-line no-control-regex 31 | if (/[\u0001-\u0006\u0008\u0009\u000B-\u001A]/.test(normalizeString(String(cell)))) { 32 | throw new Error('Table data must not contain control characters.'); 33 | } 34 | } 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /.README/api/table/columns/padding.md: -------------------------------------------------------------------------------- 1 | ###### config.columns[*].paddingLeft 2 | 3 | Type: `number`\ 4 | Default: `1` 5 | 6 | The number of whitespaces used to pad the content on the left. 7 | 8 | ###### config.columns[*].paddingRight 9 | 10 | Type: `number`\ 11 | Default: `1` 12 | 13 | The number of whitespaces used to pad the content on the right. 14 | 15 | The `paddingLeft` and `paddingRight` options do not count on the column width. So the column has `width = 5`, `paddingLeft = 2` and `paddingRight = 2` will have the total width is `9`. 16 | 17 | 18 | ```js 19 | const data = [ 20 | ['0A', 'AABBCC', '0C'], 21 | ['1A', '1B', '1C'], 22 | ['2A', '2B', '2C'] 23 | ]; 24 | 25 | const config = { 26 | columns: [ 27 | { 28 | paddingLeft: 3 29 | }, 30 | { 31 | width: 2, 32 | paddingRight: 3 33 | } 34 | ] 35 | }; 36 | 37 | console.log(table(data, config)); 38 | ``` 39 | 40 | ``` 41 | ╔══════╤══════╤════╗ 42 | ║ 0A │ AA │ 0C ║ 43 | ║ │ BB │ ║ 44 | ║ │ CC │ ║ 45 | ╟──────┼──────┼────╢ 46 | ║ 1A │ 1B │ 1C ║ 47 | ╟──────┼──────┼────╢ 48 | ║ 2A │ 2B │ 2C ║ 49 | ╚══════╧══════╧════╝ 50 | ``` 51 | -------------------------------------------------------------------------------- /src/wrapCell.ts: -------------------------------------------------------------------------------- 1 | import { 2 | splitAnsi, 3 | } from './utils'; 4 | import { 5 | wrapString, 6 | } from './wrapString'; 7 | import { 8 | wrapWord, 9 | } from './wrapWord'; 10 | 11 | /** 12 | * Wrap a single cell value into a list of lines 13 | * 14 | * Always wraps on newlines, for the remainder uses either word or string wrapping 15 | * depending on user configuration. 16 | * 17 | */ 18 | export const wrapCell = (cellValue: string, cellWidth: number, useWrapWord: boolean): string[] => { 19 | // First split on literal newlines 20 | const cellLines = splitAnsi(cellValue); 21 | 22 | // Then iterate over the list and word-wrap every remaining line if necessary. 23 | for (let lineNr = 0; lineNr < cellLines.length;) { 24 | let lineChunks; 25 | 26 | if (useWrapWord) { 27 | lineChunks = wrapWord(cellLines[lineNr], cellWidth); 28 | } else { 29 | lineChunks = wrapString(cellLines[lineNr], cellWidth); 30 | } 31 | 32 | // Replace our original array element with whatever the wrapping returned 33 | cellLines.splice(lineNr, 1, ...lineChunks); 34 | lineNr += lineChunks.length; 35 | } 36 | 37 | return cellLines; 38 | }; 39 | -------------------------------------------------------------------------------- /.README/api/table/drawHorizontalLine.md: -------------------------------------------------------------------------------- 1 | ##### config.drawHorizontalLine 2 | 3 | Type: `(lineIndex: number, rowCount: number) => boolean`\ 4 | Default: `() => true` 5 | 6 | It is used to tell whether to draw a horizontal line. This callback is called for each horizontal border of the table. 7 | If the table has `n` rows, then the `index` parameter is alternatively received all numbers in range `[0, n]` inclusively. 8 | If the table has `n` rows and contains the header, then the range will be `[0, n+1]` inclusively. 9 | 10 | ```js 11 | const data = [ 12 | ['0A', '0B', '0C'], 13 | ['1A', '1B', '1C'], 14 | ['2A', '2B', '2C'], 15 | ['3A', '3B', '3C'], 16 | ['4A', '4B', '4C'] 17 | ]; 18 | 19 | const config = { 20 | drawHorizontalLine: (lineIndex, rowCount) => { 21 | return lineIndex === 0 || lineIndex === 1 || lineIndex === rowCount - 1 || lineIndex === rowCount; 22 | } 23 | }; 24 | 25 | console.log(table(data, config)); 26 | 27 | ``` 28 | 29 | ``` 30 | ╔════╤════╤════╗ 31 | ║ 0A │ 0B │ 0C ║ 32 | ╟────┼────┼────╢ 33 | ║ 1A │ 1B │ 1C ║ 34 | ║ 2A │ 2B │ 2C ║ 35 | ║ 3A │ 3B │ 3C ║ 36 | ╟────┼────┼────╢ 37 | ║ 4A │ 4B │ 4C ║ 38 | ╚════╧════╧════╝ 39 | 40 | ``` 41 | -------------------------------------------------------------------------------- /.README/api/table/border.md: -------------------------------------------------------------------------------- 1 | ##### config.border 2 | 3 | Type: `{ [type: string]: string }`\ 4 | Default: `honeywell` [template](#getbordercharacters) 5 | 6 | Custom borders. The keys are any of: 7 | - `topLeft`, `topRight`, `topBody`,`topJoin` 8 | - `bottomLeft`, `bottomRight`, `bottomBody`, `bottomJoin` 9 | - `joinLeft`, `joinRight`, `joinBody`, `joinJoin` 10 | - `bodyLeft`, `bodyRight`, `bodyJoin` 11 | - `headerJoin` 12 | 13 | ```js 14 | const data = [ 15 | ['0A', '0B', '0C'], 16 | ['1A', '1B', '1C'], 17 | ['2A', '2B', '2C'] 18 | ]; 19 | 20 | const config = { 21 | border: { 22 | topBody: `─`, 23 | topJoin: `┬`, 24 | topLeft: `┌`, 25 | topRight: `┐`, 26 | 27 | bottomBody: `─`, 28 | bottomJoin: `┴`, 29 | bottomLeft: `└`, 30 | bottomRight: `┘`, 31 | 32 | bodyLeft: `│`, 33 | bodyRight: `│`, 34 | bodyJoin: `│`, 35 | 36 | joinBody: `─`, 37 | joinLeft: `├`, 38 | joinRight: `┤`, 39 | joinJoin: `┼` 40 | } 41 | }; 42 | 43 | console.log(table(data, config)); 44 | ``` 45 | 46 | ``` 47 | ┌────┬────┬────┐ 48 | │ 0A │ 0B │ 0C │ 49 | ├────┼────┼────┤ 50 | │ 1A │ 1B │ 1C │ 51 | ├────┼────┼────┤ 52 | │ 2A │ 2B │ 2C │ 53 | └────┴────┴────┘ 54 | ``` 55 | -------------------------------------------------------------------------------- /test/README/api/table/header.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TableUserConfig, 3 | } from '../../../../src'; 4 | import { 5 | table, 6 | } from '../../../../src'; 7 | import { 8 | expectTable, 9 | } from '../../../utils'; 10 | 11 | describe('README.md api/table/', () => { 12 | it('/header', () => { 13 | const data = [ 14 | ['0A', '0B', '0C'], 15 | ['1A', '1B', '1C'], 16 | ['2A', '2B', '2C'], 17 | ]; 18 | 19 | const config: TableUserConfig = { 20 | columnDefault: { 21 | width: 10, 22 | }, 23 | header: { 24 | alignment: 'center', 25 | content: 'THE HEADER\nThis is the table about something', 26 | }, 27 | }; 28 | 29 | expectTable(table(data, config), ` 30 | ╔══════════════════════════════════════╗ 31 | ║ THE HEADER ║ 32 | ║ This is the table about something ║ 33 | ╟────────────┬────────────┬────────────╢ 34 | ║ 0A │ 0B │ 0C ║ 35 | ╟────────────┼────────────┼────────────╢ 36 | ║ 1A │ 1B │ 1C ║ 37 | ╟────────────┼────────────┼────────────╢ 38 | ║ 2A │ 2B │ 2C ║ 39 | ╚════════════╧════════════╧════════════╝ 40 | `); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /test/README/api/table/border.ts: -------------------------------------------------------------------------------- 1 | import { 2 | table, 3 | } from '../../../../src'; 4 | import { 5 | expectTable, 6 | } from '../../../utils'; 7 | 8 | describe('README.md api/table', () => { 9 | it('border', () => { 10 | const data = [ 11 | ['0A', '0B', '0C'], 12 | ['1A', '1B', '1C'], 13 | ['2A', '2B', '2C'], 14 | ]; 15 | 16 | /* eslint-disable sort-keys-fix/sort-keys-fix */ 17 | const config = { 18 | border: { 19 | topBody: '─', 20 | topJoin: '┬', 21 | topLeft: '┌', 22 | topRight: '┐', 23 | 24 | bottomBody: '─', 25 | bottomJoin: '┴', 26 | bottomLeft: '└', 27 | bottomRight: '┘', 28 | 29 | bodyLeft: '│', 30 | bodyRight: '│', 31 | bodyJoin: '│', 32 | 33 | joinBody: '─', 34 | joinLeft: '├', 35 | joinRight: '┤', 36 | joinJoin: '┼', 37 | }, 38 | }; 39 | /* eslint-enable sort-keys-fix/sort-keys-fix */ 40 | 41 | const output = table(data, config); 42 | 43 | expectTable(output, ` 44 | ┌────┬────┬────┐ 45 | │ 0A │ 0B │ 0C │ 46 | ├────┼────┼────┤ 47 | │ 1A │ 1B │ 1C │ 48 | ├────┼────┼────┤ 49 | │ 2A │ 2B │ 2C │ 50 | └────┴────┴────┘ 51 | `); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/README/api/table/column/alignment.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TableUserConfig, 3 | } from '../../../../../src'; 4 | import { 5 | table, 6 | } from '../../../../../src'; 7 | import { 8 | expectTable, 9 | } from '../../../../utils'; 10 | 11 | describe('README.md api/table/columns', () => { 12 | it('/alignment', () => { 13 | const data = [ 14 | ['0A', '0B', '0C', '0D 0E 0F'], 15 | ['1A', '1B', '1C', '1D 1E 1F'], 16 | ['2A', '2B', '2C', '2D 2E 2F'], 17 | ]; 18 | 19 | const config: TableUserConfig = { 20 | columnDefault: { 21 | width: 10, 22 | }, 23 | columns: [ 24 | {alignment: 'left'}, 25 | {alignment: 'center'}, 26 | {alignment: 'right'}, 27 | {alignment: 'justify'}, 28 | ], 29 | }; 30 | 31 | expectTable(table(data, config), ` 32 | ╔════════════╤════════════╤════════════╤════════════╗ 33 | ║ 0A │ 0B │ 0C │ 0D 0E 0F ║ 34 | ╟────────────┼────────────┼────────────┼────────────╢ 35 | ║ 1A │ 1B │ 1C │ 1D 1E 1F ║ 36 | ╟────────────┼────────────┼────────────┼────────────╢ 37 | ║ 2A │ 2B │ 2C │ 2D 2E 2F ║ 38 | ╚════════════╧════════════╧════════════╧════════════╝ 39 | `); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/drawTable.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createTableBorderGetter, 3 | } from './drawBorder'; 4 | import { 5 | drawContent, 6 | } from './drawContent'; 7 | import { 8 | drawRow, 9 | } from './drawRow'; 10 | import type { 11 | TableConfig, Row, 12 | } from './types/internal'; 13 | import { 14 | groupBySizes, 15 | } from './utils'; 16 | 17 | export const drawTable = (rows: Row[], outputColumnWidths: number[], rowHeights: number[], config: TableConfig): string => { 18 | const { 19 | drawHorizontalLine, 20 | singleLine, 21 | } = config; 22 | 23 | const contents = groupBySizes(rows, rowHeights).map((group, groupIndex) => { 24 | return group.map((row) => { 25 | return drawRow(row, {...config, 26 | rowIndex: groupIndex}); 27 | }).join(''); 28 | }); 29 | 30 | return drawContent({contents, 31 | drawSeparator: (index, size) => { 32 | // Top/bottom border 33 | if (index === 0 || index === size) { 34 | return drawHorizontalLine(index, size); 35 | } 36 | 37 | return !singleLine && drawHorizontalLine(index, size); 38 | }, 39 | elementType: 'row', 40 | rowIndex: -1, 41 | separatorGetter: createTableBorderGetter(outputColumnWidths, {...config, 42 | rowCount: contents.length}), 43 | spanningCellManager: config.spanningCellManager}); 44 | }; 45 | -------------------------------------------------------------------------------- /test/validateTableConfig.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ValidateFunction, 3 | } from 'ajv'; 4 | import Ajv from 'ajv'; 5 | import ajvKeywords from 'ajv-keywords'; 6 | import { 7 | expect, 8 | } from 'chai'; 9 | import validators from '../src/generated/validators'; 10 | import configSchema from '../src/schemas/config.json'; 11 | import sharedSchema from '../src/schemas/shared.json'; 12 | import { 13 | tableConfigSamples, 14 | } from './tableConfigSamples'; 15 | 16 | const validateConfig = validators['config.json']; 17 | 18 | describe('config.json schema', () => { 19 | let validate: ValidateFunction; 20 | 21 | before(() => { 22 | const ajv = new Ajv({allErrors: true}); 23 | 24 | ajvKeywords(ajv, 'typeof'); 25 | ajv.addSchema(sharedSchema); 26 | validate = ajv.compile(configSchema); 27 | }); 28 | 29 | it('passes validation of valid config samples', () => { 30 | for (const sample of tableConfigSamples.valid) { 31 | expect(validate(sample)).to.equal(true); 32 | expect(validateConfig(sample)).to.equal(true); 33 | } 34 | }); 35 | 36 | it('fails validation of invalid config samples', () => { 37 | for (const sample of tableConfigSamples.invalid) { 38 | expect(validate(sample)).to.equal(false); 39 | expect(validateConfig(sample)).to.equal(false); 40 | } 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /.README/api/table/header.md: -------------------------------------------------------------------------------- 1 | ##### config.header 2 | 3 | Type: `object` 4 | 5 | Header configuration. 6 | 7 | *Deprecated in favor of the new spanning cells API.* 8 | 9 | The header configuration inherits the most of the column's, except: 10 | - `content` **{string}**: the header content. 11 | - `width:` calculate based on the content width automatically. 12 | - `alignment:` `center` be default. 13 | - `verticalAlignment:` is not supported. 14 | - `config.border.topJoin` will be `config.border.topBody` for prettier. 15 | 16 | ```js 17 | const data = [ 18 | ['0A', '0B', '0C'], 19 | ['1A', '1B', '1C'], 20 | ['2A', '2B', '2C'], 21 | ]; 22 | 23 | const config = { 24 | columnDefault: { 25 | width: 10, 26 | }, 27 | header: { 28 | alignment: 'center', 29 | content: 'THE HEADER\nThis is the table about something', 30 | }, 31 | } 32 | 33 | console.log(table(data, config)); 34 | ``` 35 | 36 | ``` 37 | ╔══════════════════════════════════════╗ 38 | ║ THE HEADER ║ 39 | ║ This is the table about something ║ 40 | ╟────────────┬────────────┬────────────╢ 41 | ║ 0A │ 0B │ 0C ║ 42 | ╟────────────┼────────────┼────────────╢ 43 | ║ 1A │ 1B │ 1C ║ 44 | ╟────────────┼────────────┼────────────╢ 45 | ║ 2A │ 2B │ 2C ║ 46 | ╚════════════╧════════════╧════════════╝ 47 | ``` 48 | -------------------------------------------------------------------------------- /.README/api/table/columns/wrapWord.md: -------------------------------------------------------------------------------- 1 | ###### config.columns[*].wrapWord 2 | 3 | Type: `boolean`\ 4 | Default: `false` 5 | 6 | The `table` package implements auto text wrapping, i.e., text that has the width greater than the container width will be separated into multiple lines at the nearest space or one of the special characters: `\|/_.,;-`. 7 | 8 | When `wrapWord` is `false`: 9 | 10 | ```js 11 | const data = [ 12 | ['Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus pulvinar nibh sed mauris convallis dapibus. Nunc venenatis tempus nulla sit amet viverra.'] 13 | ]; 14 | 15 | const config = { 16 | columns: [ { width: 20 } ] 17 | }; 18 | 19 | console.log(table(data, config)); 20 | ``` 21 | 22 | ``` 23 | ╔══════════════════════╗ 24 | ║ Lorem ipsum dolor si ║ 25 | ║ t amet, consectetur ║ 26 | ║ adipiscing elit. Pha ║ 27 | ║ sellus pulvinar nibh ║ 28 | ║ sed mauris convallis ║ 29 | ║ dapibus. Nunc venena ║ 30 | ║ tis tempus nulla sit ║ 31 | ║ amet viverra. ║ 32 | ╚══════════════════════╝ 33 | ``` 34 | 35 | When `wrapWord` is `true`: 36 | 37 | ``` 38 | ╔══════════════════════╗ 39 | ║ Lorem ipsum dolor ║ 40 | ║ sit amet, ║ 41 | ║ consectetur ║ 42 | ║ adipiscing elit. ║ 43 | ║ Phasellus pulvinar ║ 44 | ║ nibh sed mauris ║ 45 | ║ convallis dapibus. ║ 46 | ║ Nunc venenatis ║ 47 | ║ tempus nulla sit ║ 48 | ║ amet viverra. ║ 49 | ╚══════════════════════╝ 50 | 51 | ``` 52 | -------------------------------------------------------------------------------- /src/calculateSpanningCellWidth.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SpanningCellParameters, 3 | } from './spanningCellManager'; 4 | import type { 5 | RangeConfig, 6 | } from './types/internal'; 7 | import { 8 | sequence, sumArray, 9 | } from './utils'; 10 | 11 | export const calculateSpanningCellWidth = (rangeConfig: RangeConfig, dependencies: SpanningCellParameters): number => { 12 | const {columnsConfig, drawVerticalLine} = dependencies; 13 | const {topLeft, bottomRight} = rangeConfig; 14 | 15 | const totalWidth = sumArray( 16 | columnsConfig.slice(topLeft.col, bottomRight.col + 1).map(({width}) => { 17 | return width; 18 | }), 19 | ); 20 | 21 | const totalPadding = 22 | topLeft.col === bottomRight.col ? 23 | columnsConfig[topLeft.col].paddingRight + 24 | columnsConfig[bottomRight.col].paddingLeft : 25 | sumArray( 26 | columnsConfig 27 | .slice(topLeft.col, bottomRight.col + 1) 28 | .map(({paddingLeft, paddingRight}) => { 29 | return paddingLeft + paddingRight; 30 | }), 31 | ); 32 | const totalBorderWidths = bottomRight.col - topLeft.col; 33 | 34 | const totalHiddenVerticalBorders = sequence(topLeft.col + 1, bottomRight.col).filter((verticalBorderIndex) => { 35 | return !drawVerticalLine(verticalBorderIndex, columnsConfig.length); 36 | }).length; 37 | 38 | return totalWidth + totalPadding + totalBorderWidths - totalHiddenVerticalBorders; 39 | }; 40 | -------------------------------------------------------------------------------- /src/calculateMaximumColumnWidths.ts: -------------------------------------------------------------------------------- 1 | import stringWidth from 'string-width'; 2 | import type { 3 | SpanningCellConfig, 4 | } from './types/api'; 5 | import type { 6 | Row, 7 | Cell, 8 | } from './types/internal'; 9 | import { 10 | calculateRangeCoordinate, isCellInRange, 11 | } from './utils'; 12 | 13 | export const calculateMaximumCellWidth = (cell: Cell): number => { 14 | return Math.max( 15 | ...cell.split('\n').map(stringWidth), 16 | ); 17 | }; 18 | 19 | /** 20 | * Produces an array of values that describe the largest value length (width) in every column. 21 | */ 22 | export const calculateMaximumColumnWidths = (rows: Row[], spanningCellConfigs: SpanningCellConfig[] = []): number[] => { 23 | const columnWidths = new Array(rows[0].length).fill(0); 24 | const rangeCoordinates = spanningCellConfigs.map(calculateRangeCoordinate); 25 | const isSpanningCell = (rowIndex: number, columnIndex: number): boolean => { 26 | return rangeCoordinates.some((rangeCoordinate) => { 27 | return isCellInRange({col: columnIndex, 28 | row: rowIndex}, rangeCoordinate); 29 | }); 30 | }; 31 | 32 | rows.forEach((row, rowIndex) => { 33 | row.forEach((cell, cellIndex) => { 34 | if (isSpanningCell(rowIndex, cellIndex)) { 35 | return; 36 | } 37 | columnWidths[cellIndex] = Math.max(columnWidths[cellIndex], calculateMaximumCellWidth(cell)); 38 | }); 39 | }); 40 | 41 | return columnWidths; 42 | }; 43 | -------------------------------------------------------------------------------- /src/wrapWord.ts: -------------------------------------------------------------------------------- 1 | import slice from 'slice-ansi'; 2 | import stripAnsi from 'strip-ansi'; 3 | 4 | const calculateStringLengths = (input: string, size: number): Array<[Length:number, Offset: number]> => { 5 | let subject = stripAnsi(input); 6 | 7 | const chunks: Array<[number, number]> = []; 8 | 9 | // https://regex101.com/r/gY5kZ1/1 10 | const re = new RegExp('(^.{1,' + String(Math.max(size, 1)) + '}(\\s+|$))|(^.{1,' + String(Math.max(size - 1, 1)) + '}(\\\\|/|_|\\.|,|;|-))'); 11 | 12 | do { 13 | let chunk: string; 14 | 15 | const match = re.exec(subject); 16 | 17 | if (match) { 18 | chunk = match[0]; 19 | 20 | subject = subject.slice(chunk.length); 21 | 22 | const trimmedLength = chunk.trim().length; 23 | const offset = chunk.length - trimmedLength; 24 | 25 | chunks.push([trimmedLength, offset]); 26 | } else { 27 | chunk = subject.slice(0, size); 28 | subject = subject.slice(size); 29 | 30 | chunks.push([chunk.length, 0]); 31 | } 32 | } while (subject.length); 33 | 34 | return chunks; 35 | }; 36 | 37 | export const wrapWord = (input: string, size: number): string[] => { 38 | const result: string[] = []; 39 | 40 | let startIndex = 0; 41 | calculateStringLengths(input, size).forEach(([length, offset]) => { 42 | result.push(slice(input, startIndex, startIndex + length)); 43 | 44 | startIndex += length + offset; 45 | }); 46 | 47 | return result; 48 | }; 49 | -------------------------------------------------------------------------------- /test/validateStreamConfig.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ValidateFunction, 3 | } from 'ajv'; 4 | import Ajv from 'ajv'; 5 | import ajvKeywords from 'ajv-keywords'; 6 | import ajvSchemaDraft06 from 'ajv/lib/refs/json-schema-draft-06.json'; 7 | import { 8 | expect, 9 | } from 'chai'; 10 | import validators from '../src/generated/validators'; 11 | import sharedSchema from '../src/schemas/shared.json'; 12 | import configSchema from '../src/schemas/streamConfig.json'; 13 | import streamConfigSamples from './streamConfigSamples'; 14 | 15 | const validateConfig = validators['streamConfig.json']; 16 | 17 | describe('streamConfig.json schema', () => { 18 | let validate: ValidateFunction; 19 | 20 | before(() => { 21 | const ajv = new Ajv({ 22 | allErrors: true, 23 | }); 24 | 25 | ajv.addMetaSchema(ajvSchemaDraft06); 26 | 27 | ajvKeywords(ajv, 'typeof'); 28 | ajv.addSchema(sharedSchema); 29 | validate = ajv.compile(configSchema); 30 | }); 31 | 32 | it('passes validation of valid streamConfig samples', () => { 33 | for (const sample of streamConfigSamples.valid) { 34 | expect(validate(sample)).to.equal(true); 35 | expect(validateConfig(sample)).to.equal(true); 36 | } 37 | }); 38 | 39 | it('fails validation of invalid streamConfig samples', () => { 40 | for (const sample of streamConfigSamples.invalid) { 41 | expect(validate(sample)).to.equal(false); 42 | expect(validateConfig(sample)).to.equal(false); 43 | } 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/validateSpanningCellConfig.ts: -------------------------------------------------------------------------------- 1 | import { 2 | expect, 3 | } from 'chai'; 4 | import { 5 | validateSpanningCellConfig, 6 | } from '../src/validateSpanningCellConfig'; 7 | import { 8 | baseRows, 9 | } from './spanningCellFixtures'; 10 | 11 | describe('validateSpanningCellConfig', () => { 12 | it('colSpan = 0', () => { 13 | expect(() => { 14 | validateSpanningCellConfig(baseRows, [{col: 1, 15 | colSpan: 0, 16 | row: 0}]); 17 | }).to.be.throw(); 18 | }); 19 | 20 | it('rowSpan = 0', () => { 21 | expect(() => { 22 | validateSpanningCellConfig(baseRows, [{col: 1, 23 | row: 0, 24 | rowSpan: 0}]); 25 | }).to.be.throw(); 26 | }); 27 | 28 | it('no given colSpan and rowSpan', () => { 29 | expect(() => { 30 | validateSpanningCellConfig(baseRows, [{col: 1, 31 | row: 0}]); 32 | }).to.be.throw(); 33 | }); 34 | 35 | it('topLeft is out of range', () => { 36 | expect(() => { 37 | validateSpanningCellConfig(baseRows, [{col: 4, 38 | row: 0}]); 39 | }).to.be.throw(); 40 | }); 41 | 42 | it('bottomRight is out of range', () => { 43 | expect(() => { 44 | validateSpanningCellConfig(baseRows, [{col: 2, 45 | colSpan: 3, 46 | row: 0}]); 47 | }).to.be.throw(); 48 | }); 49 | 50 | it('overlap', () => { 51 | expect(() => { 52 | validateSpanningCellConfig(baseRows, [{col: 0, 53 | row: 0, 54 | rowSpan: 2}, {col: 0, 55 | colSpan: 2, 56 | row: 1}]); 57 | }).to.be.throw(); 58 | }); 59 | }); 60 | 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Gajus Kuizinas (http://gajus.com/) 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the Gajus Kuizinas (http://gajus.com/) nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL ANUARY BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /test/README/api/stream/streaming.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createStream, 3 | } from '../../../../src'; 4 | import { 5 | expectTable, 6 | } from '../../../utils'; 7 | 8 | describe('README.md api/stream', () => { 9 | describe('process.stdout.write', () => { 10 | let processStdoutWriteBuffer: string; 11 | 12 | /** 13 | * @member {Function} Reference to the original process.stdout.write function. 14 | */ 15 | // eslint-disable-next-line @typescript-eslint/unbound-method 16 | const processStdoutWrite = process.stdout.write; 17 | 18 | const overwriteProcessStdoutWrite = () => { 19 | processStdoutWriteBuffer = ''; 20 | 21 | process.stdout.write = (text: string) => { 22 | processStdoutWriteBuffer += text; 23 | 24 | return true; 25 | }; 26 | }; 27 | 28 | const resetProcessStdoutWrite = () => { 29 | process.stdout.write = processStdoutWrite; 30 | 31 | return processStdoutWriteBuffer; 32 | }; 33 | 34 | it('streaming', () => { 35 | const config = { 36 | columnCount: 3, 37 | columnDefault: { 38 | width: 2, 39 | }, 40 | }; 41 | 42 | const stream = createStream(config); 43 | 44 | overwriteProcessStdoutWrite(); 45 | 46 | stream.write(['0A', '0B', '0C']); 47 | stream.write(['1A', '1B', '1C']); 48 | stream.write(['2A', '2B', '2C']); 49 | 50 | const output = resetProcessStdoutWrite(); 51 | 52 | expectTable(output + '\n', '╔════╤════╤════╗\n║ 0A │ 0B │ 0C ║\n╚════╧════╧════╝\r\u001B[K╟────┼────┼────╢\n║ 1A │ 1B │ 1C ║\n╚════╧════╧════╝\r\u001B[K╟────┼────┼────╢\n║ 2A │ 2B │ 2C ║\n╚════╧════╧════╝'); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/drawRow.ts: -------------------------------------------------------------------------------- 1 | import { 2 | expect, 3 | } from 'chai'; 4 | import { 5 | drawRow, 6 | } from '../src/drawRow'; 7 | 8 | const drawVerticalLine = () => { 9 | return true; 10 | }; 11 | 12 | describe('drawRow', () => { 13 | context('default drawVerticalLine', () => { 14 | it('draws a row using all parts', () => { 15 | const border = { 16 | bodyJoin: '│', 17 | bodyLeft: '║', 18 | bodyRight: '║', 19 | }; 20 | const config = { 21 | border, 22 | drawVerticalLine, 23 | }; 24 | 25 | expect(drawRow([], config)).to.equal('║║\n'); 26 | expect(drawRow(['a'], config)).to.equal('║a║\n'); 27 | expect(drawRow(['a', ' b '], config)).to.equal('║a│ b ║\n'); 28 | }); 29 | }); 30 | 31 | context('custom drawVerticalLine', () => { 32 | it('draws the vertical line when the drawVerticalLine returns true', () => { 33 | const rows = [' a ', ' b ', ' c ']; 34 | 35 | const border = { 36 | bodyJoin: '│', 37 | bodyLeft: '║', 38 | bodyRight: '║', 39 | }; 40 | 41 | expect(drawRow(rows, { 42 | border, 43 | drawVerticalLine: (index) => { 44 | return index === 0; 45 | }, 46 | })).to.equal('║ a b c \n'); 47 | 48 | expect(drawRow(rows, { 49 | border, 50 | drawVerticalLine: (index) => { 51 | return index % 2 === 0; 52 | }, 53 | })).to.equal('║ a b │ c \n'); 54 | 55 | expect(drawRow(rows, { 56 | border, 57 | drawVerticalLine: (index, size) => { 58 | return index > 0 && index <= size; 59 | }, 60 | })).to.equal(' a │ b │ c ║\n'); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/makeStreamConfig.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ColumnUserConfig, 3 | Indexable, 4 | StreamUserConfig, 5 | } from './types/api'; 6 | import type { 7 | ColumnConfig, 8 | StreamConfig, 9 | } from './types/internal'; 10 | import { 11 | makeBorderConfig, 12 | } from './utils'; 13 | import { 14 | validateConfig, 15 | } from './validateConfig'; 16 | 17 | /** 18 | * Creates a configuration for every column using default 19 | * values for the missing configuration properties. 20 | */ 21 | const makeColumnsConfig = (columnCount: number, 22 | columns: Indexable = {}, 23 | columnDefault: StreamUserConfig['columnDefault']): ColumnConfig[] => { 24 | return Array.from({length: columnCount}).map((_, index) => { 25 | return { 26 | alignment: 'left', 27 | paddingLeft: 1, 28 | paddingRight: 1, 29 | truncate: Number.POSITIVE_INFINITY, 30 | verticalAlignment: 'top', 31 | wrapWord: false, 32 | ...columnDefault, 33 | ...columns[index], 34 | }; 35 | }); 36 | }; 37 | 38 | /** 39 | * Makes a new configuration object out of the userConfig object 40 | * using default values for the missing configuration properties. 41 | */ 42 | export const makeStreamConfig = (config: StreamUserConfig): StreamConfig => { 43 | validateConfig('streamConfig.json', config); 44 | 45 | if (config.columnDefault.width === undefined) { 46 | throw new Error('Must provide config.columnDefault.width when creating a stream.'); 47 | } 48 | 49 | return { 50 | drawVerticalLine: () => { 51 | return true; 52 | }, 53 | ...config, 54 | border: makeBorderConfig(config.border), 55 | columns: makeColumnsConfig(config.columnCount, config.columns, config.columnDefault), 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /test/README/api/table/column/wrapWord.ts: -------------------------------------------------------------------------------- 1 | import { 2 | table, 3 | } from '../../../../../src'; 4 | import { 5 | expectTable, 6 | } from '../../../../utils'; 7 | 8 | describe('README.md api/table/column/wrapWord', () => { 9 | it('text_wrapping (no wrap word)', () => { 10 | const data = [ 11 | ['Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus pulvinar nibh sed mauris convallis dapibus. Nunc venenatis tempus nulla sit amet viverra.'], 12 | ]; 13 | 14 | const config = { 15 | columns: [{width: 20}], 16 | }; 17 | 18 | const output = table(data, config); 19 | 20 | expectTable(output, ` 21 | ╔══════════════════════╗ 22 | ║ Lorem ipsum dolor si ║ 23 | ║ t amet, consectetur ║ 24 | ║ adipiscing elit. Pha ║ 25 | ║ sellus pulvinar nibh ║ 26 | ║ sed mauris convallis ║ 27 | ║ dapibus. Nunc venena ║ 28 | ║ tis tempus nulla sit ║ 29 | ║ amet viverra. ║ 30 | ╚══════════════════════╝ 31 | `); 32 | }); 33 | 34 | it('text_wrapping (wrap word)', () => { 35 | const data = [ 36 | ['Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus pulvinar nibh sed mauris convallis dapibus. Nunc venenatis tempus nulla sit amet viverra.'], 37 | ]; 38 | 39 | const config = { 40 | columns: [{width: 20, 41 | wrapWord: true}], 42 | }; 43 | 44 | expectTable(table(data, config), ` 45 | ╔══════════════════════╗ 46 | ║ Lorem ipsum dolor ║ 47 | ║ sit amet, ║ 48 | ║ consectetur ║ 49 | ║ adipiscing elit. ║ 50 | ║ Phasellus pulvinar ║ 51 | ║ nibh sed mauris ║ 52 | ║ convallis dapibus. ║ 53 | ║ Nunc venenatis ║ 54 | ║ tempus nulla sit ║ 55 | ║ amet viverra. ║ 56 | ╚══════════════════════╝ 57 | `); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /.README/api/stream/index.md: -------------------------------------------------------------------------------- 1 | ### createStream 2 | 3 | `table` package exports `createStream` function used to draw a table and append rows. 4 | 5 | **Parameter:** 6 | - _**config:**_ the same as `table`'s, except `config.columnDefault.width` and `config.columnCount` must be provided. 7 | 8 | 9 | ```js 10 | import { createStream } from 'table'; 11 | 12 | const config = { 13 | columnDefault: { 14 | width: 50 15 | }, 16 | columnCount: 1 17 | }; 18 | 19 | const stream = createStream(config); 20 | 21 | setInterval(() => { 22 | stream.write([new Date()]); 23 | }, 500); 24 | ``` 25 | 26 | ![Streaming current date.](./.README/api/stream/streaming.gif) 27 | 28 | `table` package uses ANSI escape codes to overwrite the output of the last line when a new row is printed. 29 | 30 | The underlying implementation is explained in this [Stack Overflow answer](http://stackoverflow.com/a/32938658/368691). 31 | 32 | Streaming supports all of the configuration properties and functionality of a static table (such as auto text wrapping, alignment and padding), e.g. 33 | 34 | ```js 35 | import { createStream } from 'table'; 36 | 37 | import _ from 'lodash'; 38 | 39 | const config = { 40 | columnDefault: { 41 | width: 50 42 | }, 43 | columnCount: 3, 44 | columns: [ 45 | { 46 | width: 10, 47 | alignment: 'right' 48 | }, 49 | { alignment: 'center' }, 50 | { width: 10 } 51 | 52 | ] 53 | }; 54 | 55 | const stream = createStream(config); 56 | 57 | let i = 0; 58 | 59 | setInterval(() => { 60 | let random; 61 | 62 | random = _.sample('abcdefghijklmnopqrstuvwxyz', _.random(1, 30)).join(''); 63 | 64 | stream.write([i++, new Date(), random]); 65 | }, 500); 66 | ``` 67 | 68 | ![Streaming random data.](./.README/api/stream/streaming-random.gif) 69 | -------------------------------------------------------------------------------- /test/wrapString.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-nested-callbacks */ 2 | 3 | import { 4 | expect, 5 | } from 'chai'; 6 | import { 7 | wrapString, 8 | } from '../src/wrapString'; 9 | 10 | describe('wrapString', () => { 11 | context('subject is a plain text string', () => { 12 | context('subject is lesser than the chunk size', () => { 13 | it('returns subject in a single chunk', () => { 14 | expect(wrapString('aaa', 3)).to.deep.equal(['aaa']); 15 | }); 16 | }); 17 | context('subject is larger than the chunk size', () => { 18 | it('returns subject sliced into multiple chunks', () => { 19 | expect(wrapString('aaabbbc', 3)).to.deep.equal(['aaa', 'bbb', 'c']); 20 | }); 21 | }); 22 | context('a chunk starts with a space', () => { 23 | it('adjusts chunks to offset the space', () => { 24 | expect(wrapString('aaa bbb ccc', 3)).to.deep.equal(['aaa', 'bbb', 'ccc']); 25 | }); 26 | }); 27 | }); 28 | context('subject string contains ANSI escape codes', () => { 29 | const openRed = '\u001b[31m'; 30 | const closeRed = '\u001b[39m'; 31 | describe('subject is lesser than the chunk size', () => { 32 | it('returns subject in a single chunk', () => { 33 | expect(wrapString(`${openRed}aa${closeRed}`, 3)).to.deep.equal([ 34 | `${openRed}aa${closeRed}`, 35 | ]); 36 | }); 37 | }); 38 | describe('subject is larger than the chunk size', () => { 39 | it('returns subject sliced into multiple chunks', () => { 40 | expect(wrapString(`${openRed}aaabbbc${closeRed}`, 3)).to.deep.equal([ 41 | `${openRed}aaa${closeRed}`, 42 | `${openRed}bbb${closeRed}`, 43 | `${openRed}c${closeRed}`, 44 | ]); 45 | }); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/table.ts: -------------------------------------------------------------------------------- 1 | import { 2 | alignTableData, 3 | } from './alignTableData'; 4 | import { 5 | calculateOutputColumnWidths, 6 | } from './calculateOutputColumnWidths'; 7 | import { 8 | calculateRowHeights, 9 | } from './calculateRowHeights'; 10 | import { 11 | drawTable, 12 | } from './drawTable'; 13 | import { 14 | injectHeaderConfig, 15 | } from './injectHeaderConfig'; 16 | import { 17 | makeTableConfig, 18 | } from './makeTableConfig'; 19 | import { 20 | mapDataUsingRowHeights, 21 | } from './mapDataUsingRowHeights'; 22 | import { 23 | padTableData, 24 | } from './padTableData'; 25 | import { 26 | stringifyTableData, 27 | } from './stringifyTableData'; 28 | import { 29 | truncateTableData, 30 | } from './truncateTableData'; 31 | import type { 32 | TableUserConfig, 33 | } from './types/api'; 34 | import { 35 | extractTruncates, 36 | } from './utils'; 37 | import { 38 | validateTableData, 39 | } from './validateTableData'; 40 | 41 | export const table = (data: ReadonlyArray, userConfig: TableUserConfig = {}): string => { 42 | validateTableData(data); 43 | 44 | let rows = stringifyTableData(data); 45 | 46 | const [injectedRows, injectedSpanningCellConfig] = injectHeaderConfig(rows, userConfig); 47 | 48 | const config = makeTableConfig(injectedRows, userConfig, injectedSpanningCellConfig); 49 | 50 | rows = truncateTableData(injectedRows, extractTruncates(config)); 51 | 52 | const rowHeights = calculateRowHeights(rows, config); 53 | 54 | config.spanningCellManager.setRowHeights(rowHeights); 55 | config.spanningCellManager.setRowIndexMapping(rowHeights); 56 | 57 | rows = mapDataUsingRowHeights(rows, rowHeights, config); 58 | rows = alignTableData(rows, config); 59 | rows = padTableData(rows, config); 60 | 61 | const outputColumnWidths = calculateOutputColumnWidths(config); 62 | 63 | return drawTable(rows, outputColumnWidths, rowHeights, config); 64 | }; 65 | -------------------------------------------------------------------------------- /.README/api/table/singleLine.md: -------------------------------------------------------------------------------- 1 | ##### config.singleLine 2 | 3 | Type: `boolean`\ 4 | Default: `false` 5 | 6 | If `true`, horizontal lines inside the table are not drawn. This option also overrides the `config.drawHorizontalLine` if specified. 7 | 8 | ```js 9 | const data = [ 10 | ['-rw-r--r--', '1', 'pandorym', 'staff', '1529', 'May 23 11:25', 'LICENSE'], 11 | ['-rw-r--r--', '1', 'pandorym', 'staff', '16327', 'May 23 11:58', 'README.md'], 12 | ['drwxr-xr-x', '76', 'pandorym', 'staff', '2432', 'May 23 12:02', 'dist'], 13 | ['drwxr-xr-x', '634', 'pandorym', 'staff', '20288', 'May 23 11:54', 'node_modules'], 14 | ['-rw-r--r--', '1,', 'pandorym', 'staff', '525688', 'May 23 11:52', 'package-lock.json'], 15 | ['-rw-r--r--@', '1', 'pandorym', 'staff', '2440', 'May 23 11:25', 'package.json'], 16 | ['drwxr-xr-x', '27', 'pandorym', 'staff', '864', 'May 23 11:25', 'src'], 17 | ['drwxr-xr-x', '20', 'pandorym', 'staff', '640', 'May 23 11:25', 'test'], 18 | ]; 19 | 20 | const config = { 21 | singleLine: true 22 | }; 23 | 24 | console.log(table(data, config)); 25 | ``` 26 | 27 | ``` 28 | ╔═════════════╤═════╤══════════╤═══════╤════════╤══════════════╤═══════════════════╗ 29 | ║ -rw-r--r-- │ 1 │ pandorym │ staff │ 1529 │ May 23 11:25 │ LICENSE ║ 30 | ║ -rw-r--r-- │ 1 │ pandorym │ staff │ 16327 │ May 23 11:58 │ README.md ║ 31 | ║ drwxr-xr-x │ 76 │ pandorym │ staff │ 2432 │ May 23 12:02 │ dist ║ 32 | ║ drwxr-xr-x │ 634 │ pandorym │ staff │ 20288 │ May 23 11:54 │ node_modules ║ 33 | ║ -rw-r--r-- │ 1, │ pandorym │ staff │ 525688 │ May 23 11:52 │ package-lock.json ║ 34 | ║ -rw-r--r--@ │ 1 │ pandorym │ staff │ 2440 │ May 23 11:25 │ package.json ║ 35 | ║ drwxr-xr-x │ 27 │ pandorym │ staff │ 864 │ May 23 11:25 │ src ║ 36 | ║ drwxr-xr-x │ 20 │ pandorym │ staff │ 640 │ May 23 11:25 │ test ║ 37 | ╚═════════════╧═════╧══════════╧═══════╧════════╧══════════════╧═══════════════════╝ 38 | ``` 39 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Test Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 14 14 | - 12 15 | - 10 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | 21 | - name: Setup Node 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - name: Cache node modules 27 | id: cache-node-modules 28 | uses: actions/cache@v2 29 | env: 30 | cache-name: cache-node-modules 31 | with: 32 | path: node_modules 33 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 34 | restore-keys: | 35 | ${{ runner.os }}-build-${{ env.cache-name }}- 36 | ${{ runner.os }}-build- 37 | ${{ runner.os }}- 38 | 39 | - name: Install Dependencies 40 | if: steps.cache-node-modules.outputs.cache-hit != 'true' 41 | run: npm ci 42 | 43 | - name: Build 44 | run: npm run build 45 | 46 | - name: Lint 47 | run: npm run lint 48 | 49 | - name: Test 50 | run: npm run test 51 | 52 | - name: Upload to Coveralls 53 | uses: coverallsapp/github-action@master 54 | with: 55 | github-token: ${{ secrets.GITHUB_TOKEN }} 56 | release: 57 | name: Release 58 | needs: test 59 | if: github.ref == 'refs/heads/master' 60 | runs-on: ubuntu-latest 61 | 62 | steps: 63 | - uses: actions/checkout@v2 64 | - run: npm ci 65 | - run: npm run build 66 | - run: npx semantic-release 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 70 | NODE_ENV: production 71 | CI: true 72 | -------------------------------------------------------------------------------- /test/README/api/table/singleLine.ts: -------------------------------------------------------------------------------- 1 | import { 2 | table, 3 | } from '../../../../src'; 4 | import { 5 | expectTable, 6 | } from '../../../utils'; 7 | 8 | describe('README.md api/table/', () => { 9 | it('singleLine', () => { 10 | const data = [ 11 | ['-rw-r--r--', '1', 'pandorym', 'staff', '1529', 'May 23 11:25', 'LICENSE'], 12 | ['-rw-r--r--', '1', 'pandorym', 'staff', '16327', 'May 23 11:58', 'README.md'], 13 | ['drwxr-xr-x', '76', 'pandorym', 'staff', '2432', 'May 23 12:02', 'dist'], 14 | ['drwxr-xr-x', '634', 'pandorym', 'staff', '20288', 'May 23 11:54', 'node_modules'], 15 | ['-rw-r--r--', '1,', 'pandorym', 'staff', '525688', 'May 23 11:52', 'package-lock.json'], 16 | ['-rw-r--r--@', '1', 'pandorym', 'staff', '2440', 'May 23 11:25', 'package.json'], 17 | ['drwxr-xr-x', '27', 'pandorym', 'staff', '864', 'May 23 11:25', 'src'], 18 | ['drwxr-xr-x', '20', 'pandorym', 'staff', '640', 'May 23 11:25', 'test'], 19 | ]; 20 | 21 | const config = { 22 | singleLine: true, 23 | }; 24 | 25 | const output = table(data, config); 26 | 27 | expectTable(output, ` 28 | ╔═════════════╤═════╤══════════╤═══════╤════════╤══════════════╤═══════════════════╗ 29 | ║ -rw-r--r-- │ 1 │ pandorym │ staff │ 1529 │ May 23 11:25 │ LICENSE ║ 30 | ║ -rw-r--r-- │ 1 │ pandorym │ staff │ 16327 │ May 23 11:58 │ README.md ║ 31 | ║ drwxr-xr-x │ 76 │ pandorym │ staff │ 2432 │ May 23 12:02 │ dist ║ 32 | ║ drwxr-xr-x │ 634 │ pandorym │ staff │ 20288 │ May 23 11:54 │ node_modules ║ 33 | ║ -rw-r--r-- │ 1, │ pandorym │ staff │ 525688 │ May 23 11:52 │ package-lock.json ║ 34 | ║ -rw-r--r--@ │ 1 │ pandorym │ staff │ 2440 │ May 23 11:25 │ package.json ║ 35 | ║ drwxr-xr-x │ 27 │ pandorym │ staff │ 864 │ May 23 11:25 │ src ║ 36 | ║ drwxr-xr-x │ 20 │ pandorym │ staff │ 640 │ May 23 11:25 │ test ║ 37 | ╚═════════════╧═════╧══════════╧═══════╧════════╧══════════════╧═══════════════════╝ 38 | `); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/calculateRowHeights.ts: -------------------------------------------------------------------------------- 1 | import { 2 | calculateCellHeight, 3 | } from './calculateCellHeight'; 4 | import type { 5 | BaseConfig, 6 | Row, 7 | } from './types/internal'; 8 | import { 9 | sequence, 10 | sumArray, 11 | } from './utils'; 12 | 13 | /** 14 | * Produces an array of values that describe the largest value length (height) in every row. 15 | */ 16 | export const calculateRowHeights = (rows: Row[], config: BaseConfig): number[] => { 17 | const rowHeights: number[] = []; 18 | 19 | for (const [rowIndex, row] of rows.entries()) { 20 | let rowHeight = 1; 21 | 22 | row.forEach((cell, cellIndex) => { 23 | const containingRange = config.spanningCellManager?.getContainingRange({col: cellIndex, 24 | row: rowIndex}); 25 | 26 | if (!containingRange) { 27 | const cellHeight = calculateCellHeight(cell, config.columns[cellIndex].width, config.columns[cellIndex].wrapWord); 28 | rowHeight = Math.max(rowHeight, cellHeight); 29 | 30 | return; 31 | } 32 | const {topLeft, bottomRight, height} = containingRange; 33 | 34 | // bottom-most cell of a range needs to contain all remain lines of spanning cells 35 | if (rowIndex === bottomRight.row) { 36 | const totalOccupiedSpanningCellHeight = sumArray(rowHeights.slice(topLeft.row)); 37 | const totalHorizontalBorderHeight = bottomRight.row - topLeft.row; 38 | const totalHiddenHorizontalBorderHeight = sequence(topLeft.row + 1, bottomRight.row).filter((horizontalBorderIndex) => { 39 | /* istanbul ignore next */ 40 | return !config.drawHorizontalLine?.(horizontalBorderIndex, rows.length); 41 | }).length; 42 | 43 | const cellHeight = height - totalOccupiedSpanningCellHeight - totalHorizontalBorderHeight + totalHiddenHorizontalBorderHeight; 44 | rowHeight = Math.max(rowHeight, cellHeight); 45 | } 46 | 47 | // otherwise, just depend on other sibling cell heights in the row 48 | }); 49 | 50 | rowHeights.push(rowHeight); 51 | } 52 | 53 | return rowHeights; 54 | }; 55 | -------------------------------------------------------------------------------- /test/alignTableData.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-nested-callbacks */ 2 | 3 | import { 4 | expect, 5 | } from 'chai'; 6 | import chalk from 'chalk'; 7 | import { 8 | alignTableData, 9 | } from '../src/alignTableData'; 10 | import { 11 | makeTableConfig, 12 | } from '../src/makeTableConfig'; 13 | 14 | describe('alignTableData', () => { 15 | context('when the string width is equal to column width config', () => { 16 | it('returns the unchange string', () => { 17 | const rows = [['aaa'], [chalk.red('bbb')]]; 18 | 19 | expect(alignTableData(rows, makeTableConfig(rows, { 20 | columns: { 21 | 0: { 22 | width: 3, 23 | }, 24 | }, 25 | }))).to.deep.equal(rows); 26 | }); 27 | }); 28 | 29 | context('when the string is different from the column width config', () => { 30 | it('aligns cells with column width and alignment config', () => { 31 | const rows = [['a', 'b', 'c'], 32 | [chalk.red('a'), chalk.red('b'), chalk.red('c')]]; 33 | 34 | expect(alignTableData(rows, makeTableConfig(rows, { 35 | columnDefault: { 36 | width: 3, 37 | }, 38 | columns: { 39 | 0: { 40 | alignment: 'left', 41 | }, 42 | 1: { 43 | alignment: 'right', 44 | }, 45 | 2: { 46 | alignment: 'center', 47 | }, 48 | }, 49 | }))).to.deep.equal([['a ', ' b', ' c '], [ 50 | chalk.red('a') + ' ', ' ' + chalk.red('b'), ' ' + chalk.red('c') + ' ', 51 | ]]); 52 | }); 53 | }); 54 | 55 | context('when the string is longer then column width', () => { 56 | it('throws an error', () => { 57 | const rows = [['aaaa']]; 58 | 59 | expect(() => { 60 | alignTableData(rows, makeTableConfig(rows, { 61 | columns: { 62 | 0: { 63 | width: 3, 64 | }, 65 | }, 66 | })); 67 | }).to.throw(Error, 'Subject parameter value width cannot be greater than the container width.'); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /test/calculateRowHeights.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-nested-callbacks */ 2 | 3 | import { 4 | expect, 5 | } from 'chai'; 6 | import { 7 | calculateRowHeights, 8 | } from '../src/calculateRowHeights'; 9 | import { 10 | makeTableConfig, 11 | } from '../src/makeTableConfig'; 12 | 13 | describe('calculateRowHeights', () => { 14 | context('single column', () => { 15 | context('cell content width is lesser than column width', () => { 16 | it('is equal to 1', () => { 17 | const data = [['aaa']]; 18 | 19 | const config = makeTableConfig(data, { 20 | columns: { 21 | 0: { 22 | width: 10, 23 | wrapWord: false, 24 | }, 25 | }, 26 | }); 27 | 28 | const rowHeights = calculateRowHeights(data, config); 29 | 30 | expect(rowHeights[0]).to.equal(1); 31 | }); 32 | }); 33 | context('cell content width is twice the size of the column width', () => { 34 | it('is equal to 2', () => { 35 | const data = [['aaabbb']]; 36 | 37 | const config = makeTableConfig(data, { 38 | columns: { 39 | 0: { 40 | width: 3, 41 | wrapWord: false, 42 | }, 43 | }, 44 | }); 45 | 46 | const rowHeights = calculateRowHeights(data, config); 47 | 48 | expect(rowHeights[0]).to.equal(2); 49 | }); 50 | }); 51 | }); 52 | context('multiple columns', () => { 53 | context('multiple cell content width is greater than the column width', () => { 54 | it('uses the largest height', () => { 55 | const data = [ 56 | ['aaabbb'], 57 | ['aaabbb'], 58 | ]; 59 | 60 | const config = makeTableConfig(data, { 61 | columns: { 62 | 0: { 63 | width: 2, 64 | wrapWord: false, 65 | }, 66 | }, 67 | }); 68 | 69 | const rowHeights = calculateRowHeights(data, config); 70 | 71 | expect(rowHeights[0]).to.equal(3); 72 | }); 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /.README/api/getBorderCharacters.md: -------------------------------------------------------------------------------- 1 | ### getBorderCharacters 2 | 3 | **Parameter:** 4 | - **_template_** 5 | - Type: `'honeywell' | 'norc' | 'ramac' | 'void'` 6 | - Required: `true` 7 | 8 | You can load one of the predefined border templates using `getBorderCharacters` function. 9 | 10 | ```js 11 | import { table, getBorderCharacters } from 'table'; 12 | 13 | const data = [ 14 | ['0A', '0B', '0C'], 15 | ['1A', '1B', '1C'], 16 | ['2A', '2B', '2C'] 17 | ]; 18 | 19 | const config = { 20 | border: getBorderCharacters(`name of the template`) 21 | }; 22 | 23 | console.log(table(data, config)); 24 | ``` 25 | 26 | ``` 27 | # honeywell 28 | 29 | ╔════╤════╤════╗ 30 | ║ 0A │ 0B │ 0C ║ 31 | ╟────┼────┼────╢ 32 | ║ 1A │ 1B │ 1C ║ 33 | ╟────┼────┼────╢ 34 | ║ 2A │ 2B │ 2C ║ 35 | ╚════╧════╧════╝ 36 | 37 | # norc 38 | 39 | ┌────┬────┬────┐ 40 | │ 0A │ 0B │ 0C │ 41 | ├────┼────┼────┤ 42 | │ 1A │ 1B │ 1C │ 43 | ├────┼────┼────┤ 44 | │ 2A │ 2B │ 2C │ 45 | └────┴────┴────┘ 46 | 47 | # ramac (ASCII; for use in terminals that do not support Unicode characters) 48 | 49 | +----+----+----+ 50 | | 0A | 0B | 0C | 51 | |----|----|----| 52 | | 1A | 1B | 1C | 53 | |----|----|----| 54 | | 2A | 2B | 2C | 55 | +----+----+----+ 56 | 57 | # void (no borders; see "borderless table" section of the documentation) 58 | 59 | 0A 0B 0C 60 | 61 | 1A 1B 1C 62 | 63 | 2A 2B 2C 64 | 65 | ``` 66 | 67 | Raise [an issue](https://github.com/gajus/table/issues) if you'd like to contribute a new border template. 68 | 69 | #### Borderless Table 70 | 71 | Simply using `void` border character template creates a table with a lot of unnecessary spacing. 72 | 73 | To create a more pleasant to the eye table, reset the padding and remove the joining rows, e.g. 74 | 75 | ```js 76 | 77 | const output = table(data, { 78 | border: getBorderCharacters('void'), 79 | columnDefault: { 80 | paddingLeft: 0, 81 | paddingRight: 1 82 | }, 83 | drawHorizontalLine: () => false 84 | } 85 | ); 86 | 87 | console.log(output); 88 | ``` 89 | 90 | ``` 91 | 0A 0B 0C 92 | 1A 1B 1C 93 | 2A 2B 2C 94 | ``` 95 | -------------------------------------------------------------------------------- /test/README/api/getBorderCharacters.ts: -------------------------------------------------------------------------------- 1 | import { 2 | table, 3 | getBorderCharacters, 4 | } from '../../../src'; 5 | import type { 6 | Row, 7 | } from '../../../src/types/internal'; 8 | import { 9 | expectTable, 10 | } from '../../utils'; 11 | 12 | describe('README.md api/getBorderCharacters', () => { 13 | let data: Row[]; 14 | 15 | before(() => { 16 | data = [ 17 | ['0A', '0B', '0C'], 18 | ['1A', '1B', '1C'], 19 | ['2A', '2B', '2C'], 20 | ]; 21 | }); 22 | 23 | it('honeywell', () => { 24 | const output = table(data, { 25 | border: getBorderCharacters('honeywell'), 26 | }); 27 | 28 | expectTable(output, ` 29 | ╔════╤════╤════╗ 30 | ║ 0A │ 0B │ 0C ║ 31 | ╟────┼────┼────╢ 32 | ║ 1A │ 1B │ 1C ║ 33 | ╟────┼────┼────╢ 34 | ║ 2A │ 2B │ 2C ║ 35 | ╚════╧════╧════╝ 36 | `); 37 | }); 38 | 39 | it('norc', () => { 40 | const output = table(data, { 41 | border: getBorderCharacters('norc'), 42 | }); 43 | 44 | expectTable(output, ` 45 | ┌────┬────┬────┐ 46 | │ 0A │ 0B │ 0C │ 47 | ├────┼────┼────┤ 48 | │ 1A │ 1B │ 1C │ 49 | ├────┼────┼────┤ 50 | │ 2A │ 2B │ 2C │ 51 | └────┴────┴────┘ 52 | `); 53 | }); 54 | 55 | it('ramac', () => { 56 | const output = table(data, { 57 | border: getBorderCharacters('ramac'), 58 | }); 59 | 60 | expectTable(output, ` 61 | +----+----+----+ 62 | | 0A | 0B | 0C | 63 | |----|----|----| 64 | | 1A | 1B | 1C | 65 | |----|----|----| 66 | | 2A | 2B | 2C | 67 | +----+----+----+ 68 | `); 69 | }); 70 | 71 | it('void', () => { 72 | const output = table(data, { 73 | border: getBorderCharacters('void'), 74 | }); 75 | 76 | expectTable(String(output).trim() + '\n', '0A 0B 0C \n\n 1A 1B 1C \n\n 2A 2B 2C'); 77 | }); 78 | 79 | it('borderless', () => { 80 | const output = table(data, { 81 | border: getBorderCharacters('void'), 82 | columnDefault: { 83 | paddingLeft: 0, 84 | paddingRight: 1, 85 | }, 86 | drawHorizontalLine: () => { 87 | return false; 88 | }, 89 | }); 90 | 91 | expectTable(String(output).trim() + '\n', '0A 0B 0C \n1A 1B 1C \n2A 2B 2C'); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/alignString.ts: -------------------------------------------------------------------------------- 1 | import stringWidth from 'string-width'; 2 | import type { 3 | Alignment, 4 | } from './types/api'; 5 | import { 6 | countSpaceSequence, distributeUnevenly, 7 | } from './utils'; 8 | 9 | const alignLeft = (subject: string, width: number): string => { 10 | return subject + ' '.repeat(width); 11 | }; 12 | 13 | const alignRight = (subject: string, width: number): string => { 14 | return ' '.repeat(width) + subject; 15 | }; 16 | 17 | const alignCenter = (subject: string, width: number): string => { 18 | return ' '.repeat(Math.floor(width / 2)) + subject + ' '.repeat(Math.ceil(width / 2)); 19 | }; 20 | 21 | const alignJustify = (subject: string, width: number): string => { 22 | const spaceSequenceCount = countSpaceSequence(subject); 23 | 24 | if (spaceSequenceCount === 0) { 25 | return alignLeft(subject, width); 26 | } 27 | 28 | const addingSpaces = distributeUnevenly(width, spaceSequenceCount); 29 | 30 | if (Math.max(...addingSpaces) > 3) { 31 | return alignLeft(subject, width); 32 | } 33 | 34 | let spaceSequenceIndex = 0; 35 | 36 | return subject.replace(/\s+/g, (groupSpace) => { 37 | return groupSpace + ' '.repeat(addingSpaces[spaceSequenceIndex++]); 38 | }); 39 | }; 40 | 41 | /** 42 | * Pads a string to the left and/or right to position the subject 43 | * text in a desired alignment within a container. 44 | */ 45 | export const alignString = (subject: string, containerWidth: number, alignment: Alignment): string => { 46 | const subjectWidth = stringWidth(subject); 47 | 48 | if (subjectWidth === containerWidth) { 49 | return subject; 50 | } 51 | 52 | if (subjectWidth > containerWidth) { 53 | throw new Error('Subject parameter value width cannot be greater than the container width.'); 54 | } 55 | 56 | if (subjectWidth === 0) { 57 | return ' '.repeat(containerWidth); 58 | } 59 | 60 | const availableWidth = containerWidth - subjectWidth; 61 | 62 | if (alignment === 'left') { 63 | return alignLeft(subject, availableWidth); 64 | } 65 | 66 | if (alignment === 'right') { 67 | return alignRight(subject, availableWidth); 68 | } 69 | 70 | if (alignment === 'justify') { 71 | return alignJustify(subject, availableWidth); 72 | } 73 | 74 | return alignCenter(subject, availableWidth); 75 | }; 76 | -------------------------------------------------------------------------------- /test/calculateSpanningCellWidth.ts: -------------------------------------------------------------------------------- 1 | import { 2 | expect, 3 | } from 'chai'; 4 | import { 5 | calculateSpanningCellWidth, 6 | } from '../src/calculateSpanningCellWidth'; 7 | import type { 8 | RangeConfig, 9 | } from '../src/types/internal'; 10 | import { 11 | baseCellConfig, baseColumnConfig, baseSpanningCellContext, 12 | } from './spanningCellFixtures'; 13 | 14 | describe('calculateSpanningCellWidth', () => { 15 | const baseRangeConfig: RangeConfig = { 16 | ...baseCellConfig, 17 | bottomRight: { 18 | col: 1, 19 | row: 0, 20 | }, 21 | topLeft: { 22 | col: 0, 23 | row: 0, 24 | }, 25 | }; 26 | it('base', () => { 27 | const result = calculateSpanningCellWidth(baseRangeConfig, baseSpanningCellContext); 28 | 29 | // = (1 + 15 + 1) + 1 + (1 + 10 + 1) 30 | expect(result).to.equal(30); 31 | }); 32 | 33 | it('colSpan = 1', () => { 34 | const result = calculateSpanningCellWidth({...baseRangeConfig, 35 | bottomRight: { 36 | col: 0, 37 | row: 0, 38 | }}, baseSpanningCellContext); 39 | 40 | // = (1 + 15 + 1) 41 | expect(result).to.equal(17); 42 | }); 43 | 44 | it('colSpan = 3', () => { 45 | const result = calculateSpanningCellWidth({...baseRangeConfig, 46 | bottomRight: { 47 | col: 2, 48 | row: 0, 49 | }}, baseSpanningCellContext); 50 | 51 | // = (1 + 15 + 1) + 1 + (1 + 10 + 1) + 1 + (1 + 20 + 1) 52 | expect(result).to.equal(53); 53 | }); 54 | 55 | it('increase paddings', () => { 56 | const result = calculateSpanningCellWidth(baseRangeConfig, {...baseSpanningCellContext, 57 | columnsConfig: [ 58 | {...baseColumnConfig[0], 59 | paddingLeft: 2, 60 | paddingRight: 3}, 61 | {...baseColumnConfig[1], 62 | paddingRight: 5}, 63 | ...baseColumnConfig.slice(2), 64 | ]}); 65 | 66 | // = (2 + 15 + 3) + 1 + (1 + 10 + 5) 67 | expect(result).to.equal(37); 68 | }); 69 | 70 | it('hidden border', () => { 71 | const result = calculateSpanningCellWidth(baseRangeConfig, {...baseSpanningCellContext, 72 | drawVerticalLine: (index) => { 73 | return index !== 1; 74 | }}); 75 | 76 | // = (1 + 15 + 1) + 0 + (1 + 10 + 1) 77 | expect(result).to.equal(29); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/padTableData.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-nested-callbacks */ 2 | 3 | import { 4 | expect, 5 | } from 'chai'; 6 | import { 7 | makeTableConfig, 8 | } from '../src/makeTableConfig'; 9 | import { 10 | padTableData, 11 | } from '../src/padTableData'; 12 | 13 | describe('padTableData', () => { 14 | context('when no given userConfig', () => { 15 | it('inserts 01 whitespace character regardless of string whitespaces', () => { 16 | const rows = [[' a ']]; 17 | 18 | expect(padTableData(rows, makeTableConfig(rows, undefined))).to.deep.equal([[' a ']]); 19 | }); 20 | }); 21 | 22 | context('when given paddings in columnDefault', () => { 23 | context('when no given column-specific paddings', () => { 24 | it('uses the columnDefault values', () => { 25 | const rows = [['a']]; 26 | 27 | expect(padTableData(rows, makeTableConfig(rows, {columnDefault: { 28 | paddingLeft: 2, 29 | paddingRight: 3, 30 | }}))).to.deep.equal([[' a ']]); 31 | }); 32 | }); 33 | 34 | context('when given column-specific padding values', () => { 35 | it('uses column-specific padding values', () => { 36 | const rows = [['a']]; 37 | 38 | expect(padTableData(rows, makeTableConfig(rows, { 39 | columnDefault: { 40 | paddingLeft: 2, 41 | paddingRight: 3, 42 | }, 43 | columns: { 44 | 0: { 45 | paddingLeft: 4, 46 | paddingRight: 5, 47 | }, 48 | }, 49 | }))).to.deep.equal([[' a ']]); 50 | }); 51 | }); 52 | }); 53 | 54 | context('when given multiple rows and columns', () => { 55 | it('uses corresponding column-specific padding values or fallback to the default padding values', () => { 56 | const rows = [['a', 'b'], ['c', 'd']]; 57 | 58 | expect(padTableData(rows, makeTableConfig(rows, { 59 | columnDefault: { 60 | paddingLeft: 2, 61 | paddingRight: 3, 62 | }, 63 | columns: { 64 | 0: { 65 | paddingLeft: 4, 66 | }, 67 | 1: { 68 | paddingRight: 5, 69 | }, 70 | }, 71 | }))).to.deep.equal([[' a ', ' b '], [' c ', ' d ']]); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/validateSpanningCellConfig.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SpanningCellConfig, 3 | } from './types/api'; 4 | import type { 5 | Row, 6 | } from './types/internal'; 7 | import { 8 | calculateRangeCoordinate, 9 | sequence, 10 | } from './utils'; 11 | 12 | const inRange = (start: number, end: number, value: number) => { 13 | return start <= value && value <= end; 14 | }; 15 | 16 | export const validateSpanningCellConfig = (rows: Row[], configs: SpanningCellConfig[]): void => { 17 | const [nRow, nCol] = [rows.length, rows[0].length]; 18 | 19 | configs.forEach((config, configIndex) => { 20 | const {colSpan, rowSpan} = config; 21 | if (colSpan === undefined && rowSpan === undefined) { 22 | throw new Error(`Expect at least colSpan or rowSpan is provided in config.spanningCells[${configIndex}]`); 23 | } 24 | if (colSpan !== undefined && colSpan < 1) { 25 | throw new Error(`Expect colSpan is not equal zero, instead got: ${colSpan} in config.spanningCells[${configIndex}]`); 26 | } 27 | if (rowSpan !== undefined && rowSpan < 1) { 28 | throw new Error(`Expect rowSpan is not equal zero, instead got: ${rowSpan} in config.spanningCells[${configIndex}]`); 29 | } 30 | }); 31 | 32 | const rangeCoordinates = configs.map(calculateRangeCoordinate); 33 | 34 | rangeCoordinates.forEach(({topLeft, bottomRight}, rangeIndex) => { 35 | if (!inRange(0, nCol - 1, topLeft.col) || 36 | !inRange(0, nRow - 1, topLeft.row) || 37 | !inRange(0, nCol - 1, bottomRight.col) || 38 | !inRange(0, nRow - 1, bottomRight.row)) { 39 | throw new Error(`Some cells in config.spanningCells[${rangeIndex}] are out of the table`); 40 | } 41 | }); 42 | 43 | const configOccupy = Array.from({length: nRow}, () => { 44 | return Array.from({length: nCol}); 45 | }); 46 | 47 | rangeCoordinates.forEach(({topLeft, bottomRight}, rangeIndex) => { 48 | sequence(topLeft.row, bottomRight.row).forEach((row) => { 49 | sequence(topLeft.col, bottomRight.col).forEach((col) => { 50 | if (configOccupy[row][col] !== undefined) { 51 | throw new Error(`Spanning cells in config.spanningCells[${configOccupy[row][col]}] and config.spanningCells[${rangeIndex}] are overlap each other`); 52 | } 53 | configOccupy[row][col] = rangeIndex; 54 | }); 55 | }); 56 | }); 57 | }; 58 | 59 | -------------------------------------------------------------------------------- /test/truncateTableData.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-nested-callbacks */ 2 | 3 | import { 4 | expect, 5 | } from 'chai'; 6 | import { 7 | truncateTableData, 8 | } from '../src/truncateTableData'; 9 | 10 | describe('truncateTableData', () => { 11 | context('when no given userConfig', () => { 12 | it('not truncate at all', () => { 13 | const rows = [['a'.repeat(100)]]; 14 | 15 | expect(truncateTableData(rows, [Number.POSITIVE_INFINITY])).to.deep.equal([['a'.repeat(100)]]); 16 | }); 17 | }); 18 | 19 | context('when given truncate value in columnDefault', () => { 20 | context('when no given column-specific truncate', () => { 21 | it('uses the columnDefault value', () => { 22 | const rows = [['a'.repeat(100)]]; 23 | 24 | expect(truncateTableData(rows, [20])).to.deep.equal([['a'.repeat(19) + '…']]); 25 | }); 26 | }); 27 | }); 28 | 29 | context('when given multiple rows and columns', () => { 30 | it('uses corresponding column-specific truncate values or fallback to the default truncate value', () => { 31 | const rows = [['a'.repeat(100), 'b'.repeat(100)], ['c'.repeat(100), 'd'.repeat(100)]]; 32 | 33 | expect(truncateTableData(rows, [30, 20])).to.deep.equal([ 34 | ['a'.repeat(29) + '…', 'b'.repeat(19) + '…'], 35 | ['c'.repeat(29) + '…', 'd'.repeat(19) + '…']]); 36 | }); 37 | }); 38 | 39 | context('edge cases', () => { 40 | context('truncate = 0', () => { 41 | it('returns ellipsis only', () => { 42 | const rows = [['a'.repeat(100)]]; 43 | expect(truncateTableData(rows, [0])).to.deep.equal([['…']]); 44 | }); 45 | }); 46 | 47 | context('truncate = 1', () => { 48 | it('returns ellipsis only', () => { 49 | const rows = [['a'.repeat(100)]]; 50 | expect(truncateTableData(rows, [1])).to.deep.equal([['…']]); 51 | }); 52 | }); 53 | 54 | context('truncate = 2', () => { 55 | it('returns 2-length string with ellipsis', () => { 56 | const rows = [['a'.repeat(100)]]; 57 | expect(truncateTableData(rows, [2])).to.deep.equal([['a…']]); 58 | }); 59 | }); 60 | 61 | context('empty string', () => { 62 | it('returns empty string', () => { 63 | const rows = [['']]; 64 | expect(truncateTableData(rows, [100])).to.deep.equal([['']]); 65 | }); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/mapDataUsingRowHeights.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | VerticalAlignment, 3 | } from './types/api'; 4 | import type { 5 | BaseConfig, 6 | Row, 7 | } from './types/internal'; 8 | import { 9 | flatten, 10 | } from './utils'; 11 | import { 12 | wrapCell, 13 | } from './wrapCell'; 14 | 15 | const createEmptyStrings = (length: number) => { 16 | return new Array(length).fill(''); 17 | }; 18 | 19 | export const padCellVertically = (lines: string[], rowHeight: number, verticalAlignment: VerticalAlignment): string[] => { 20 | const availableLines = rowHeight - lines.length; 21 | 22 | if (verticalAlignment === 'top') { 23 | return [...lines, ...createEmptyStrings(availableLines)]; 24 | } 25 | 26 | if (verticalAlignment === 'bottom') { 27 | return [...createEmptyStrings(availableLines), ...lines]; 28 | } 29 | 30 | return [ 31 | ...createEmptyStrings(Math.floor(availableLines / 2)), 32 | ...lines, 33 | ...createEmptyStrings(Math.ceil(availableLines / 2)), 34 | ]; 35 | }; 36 | 37 | export const mapDataUsingRowHeights = (unmappedRows: Row[], rowHeights: number[], config: BaseConfig): Row[] => { 38 | const nColumns = unmappedRows[0].length; 39 | 40 | const mappedRows = unmappedRows.map((unmappedRow, unmappedRowIndex) => { 41 | const outputRowHeight = rowHeights[unmappedRowIndex]; 42 | const outputRow: Row[] = Array.from({length: outputRowHeight}, () => { 43 | return new Array(nColumns).fill(''); 44 | }); 45 | 46 | unmappedRow.forEach((cell, cellIndex) => { 47 | const containingRange = config.spanningCellManager?.getContainingRange({col: cellIndex, 48 | row: unmappedRowIndex}); 49 | if (containingRange) { 50 | containingRange.extractCellContent(unmappedRowIndex).forEach((cellLine, cellLineIndex) => { 51 | outputRow[cellLineIndex][cellIndex] = cellLine; 52 | }); 53 | 54 | return; 55 | } 56 | const cellLines = wrapCell(cell, config.columns[cellIndex].width, config.columns[cellIndex].wrapWord); 57 | 58 | const paddedCellLines = padCellVertically(cellLines, outputRowHeight, config.columns[cellIndex].verticalAlignment); 59 | 60 | paddedCellLines.forEach((cellLine, cellLineIndex) => { 61 | outputRow[cellLineIndex][cellIndex] = cellLine; 62 | }); 63 | }); 64 | 65 | return outputRow; 66 | }); 67 | 68 | return flatten(mappedRows); 69 | }; 70 | 71 | -------------------------------------------------------------------------------- /src/alignSpanningCell.ts: -------------------------------------------------------------------------------- 1 | import stringWidth from 'string-width'; 2 | import { 3 | alignString, 4 | } from './alignString'; 5 | import { 6 | padCellVertically, 7 | } from './mapDataUsingRowHeights'; 8 | import { 9 | padString, 10 | } from './padTableData'; 11 | import type { 12 | SpanningCellContext, 13 | } from './spanningCellManager'; 14 | import { 15 | truncateString, 16 | } from './truncateTableData'; 17 | import type { 18 | RangeConfig, 19 | } from './types/internal'; 20 | import { 21 | sequence, sumArray, 22 | } from './utils'; 23 | import { 24 | wrapCell, 25 | } from './wrapCell'; 26 | 27 | /** 28 | * Fill content into all cells in range in order to calculate total height 29 | */ 30 | export const wrapRangeContent = (rangeConfig: RangeConfig, rangeWidth: number, context: SpanningCellContext): string[] => { 31 | const {topLeft, paddingRight, paddingLeft, truncate, wrapWord, alignment} = rangeConfig; 32 | 33 | const originalContent = context.rows[topLeft.row][topLeft.col]; 34 | const contentWidth = rangeWidth - paddingLeft - paddingRight; 35 | 36 | return wrapCell(truncateString(originalContent, truncate), contentWidth, wrapWord).map((line) => { 37 | const alignedLine = alignString(line, contentWidth, alignment); 38 | 39 | return padString(alignedLine, paddingLeft, paddingRight); 40 | }); 41 | }; 42 | 43 | export const alignVerticalRangeContent = (range: RangeConfig, content: string[], context: SpanningCellContext) => { 44 | const {rows, drawHorizontalLine, rowHeights} = context; 45 | const {topLeft, bottomRight, verticalAlignment} = range; 46 | 47 | // They are empty before calculateRowHeights function run 48 | if (rowHeights.length === 0) { 49 | return []; 50 | } 51 | 52 | const totalCellHeight = sumArray(rowHeights.slice(topLeft.row, bottomRight.row + 1)); 53 | const totalBorderHeight = bottomRight.row - topLeft.row; 54 | const hiddenHorizontalBorderCount = sequence(topLeft.row + 1, bottomRight.row).filter((horizontalBorderIndex) => { 55 | return !drawHorizontalLine(horizontalBorderIndex, rows.length); 56 | }).length; 57 | 58 | const availableRangeHeight = totalCellHeight + totalBorderHeight - hiddenHorizontalBorderCount; 59 | 60 | return padCellVertically(content, availableRangeHeight, verticalAlignment).map((line) => { 61 | if (line.length === 0) { 62 | return ' '.repeat(stringWidth(content[0])); 63 | } 64 | 65 | return line; 66 | }); 67 | }; 68 | -------------------------------------------------------------------------------- /src/types/internal.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SpanningCellManager, 3 | } from '../spanningCellManager'; 4 | import type { 5 | BorderConfig, 6 | ColumnUserConfig, 7 | DrawHorizontalLine, 8 | DrawVerticalLine, 9 | StreamUserConfig, 10 | TableUserConfig, 11 | CellUserConfig, 12 | } from './api'; 13 | 14 | /** @internal */ 15 | export type Cell = string; 16 | 17 | /** @internal */ 18 | export type Row = Cell[]; 19 | 20 | /** @internal */ 21 | export type TopBorderConfig = Pick; 22 | 23 | /** @internal */ 24 | export type BottomBorderConfig = Pick; 25 | 26 | /** @internal */ 27 | export type BodyBorderConfig = Pick; 28 | 29 | /** @internal */ 30 | export type JoinBorderConfig = Pick; 31 | 32 | /** @internal */ 33 | export type ColumnConfig = Required; 34 | 35 | /** @internal */ 36 | export type TableConfig = Required> & { 37 | readonly border: BorderConfig, 38 | readonly columns: ColumnConfig[], 39 | readonly spanningCellManager: SpanningCellManager, 40 | }; 41 | 42 | /** @internal */ 43 | export type StreamConfig = Required> & { 44 | readonly border: BorderConfig, 45 | readonly columns: ColumnConfig[], 46 | }; 47 | 48 | /** @internal */ 49 | export type BaseConfig = { 50 | readonly border: BorderConfig, 51 | readonly columns: ColumnConfig[], 52 | readonly drawVerticalLine: DrawVerticalLine, 53 | readonly drawHorizontalLine?: DrawHorizontalLine, 54 | readonly spanningCellManager?: SpanningCellManager, 55 | }; 56 | 57 | /** @internal */ 58 | export type SeparatorGetter = (index: number, size: number) => string; 59 | 60 | /** @internal */ 61 | export type CellCoordinates = { 62 | row: number, 63 | col: number, 64 | }; 65 | 66 | /** @internal */ 67 | export type RangeCoordinate = { 68 | topLeft: CellCoordinates, 69 | bottomRight: CellCoordinates, 70 | }; 71 | 72 | /** @internal */ 73 | export type RangeConfig = RangeCoordinate & Required; 74 | 75 | /** @internal */ 76 | export type ResolvedRangeConfig = RangeConfig & { 77 | height: number, 78 | width: number, 79 | extractCellContent: (rowIndex: number) => string[], 80 | extractBorderContent: (borderIndex: number) => string, 81 | }; 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": { 3 | "email": "gajus@gajus.com", 4 | "name": "Gajus Kuizinas", 5 | "url": "http://gajus.com" 6 | }, 7 | "dependencies": { 8 | "ajv": "^8.0.1", 9 | "lodash.truncate": "^4.4.2", 10 | "slice-ansi": "^4.0.0", 11 | "string-width": "^4.2.3", 12 | "strip-ansi": "^6.0.1" 13 | }, 14 | "description": "Formats data into a string table.", 15 | "devDependencies": { 16 | "@types/chai": "^4.2.16", 17 | "@types/lodash.mapvalues": "^4.6.6", 18 | "@types/lodash.truncate": "^4.4.6", 19 | "@types/mocha": "^9.0.0", 20 | "@types/node": "^14.14.37", 21 | "@types/sinon": "^10.0.0", 22 | "@types/slice-ansi": "^4.0.0", 23 | "ajv-cli": "^5.0.0", 24 | "ajv-keywords": "^5.0.0", 25 | "chai": "^4.2.0", 26 | "chalk": "^4.1.0", 27 | "coveralls": "^3.1.0", 28 | "eslint": "^7.32.0", 29 | "eslint-config-canonical": "^25.0.0", 30 | "gitdown": "^3.1.4", 31 | "husky": "^4.3.6", 32 | "js-beautify": "^1.14.0", 33 | "lodash.mapvalues": "^4.6.0", 34 | "mkdirp": "^1.0.4", 35 | "mocha": "^8.2.1", 36 | "nyc": "^15.1.0", 37 | "semantic-release": "^17.3.1", 38 | "sinon": "^12.0.1", 39 | "ts-node": "^9.1.1", 40 | "typescript": "4.5.2" 41 | }, 42 | "engines": { 43 | "node": ">=10.0.0" 44 | }, 45 | "husky": { 46 | "hooks": { 47 | "post-commit": "npm run create-readme && git add README.md && git commit -m 'docs: generate docs' --no-verify", 48 | "pre-commit": "npm run build && npm run lint && npm run test" 49 | } 50 | }, 51 | "keywords": [ 52 | "ascii", 53 | "text", 54 | "table", 55 | "align", 56 | "ansi" 57 | ], 58 | "license": "BSD-3-Clause", 59 | "main": "./dist/src/index.js", 60 | "module": "./dist/src/index.js", 61 | "files": [ 62 | "dist/src/" 63 | ], 64 | "name": "table", 65 | "repository": { 66 | "type": "git", 67 | "url": "https://github.com/gajus/table" 68 | }, 69 | "scripts": { 70 | "prebuild": "rm -fr ./src/generated && mkdirp ./src/generated", 71 | "build": "npm run create-validators && tsc", 72 | "create-readme": "gitdown ./.README/README.md --output-file ./README.md", 73 | "create-validators": "ajv compile --all-errors --inline-refs=false -s src/schemas/config -s src/schemas/streamConfig -r src/schemas/shared -c ajv-keywords/dist/keywords/typeof -o | js-beautify > ./src/generated/validators.js", 74 | "lint": "eslint ./src ./test", 75 | "test": "nyc mocha && nyc check-coverage --lines 95" 76 | }, 77 | "sideEffects": false, 78 | "version": "1.0.0" 79 | } 80 | -------------------------------------------------------------------------------- /test/README/demo.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import mapValues from 'lodash.mapvalues'; 3 | import { 4 | getBorderCharacters, table, 5 | } from '../../src'; 6 | 7 | describe('README.md demo', () => { 8 | it('moon_mission', () => { 9 | const data = [ 10 | [ 11 | chalk.bold('Spacecraft'), 12 | chalk.bold('Launch Date'), 13 | chalk.bold('Operator'), 14 | chalk.bold('Outcome'), 15 | chalk.bold('Remarks'), 16 | ], 17 | [ 18 | 'Able I', 19 | '17 August 1958', 20 | 'USAF', 21 | chalk.black.bgRed('Launch failure'), 22 | 'First attempted launch beyond Earth orbit; failed to orbit due to turbopump gearbox malfunction resulting in first stage explosion.[3] Reached apogee of 16 kilometres (9.9 mi)', 23 | ], 24 | [ 25 | 'Luna 2', 26 | '12 September 1959', 27 | 'OKB-1', 28 | chalk.black.bgGreen('Successful'), 29 | 'Successful impact at 21:02 on 14 September 1959. First spacecraft to reach lunar surface', 30 | ], 31 | [ 32 | 'Lunar Orbiter 1', 33 | '10 August 1966', 34 | 'NASA', 35 | chalk.black.bgYellow('Partial failure'), 36 | 'Orbital insertion at around 15:36 UTC on 14 August. Deorbited early due to lack of fuel and to avoid communications interference with the next mission, impacted the Moon at 13:30 UTC on 29 October 1966.', 37 | ], 38 | [ 39 | 'Apollo 8', 40 | '21 December 1968', 41 | 'NASA', 42 | chalk.black.bgGreen('Successful'), 43 | 'First manned mission to the Moon; entered orbit around the Moon with four-minute burn beginning at 09:59:52 UTC on 24 December. Completed ten orbits of the Moon before returning to Earth with an engine burn at 06:10:16 UTC on 25 December. Landed in the Pacific Ocean at 15:51 UTC on 27 December.', 44 | ], 45 | [ 46 | 'Apollo 11', 47 | '16 July 1969', 48 | 'NASA', 49 | chalk.black.bgGreen('Successful'), 50 | 'First manned landing on the Moon. LM landed at 20:17 UTC on 20 July 1969', 51 | ], 52 | ]; 53 | 54 | const tableBorder = mapValues(getBorderCharacters('honeywell'), (char) => { 55 | return chalk.gray(char); 56 | }); 57 | 58 | table(data, { 59 | border: tableBorder, 60 | columns: { 61 | 4: { 62 | alignment: 'justify', 63 | width: 50, 64 | wrapWord: true, 65 | }, 66 | }, 67 | header: {content: chalk.bold.blue('List of missions to the Moon')}, 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /.README/README.md: -------------------------------------------------------------------------------- 1 | # Table 2 | 3 | > Produces a string that represents array data in a text table. 4 | 5 | [![Github action status](https://github.com/gajus/table/actions/workflows/main.yml/badge.svg)](https://github.com/gajus/table/actions) 6 | [![Coveralls](https://img.shields.io/coveralls/gajus/table.svg?style=flat-square)](https://coveralls.io/github/gajus/table) 7 | [![NPM version](http://img.shields.io/npm/v/table.svg?style=flat-square)](https://www.npmjs.org/package/table) 8 | [![Canonical Code Style](https://img.shields.io/badge/code%20style-canonical-blue.svg?style=flat-square)](https://github.com/gajus/canonical) 9 | [![Twitter Follow](https://img.shields.io/twitter/follow/kuizinas.svg?style=social&label=Follow)](https://twitter.com/kuizinas) 10 | 11 | {"gitdown": "contents"} 12 | 13 | ![Demo of table displaying a list of missions to the Moon.](./.README/demo.png) 14 | 15 | ## Features 16 | 17 | * Works with strings containing [fullwidth](https://en.wikipedia.org/wiki/Halfwidth_and_fullwidth_forms) characters. 18 | * Works with strings containing [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code). 19 | * Configurable border characters. 20 | * Configurable content alignment per column. 21 | * Configurable content padding per column. 22 | * Configurable column width. 23 | * Text wrapping. 24 | 25 | {"gitdown": "include", "file": "./install.md"} 26 | 27 | {"gitdown": "include", "file": "./usage.md"} 28 | 29 | ## API 30 | 31 | {"gitdown": "include", "file": "./api/table/index.md"} 32 | {"gitdown": "include", "file": "./api/table/border.md"} 33 | {"gitdown": "include", "file": "./api/table/drawVerticalLine.md"} 34 | {"gitdown": "include", "file": "./api/table/drawHorizontalLine.md"} 35 | {"gitdown": "include", "file": "./api/table/singleLine.md"} 36 | 37 | {"gitdown": "include", "file": "./api/table/columns/index.md"} 38 | {"gitdown": "include", "file": "./api/table/columns/width.md"} 39 | {"gitdown": "include", "file": "./api/table/columns/alignment.md"} 40 | {"gitdown": "include", "file": "./api/table/columns/verticalAlignment.md"} 41 | {"gitdown": "include", "file": "./api/table/columns/padding.md"} 42 | {"gitdown": "include", "file": "./api/table/columns/truncate.md"} 43 | {"gitdown": "include", "file": "./api/table/columns/wrapWord.md"} 44 | 45 | {"gitdown": "include", "file": "./api/table/columnDefault.md"} 46 | 47 | {"gitdown": "include", "file": "./api/table/header.md"} 48 | 49 | {"gitdown": "include", "file": "./api/table/spanningCells.md"} 50 | 51 | {"gitdown": "include", "file": "./api/stream/index.md"} 52 | 53 | {"gitdown": "include", "file": "./api/getBorderCharacters.md"} 54 | -------------------------------------------------------------------------------- /src/drawContent.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SpanningCellManager, 3 | } from './spanningCellManager'; 4 | import type { 5 | CellCoordinates, 6 | } from './types/internal'; 7 | 8 | /** 9 | * Shared function to draw horizontal borders, rows or the entire table 10 | */ 11 | 12 | type DrawContentParameters = { 13 | contents: string[], 14 | drawSeparator: (index: number, size: number) => boolean, 15 | separatorGetter: (index: number, size: number) => string, 16 | spanningCellManager?: SpanningCellManager, 17 | rowIndex?: number, 18 | elementType?: 'border' | 'cell' | 'row', }; 19 | 20 | export const drawContent = (parameters: DrawContentParameters): string => { 21 | const {contents, separatorGetter, drawSeparator, spanningCellManager, rowIndex, elementType} = parameters; 22 | const contentSize = contents.length; 23 | const result: string[] = []; 24 | 25 | if (drawSeparator(0, contentSize)) { 26 | result.push(separatorGetter(0, contentSize)); 27 | } 28 | 29 | contents.forEach((content, contentIndex) => { 30 | if (!elementType || elementType === 'border' || elementType === 'row') { 31 | result.push(content); 32 | } 33 | 34 | if (elementType === 'cell' && rowIndex === undefined) { 35 | result.push(content); 36 | } 37 | 38 | if (elementType === 'cell' && rowIndex !== undefined) { 39 | /* istanbul ignore next */ 40 | const containingRange = spanningCellManager?.getContainingRange({col: contentIndex, 41 | row: rowIndex}); 42 | 43 | // when drawing content row, just add a cell when it is a normal cell 44 | // or belongs to first column of spanning cell 45 | if (!containingRange || contentIndex === containingRange.topLeft.col) { 46 | result.push(content); 47 | } 48 | } 49 | 50 | // Only append the middle separator if the content is not the last 51 | if (contentIndex + 1 < contentSize && drawSeparator(contentIndex + 1, contentSize)) { 52 | const separator = separatorGetter(contentIndex + 1, contentSize); 53 | 54 | if (elementType === 'cell' && rowIndex !== undefined) { 55 | const currentCell: CellCoordinates = {col: contentIndex + 1, 56 | row: rowIndex}; 57 | /* istanbul ignore next */ 58 | const containingRange = spanningCellManager?.getContainingRange(currentCell); 59 | if (!containingRange || containingRange.topLeft.col === currentCell.col) { 60 | result.push(separator); 61 | } 62 | } else { 63 | result.push(separator); 64 | } 65 | } 66 | }); 67 | 68 | if (drawSeparator(contentSize, contentSize)) { 69 | result.push(separatorGetter(contentSize, contentSize)); 70 | } 71 | 72 | return result.join(''); 73 | }; 74 | -------------------------------------------------------------------------------- /src/makeTableConfig.ts: -------------------------------------------------------------------------------- 1 | import { 2 | calculateMaximumColumnWidths, 3 | } from './calculateMaximumColumnWidths'; 4 | import { 5 | createSpanningCellManager, 6 | } from './spanningCellManager'; 7 | import type { 8 | ColumnUserConfig, Indexable, 9 | SpanningCellConfig, 10 | TableUserConfig, 11 | } from './types/api'; 12 | import type { 13 | ColumnConfig, Row, TableConfig, 14 | } from './types/internal'; 15 | import { 16 | makeBorderConfig, 17 | } from './utils'; 18 | import { 19 | validateConfig, 20 | } from './validateConfig'; 21 | import { 22 | validateSpanningCellConfig, 23 | } from './validateSpanningCellConfig'; 24 | 25 | /** 26 | * Creates a configuration for every column using default 27 | * values for the missing configuration properties. 28 | */ 29 | const makeColumnsConfig = (rows: Row[], 30 | columns?: Indexable, 31 | columnDefault?: ColumnUserConfig, 32 | spanningCellConfigs?: SpanningCellConfig[]): ColumnConfig[] => { 33 | const columnWidths = calculateMaximumColumnWidths(rows, spanningCellConfigs); 34 | 35 | return rows[0].map((_, columnIndex) => { 36 | return { 37 | alignment: 'left', 38 | paddingLeft: 1, 39 | paddingRight: 1, 40 | truncate: Number.POSITIVE_INFINITY, 41 | verticalAlignment: 'top', 42 | width: columnWidths[columnIndex], 43 | wrapWord: false, 44 | ...columnDefault, 45 | ...columns?.[columnIndex], 46 | }; 47 | }); 48 | }; 49 | 50 | /** 51 | * Makes a new configuration object out of the userConfig object 52 | * using default values for the missing configuration properties. 53 | */ 54 | 55 | export const makeTableConfig = (rows: Row[], config: TableUserConfig = {}, injectedSpanningCellConfig?: SpanningCellConfig[]): TableConfig => { 56 | validateConfig('config.json', config); 57 | validateSpanningCellConfig(rows, config.spanningCells ?? []); 58 | 59 | const spanningCellConfigs = injectedSpanningCellConfig ?? config.spanningCells ?? []; 60 | 61 | const columnsConfig = makeColumnsConfig(rows, config.columns, config.columnDefault, spanningCellConfigs); 62 | 63 | const drawVerticalLine = config.drawVerticalLine ?? (() => { 64 | return true; 65 | }); 66 | const drawHorizontalLine = config.drawHorizontalLine ?? (() => { 67 | return true; 68 | }); 69 | 70 | return { 71 | ...config, 72 | border: makeBorderConfig(config.border), 73 | columns: columnsConfig, 74 | drawHorizontalLine, 75 | drawVerticalLine, 76 | singleLine: config.singleLine ?? false, 77 | spanningCellManager: createSpanningCellManager({ 78 | columnsConfig, 79 | drawHorizontalLine, 80 | drawVerticalLine, 81 | rows, 82 | spanningCellConfigs, 83 | }), 84 | }; 85 | }; 86 | -------------------------------------------------------------------------------- /src/getBorderCharacters.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys-fix/sort-keys-fix */ 2 | 3 | import type { 4 | BorderConfig, 5 | } from './types/api'; 6 | 7 | export const getBorderCharacters = (name: string): BorderConfig => { 8 | if (name === 'honeywell') { 9 | return { 10 | topBody: '═', 11 | topJoin: '╤', 12 | topLeft: '╔', 13 | topRight: '╗', 14 | 15 | bottomBody: '═', 16 | bottomJoin: '╧', 17 | bottomLeft: '╚', 18 | bottomRight: '╝', 19 | 20 | bodyLeft: '║', 21 | bodyRight: '║', 22 | bodyJoin: '│', 23 | headerJoin: '┬', 24 | 25 | joinBody: '─', 26 | joinLeft: '╟', 27 | joinRight: '╢', 28 | joinJoin: '┼', 29 | joinMiddleDown: '┬', 30 | joinMiddleUp: '┴', 31 | joinMiddleLeft: '┤', 32 | joinMiddleRight: '├', 33 | }; 34 | } 35 | 36 | if (name === 'norc') { 37 | return { 38 | topBody: '─', 39 | topJoin: '┬', 40 | topLeft: '┌', 41 | topRight: '┐', 42 | 43 | bottomBody: '─', 44 | bottomJoin: '┴', 45 | bottomLeft: '└', 46 | bottomRight: '┘', 47 | 48 | bodyLeft: '│', 49 | bodyRight: '│', 50 | bodyJoin: '│', 51 | headerJoin: '┬', 52 | 53 | joinBody: '─', 54 | joinLeft: '├', 55 | joinRight: '┤', 56 | joinJoin: '┼', 57 | joinMiddleDown: '┬', 58 | joinMiddleUp: '┴', 59 | joinMiddleLeft: '┤', 60 | joinMiddleRight: '├', 61 | }; 62 | } 63 | 64 | if (name === 'ramac') { 65 | return { 66 | topBody: '-', 67 | topJoin: '+', 68 | topLeft: '+', 69 | topRight: '+', 70 | 71 | bottomBody: '-', 72 | bottomJoin: '+', 73 | bottomLeft: '+', 74 | bottomRight: '+', 75 | 76 | bodyLeft: '|', 77 | bodyRight: '|', 78 | bodyJoin: '|', 79 | headerJoin: '+', 80 | 81 | joinBody: '-', 82 | joinLeft: '|', 83 | joinRight: '|', 84 | joinJoin: '|', 85 | joinMiddleDown: '+', 86 | joinMiddleUp: '+', 87 | joinMiddleLeft: '+', 88 | joinMiddleRight: '+', 89 | }; 90 | } 91 | 92 | if (name === 'void') { 93 | return { 94 | topBody: '', 95 | topJoin: '', 96 | topLeft: '', 97 | topRight: '', 98 | 99 | bottomBody: '', 100 | bottomJoin: '', 101 | bottomLeft: '', 102 | bottomRight: '', 103 | 104 | bodyLeft: '', 105 | bodyRight: '', 106 | bodyJoin: '', 107 | headerJoin: '', 108 | 109 | joinBody: '', 110 | joinLeft: '', 111 | joinRight: '', 112 | joinJoin: '', 113 | joinMiddleDown: '', 114 | joinMiddleUp: '', 115 | joinMiddleLeft: '', 116 | joinMiddleRight: '', 117 | }; 118 | } 119 | 120 | throw new Error('Unknown border template "' + name + '".'); 121 | }; 122 | -------------------------------------------------------------------------------- /src/schemas/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "config.json", 3 | "$schema": "http://json-schema.org/draft-07/schema#", 4 | "type": "object", 5 | "properties": { 6 | "border": { 7 | "$ref": "shared.json#/definitions/borders" 8 | }, 9 | "header": { 10 | "type": "object", 11 | "properties": { 12 | "content": { 13 | "type": "string" 14 | }, 15 | "alignment": { 16 | "$ref": "shared.json#/definitions/alignment" 17 | }, 18 | "wrapWord": { 19 | "type": "boolean" 20 | }, 21 | "truncate": { 22 | "type": "integer" 23 | }, 24 | "paddingLeft": { 25 | "type": "integer" 26 | }, 27 | "paddingRight": { 28 | "type": "integer" 29 | } 30 | }, 31 | "required": ["content"], 32 | "additionalProperties": false 33 | }, 34 | "columns": { 35 | "$ref": "shared.json#/definitions/columns" 36 | }, 37 | "columnDefault": { 38 | "$ref": "shared.json#/definitions/column" 39 | }, 40 | "drawVerticalLine": { 41 | "typeof": "function" 42 | }, 43 | "drawHorizontalLine": { 44 | "typeof": "function" 45 | }, 46 | "singleLine": { 47 | "typeof": "boolean" 48 | }, 49 | "spanningCells": { 50 | "type": "array", 51 | "items": { 52 | "type": "object", 53 | "properties": { 54 | "col": { 55 | "type": "integer", 56 | "minimum": 0 57 | }, 58 | "row": { 59 | "type": "integer", 60 | "minimum": 0 61 | }, 62 | "colSpan": { 63 | "type": "integer", 64 | "minimum": 1 65 | }, 66 | "rowSpan": { 67 | "type": "integer", 68 | "minimum": 1 69 | }, 70 | "alignment": { 71 | "$ref": "shared.json#/definitions/alignment" 72 | }, 73 | "verticalAlignment": { 74 | "$ref": "shared.json#/definitions/verticalAlignment" 75 | }, 76 | "wrapWord": { 77 | "type": "boolean" 78 | }, 79 | "truncate": { 80 | "type": "integer" 81 | }, 82 | "paddingLeft": { 83 | "type": "integer" 84 | }, 85 | "paddingRight": { 86 | "type": "integer" 87 | } 88 | }, 89 | "required": ["row", "col"], 90 | "additionalProperties": false 91 | } 92 | } 93 | }, 94 | "additionalProperties": false 95 | } 96 | -------------------------------------------------------------------------------- /src/createStream.ts: -------------------------------------------------------------------------------- 1 | import { 2 | alignTableData, 3 | } from './alignTableData'; 4 | import { 5 | calculateRowHeights, 6 | } from './calculateRowHeights'; 7 | import { 8 | drawBorderBottom, 9 | drawBorderJoin, 10 | drawBorderTop, 11 | } from './drawBorder'; 12 | import { 13 | drawRow, 14 | } from './drawRow'; 15 | import { 16 | makeStreamConfig, 17 | } from './makeStreamConfig'; 18 | import { 19 | mapDataUsingRowHeights, 20 | } from './mapDataUsingRowHeights'; 21 | import { 22 | padTableData, 23 | } from './padTableData'; 24 | import { 25 | stringifyTableData, 26 | } from './stringifyTableData'; 27 | import { 28 | truncateTableData, 29 | } from './truncateTableData'; 30 | import type { 31 | StreamUserConfig, 32 | WritableStream, 33 | } from './types/api'; 34 | import type { 35 | Row, StreamConfig, 36 | } from './types/internal'; 37 | import { 38 | extractTruncates, 39 | } from './utils'; 40 | 41 | const prepareData = (data: Row[], config: StreamConfig) => { 42 | let rows = stringifyTableData(data); 43 | 44 | rows = truncateTableData(rows, extractTruncates(config)); 45 | 46 | const rowHeights = calculateRowHeights(rows, config); 47 | 48 | rows = mapDataUsingRowHeights(rows, rowHeights, config); 49 | rows = alignTableData(rows, config); 50 | rows = padTableData(rows, config); 51 | 52 | return rows; 53 | }; 54 | 55 | const create = (row: Row, columnWidths: number[], config: StreamConfig) => { 56 | const rows = prepareData([row], config); 57 | 58 | const body = rows.map((literalRow) => { 59 | return drawRow(literalRow, config); 60 | }).join(''); 61 | 62 | let output; 63 | 64 | output = ''; 65 | 66 | output += drawBorderTop(columnWidths, config); 67 | output += body; 68 | output += drawBorderBottom(columnWidths, config); 69 | 70 | output = output.trimEnd(); 71 | 72 | process.stdout.write(output); 73 | }; 74 | 75 | const append = (row: Row, columnWidths: number[], config: StreamConfig) => { 76 | const rows = prepareData([row], config); 77 | 78 | const body = rows.map((literalRow) => { 79 | return drawRow(literalRow, config); 80 | }).join(''); 81 | 82 | let output = ''; 83 | const bottom = drawBorderBottom(columnWidths, config); 84 | 85 | if (bottom !== '\n') { 86 | output = '\r\u001B[K'; 87 | } 88 | 89 | output += drawBorderJoin(columnWidths, config); 90 | output += body; 91 | output += bottom; 92 | 93 | output = output.trimEnd(); 94 | 95 | process.stdout.write(output); 96 | }; 97 | 98 | export const createStream = (userConfig: StreamUserConfig): WritableStream => { 99 | const config = makeStreamConfig(userConfig); 100 | 101 | const columnWidths = Object.values(config.columns).map((column) => { 102 | return column.width + column.paddingLeft + column.paddingRight; 103 | }); 104 | 105 | let empty = true; 106 | 107 | return { 108 | write: (row: string[]) => { 109 | if (row.length !== config.columnCount) { 110 | throw new Error('Row cell count does not match the config.columnCount.'); 111 | } 112 | 113 | if (empty) { 114 | empty = false; 115 | 116 | create(row, columnWidths, config); 117 | } else { 118 | append(row, columnWidths, config); 119 | } 120 | }, 121 | }; 122 | }; 123 | -------------------------------------------------------------------------------- /test/README/api/table/spanningCells.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TableUserConfig, 3 | } from '../../../../src'; 4 | import { 5 | table, 6 | } from '../../../../src'; 7 | import { 8 | expectTable, 9 | } from '../../../utils'; 10 | 11 | describe('README.md api/table/', () => { 12 | it('/spanningCells', () => { 13 | const data = [ 14 | ['Test Coverage Report', '', '', '', '', ''], 15 | ['Module', 'Component', 'Test Cases', 'Failures', 'Durations', 'Success Rate'], 16 | ['Services', 'User', '50', '30', '3m 7s', '60.0%'], 17 | ['', 'Payment', '100', '80', '7m 15s', '80.0%'], 18 | ['Subtotal', '', '150', '110', '10m 22s', '73.3%'], 19 | ['Controllers', 'User', '24', '18', '1m 30s', '75.0%'], 20 | ['', 'Payment', '30', '24', '50s', '80.0%'], 21 | ['Subtotal', '', '54', '42', '2m 20s', '77.8%'], 22 | ['Total', '', '204', '152', '12m 42s', '74.5%'], 23 | ]; 24 | 25 | const config: TableUserConfig = { 26 | columns: [ 27 | {alignment: 'center', 28 | width: 12}, 29 | {alignment: 'center', 30 | width: 10}, 31 | {alignment: 'right'}, 32 | {alignment: 'right'}, 33 | {alignment: 'right'}, 34 | {alignment: 'right'}, 35 | ], 36 | spanningCells: [ 37 | {col: 0, 38 | colSpan: 6, 39 | row: 0}, 40 | {col: 0, 41 | row: 2, 42 | rowSpan: 2, 43 | verticalAlignment: 'middle'}, 44 | {alignment: 'right', 45 | col: 0, 46 | colSpan: 2, 47 | row: 4}, 48 | {col: 0, 49 | row: 5, 50 | rowSpan: 2, 51 | verticalAlignment: 'middle'}, 52 | {alignment: 'right', 53 | col: 0, 54 | colSpan: 2, 55 | row: 7}, 56 | {alignment: 'right', 57 | col: 0, 58 | colSpan: 2, 59 | row: 8}, 60 | ], 61 | }; 62 | 63 | expectTable(table(data, config), ` 64 | ╔══════════════════════════════════════════════════════════════════════════════╗ 65 | ║ Test Coverage Report ║ 66 | ╟──────────────┬────────────┬────────────┬──────────┬───────────┬──────────────╢ 67 | ║ Module │ Component │ Test Cases │ Failures │ Durations │ Success Rate ║ 68 | ╟──────────────┼────────────┼────────────┼──────────┼───────────┼──────────────╢ 69 | ║ │ User │ 50 │ 30 │ 3m 7s │ 60.0% ║ 70 | ║ Services ├────────────┼────────────┼──────────┼───────────┼──────────────╢ 71 | ║ │ Payment │ 100 │ 80 │ 7m 15s │ 80.0% ║ 72 | ╟──────────────┴────────────┼────────────┼──────────┼───────────┼──────────────╢ 73 | ║ Subtotal │ 150 │ 110 │ 10m 22s │ 73.3% ║ 74 | ╟──────────────┬────────────┼────────────┼──────────┼───────────┼──────────────╢ 75 | ║ │ User │ 24 │ 18 │ 1m 30s │ 75.0% ║ 76 | ║ Controllers ├────────────┼────────────┼──────────┼───────────┼──────────────╢ 77 | ║ │ Payment │ 30 │ 24 │ 50s │ 80.0% ║ 78 | ╟──────────────┴────────────┼────────────┼──────────┼───────────┼──────────────╢ 79 | ║ Subtotal │ 54 │ 42 │ 2m 20s │ 77.8% ║ 80 | ╟───────────────────────────┼────────────┼──────────┼───────────┼──────────────╢ 81 | ║ Total │ 204 │ 152 │ 12m 42s │ 74.5% ║ 82 | ╚═══════════════════════════╧════════════╧══════════╧═══════════╧══════════════╝ 83 | `); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /test/validateTableData.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-nested-callbacks */ 2 | 3 | import { 4 | expect, 5 | } from 'chai'; 6 | import { 7 | table, 8 | } from '../src'; 9 | import { 10 | validateTableData, 11 | } from '../src/validateTableData'; 12 | 13 | describe('validateTableData', () => { 14 | context('table does not have a row', () => { 15 | it('throws an error', () => { 16 | expect(() => { 17 | validateTableData([]); 18 | }).to.throw(Error, 'Table must define at least one row.'); 19 | }); 20 | }); 21 | 22 | context('table does not have a column', () => { 23 | it('throws an error', () => { 24 | expect(() => { 25 | validateTableData([[]]); 26 | }).to.throw(Error, 'Table must define at least one column.'); 27 | }); 28 | }); 29 | 30 | context('row data is not an array', () => { 31 | it('throws an error', () => { 32 | expect(() => { 33 | validateTableData({} as never); 34 | }).to.throw(Error, 'Table data must be an array.'); 35 | }); 36 | }); 37 | 38 | context('column data is not an array', () => { 39 | it('throws an error', () => { 40 | expect(() => { 41 | validateTableData([{}] as never); 42 | }).to.throw(Error, 'Table row data must be an array.'); 43 | }); 44 | }); 45 | 46 | context('cell data contains a control character', () => { 47 | it('throws an error', () => { 48 | expect(() => { 49 | validateTableData([ 50 | [ 51 | String.fromCodePoint(0x01), 52 | ], 53 | ]); 54 | }).to.throw(Error, 'Table data must not contain control characters.'); 55 | }); 56 | }); 57 | 58 | context('cell data contains newlines', () => { 59 | it('does not throw', () => { 60 | expect(() => { 61 | validateTableData([['ab\nc']]); 62 | }).to.not.throw(); 63 | }); 64 | }); 65 | 66 | context('cell data contains Windows-style newlines', () => { 67 | it('does not throw and replaces by Unix-style newline', () => { 68 | expect(() => { 69 | validateTableData([['ab\r\nc']]); 70 | }).to.not.throw(); 71 | 72 | expect(table([['ab\r\nc']])).to.equal('╔════╗\n║ ab ║\n║ c ║\n╚════╝\n'); 73 | }); 74 | }); 75 | 76 | context('cell data contains carriage return only', () => { 77 | it('throws an error', () => { 78 | expect(() => { 79 | validateTableData([['ab\rc']]); 80 | }).to.throw(Error, 'Table data must not contain control characters.'); 81 | }); 82 | }); 83 | 84 | context('cell data contains hyperlinks', () => { 85 | const OSC = '\u001B]'; 86 | const BEL = '\u0007'; 87 | const SEP = ';'; 88 | const url = 'https://example.com'; 89 | const text = 'This is a link to example.com'; 90 | 91 | const link = [ 92 | OSC, 93 | '8', 94 | SEP, 95 | SEP, 96 | url, 97 | BEL, 98 | text, 99 | OSC, 100 | '8', 101 | SEP, 102 | SEP, 103 | BEL, 104 | ].join(''); 105 | 106 | it('does not throw', () => { 107 | expect(() => { 108 | validateTableData([[link]]); 109 | }).to.not.throw(); 110 | }); 111 | }); 112 | 113 | context('rows have inconsistent number of cells', () => { 114 | it('throws an error', () => { 115 | expect(() => { 116 | validateTableData([ 117 | ['a', 'b', 'c'], 118 | ['a', 'b'], 119 | ]); 120 | }).to.throw(Error, 'Table must have a consistent number of cells.'); 121 | }); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /.README/api/table/spanningCells.md: -------------------------------------------------------------------------------- 1 | ##### config.spanningCells 2 | 3 | Type: `SpanningCellConfig[]` 4 | 5 | Spanning cells configuration. 6 | 7 | The configuration should be straightforward: just specify an array of minimal cell configurations including the position of top-left cell 8 | and the number of columns and/or rows will be expanded from it. 9 | 10 | The content of overlap cells will be ignored to make the `data` shape be consistent. 11 | 12 | By default, the configuration of column that the top-left cell belongs to will be applied to the whole spanning cell, except: 13 | * The `width` will be summed up of all spanning columns. 14 | * The `paddingRight` will be received from the right-most column intentionally. 15 | 16 | Advances customized column-like styles can be configurable to each spanning cell to overwrite the default behavior. 17 | 18 | ```js 19 | const data = [ 20 | ['Test Coverage Report', '', '', '', '', ''], 21 | ['Module', 'Component', 'Test Cases', 'Failures', 'Durations', 'Success Rate'], 22 | ['Services', 'User', '50', '30', '3m 7s', '60.0%'], 23 | ['', 'Payment', '100', '80', '7m 15s', '80.0%'], 24 | ['Subtotal', '', '150', '110', '10m 22s', '73.3%'], 25 | ['Controllers', 'User', '24', '18', '1m 30s', '75.0%'], 26 | ['', 'Payment', '30', '24', '50s', '80.0%'], 27 | ['Subtotal', '', '54', '42', '2m 20s', '77.8%'], 28 | ['Total', '', '204', '152', '12m 42s', '74.5%'], 29 | ]; 30 | 31 | const config = { 32 | columns: [ 33 | { alignment: 'center', width: 12 }, 34 | { alignment: 'center', width: 10 }, 35 | { alignment: 'right' }, 36 | { alignment: 'right' }, 37 | { alignment: 'right' }, 38 | { alignment: 'right' } 39 | ], 40 | spanningCells: [ 41 | { col: 0, row: 0, colSpan: 6 }, 42 | { col: 0, row: 2, rowSpan: 2, verticalAlignment: 'middle'}, 43 | { col: 0, row: 4, colSpan: 2, alignment: 'right'}, 44 | { col: 0, row: 5, rowSpan: 2, verticalAlignment: 'middle'}, 45 | { col: 0, row: 7, colSpan: 2, alignment: 'right' }, 46 | { col: 0, row: 8, colSpan: 2, alignment: 'right' } 47 | ], 48 | }; 49 | 50 | console.log(table(data, config)); 51 | ``` 52 | 53 | ``` 54 | ╔══════════════════════════════════════════════════════════════════════════════╗ 55 | ║ Test Coverage Report ║ 56 | ╟──────────────┬────────────┬────────────┬──────────┬───────────┬──────────────╢ 57 | ║ Module │ Component │ Test Cases │ Failures │ Durations │ Success Rate ║ 58 | ╟──────────────┼────────────┼────────────┼──────────┼───────────┼──────────────╢ 59 | ║ │ User │ 50 │ 30 │ 3m 7s │ 60.0% ║ 60 | ║ Services ├────────────┼────────────┼──────────┼───────────┼──────────────╢ 61 | ║ │ Payment │ 100 │ 80 │ 7m 15s │ 80.0% ║ 62 | ╟──────────────┴────────────┼────────────┼──────────┼───────────┼──────────────╢ 63 | ║ Subtotal │ 150 │ 110 │ 10m 22s │ 73.3% ║ 64 | ╟──────────────┬────────────┼────────────┼──────────┼───────────┼──────────────╢ 65 | ║ │ User │ 24 │ 18 │ 1m 30s │ 75.0% ║ 66 | ║ Controllers ├────────────┼────────────┼──────────┼───────────┼──────────────╢ 67 | ║ │ Payment │ 30 │ 24 │ 50s │ 80.0% ║ 68 | ╟──────────────┴────────────┼────────────┼──────────┼───────────┼──────────────╢ 69 | ║ Subtotal │ 54 │ 42 │ 2m 20s │ 77.8% ║ 70 | ╟───────────────────────────┼────────────┼──────────┼───────────┼──────────────╢ 71 | ║ Total │ 204 │ 152 │ 12m 42s │ 74.5% ║ 72 | ╚═══════════════════════════╧════════════╧══════════╧═══════════╧══════════════╝ 73 | ``` 74 | -------------------------------------------------------------------------------- /test/createStream.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-nested-callbacks */ 2 | 3 | import { 4 | expect, 5 | } from 'chai'; 6 | // eslint-disable-next-line import/no-namespace 7 | import * as Sinon from 'sinon'; 8 | import { 9 | getBorderCharacters, 10 | createStream, 11 | } from '../src'; 12 | 13 | describe('createStream', () => { 14 | context('"config.columnDefault.width" property is not provided', () => { 15 | it('throws an error', () => { 16 | expect(() => { 17 | createStream({ 18 | columnCount: 1, 19 | columnDefault: {}, 20 | } as never); 21 | }).to.throw(Error, 'Must provide config.columnDefault.width when creating a stream.'); 22 | }); 23 | }); 24 | context('Table data cell count does not match the columnCount.', () => { 25 | it('throws an error', () => { 26 | expect(() => { 27 | const stream = createStream({ 28 | columnCount: 10, 29 | columnDefault: { 30 | width: 10, 31 | }, 32 | }); 33 | 34 | stream.write(['foo']); 35 | }).to.throw(Error, 'Row cell count does not match the config.columnCount.'); 36 | }); 37 | }); 38 | 39 | context('normal stream', () => { 40 | let stub: Sinon.SinonStub; 41 | beforeEach(() => { 42 | stub = Sinon.stub(process.stdout, 'write'); 43 | }); 44 | afterEach(() => { 45 | stub.restore(); 46 | stub.resetHistory(); 47 | }); 48 | 49 | it('process.stdout.write calls twice with proper arguments', () => { 50 | const stream = createStream({ 51 | border: getBorderCharacters('ramac'), 52 | columnCount: 3, 53 | columnDefault: { 54 | width: 2, 55 | }, 56 | columns: { 57 | 0: { 58 | alignment: 'right', 59 | paddingLeft: 3, 60 | }, 61 | 1: { 62 | alignment: 'center', 63 | paddingRight: 2, 64 | }, 65 | 2: { 66 | alignment: 'left', 67 | width: 5, 68 | }, 69 | }, 70 | }); 71 | stream.write(['a b', 'ccc', 'd']); 72 | stream.write(['e', 'f', 'g']); 73 | 74 | Sinon.assert.callCount(stub, 2); 75 | Sinon.assert.calledWithExactly(stub.getCall(0), '+------+-----+-------+\n| a | cc | d |\n| b | c | |\n+------+-----+-------+'); 76 | Sinon.assert.calledWithExactly(stub.getCall(1), '\r\u001b[K|------|-----|-------|\n| e | f | g |\n+------+-----+-------+'); 77 | }); 78 | 79 | context('given custom drawVerticalLine', () => { 80 | it('use the callback to draw vertical lines', () => { 81 | const stream = createStream({ 82 | columnCount: 2, 83 | columnDefault: { 84 | width: 2, 85 | }, 86 | drawVerticalLine: (index) => { 87 | return index === 1; 88 | }, 89 | }); 90 | 91 | stream.write(['a', 'b']); 92 | Sinon.assert.callCount(stub, 1); 93 | 94 | Sinon.assert.calledOnceWithExactly(stub, '════╤════\n a │ b \n════╧════'); 95 | }); 96 | }); 97 | 98 | context('append empty row', () => { 99 | it('does not add a new line', () => { 100 | const stream = createStream({ 101 | border: getBorderCharacters('void'), 102 | columnCount: 1, 103 | columnDefault: { 104 | width: 2, 105 | }, 106 | }); 107 | 108 | stream.write(['a']); 109 | stream.write(['']); 110 | stream.write(['a']); 111 | 112 | Sinon.assert.calledWithExactly(stub.getCall(1), ''); 113 | Sinon.assert.calledWithExactly(stub.getCall(2), '\n a'); 114 | }); 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /test/getBorderCharacters.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-nested-callbacks */ 2 | /* eslint-disable sort-keys-fix/sort-keys-fix */ 3 | 4 | import { 5 | expect, 6 | } from 'chai'; 7 | import { 8 | getBorderCharacters, 9 | } from '../src'; 10 | 11 | describe('getBorderCharacters', () => { 12 | context('given name \'honeywell\'', () => { 13 | it('returns the \'honeywell\' template', () => { 14 | expect(getBorderCharacters('honeywell')).to.be.deep.equal({ 15 | headerJoin: '┬', 16 | 17 | bodyJoin: '│', 18 | bodyLeft: '║', 19 | bodyRight: '║', 20 | 21 | bottomJoin: '╧', 22 | bottomLeft: '╚', 23 | bottomRight: '╝', 24 | bottomBody: '═', 25 | 26 | joinBody: '─', 27 | joinJoin: '┼', 28 | joinLeft: '╟', 29 | joinRight: '╢', 30 | joinMiddleDown: '┬', 31 | joinMiddleLeft: '┤', 32 | joinMiddleRight: '├', 33 | joinMiddleUp: '┴', 34 | 35 | topBody: '═', 36 | topJoin: '╤', 37 | topLeft: '╔', 38 | topRight: '╗', 39 | }); 40 | }); 41 | }); 42 | 43 | context('given name \'norc\'', () => { 44 | it('returns the \'norc\' template', () => { 45 | expect(getBorderCharacters('norc')).to.be.deep.equal({ 46 | headerJoin: '┬', 47 | 48 | bodyJoin: '│', 49 | bodyLeft: '│', 50 | bodyRight: '│', 51 | 52 | bottomJoin: '┴', 53 | bottomLeft: '└', 54 | bottomRight: '┘', 55 | bottomBody: '─', 56 | 57 | joinJoin: '┼', 58 | joinLeft: '├', 59 | joinRight: '┤', 60 | joinBody: '─', 61 | joinMiddleDown: '┬', 62 | joinMiddleLeft: '┤', 63 | joinMiddleRight: '├', 64 | joinMiddleUp: '┴', 65 | 66 | topBody: '─', 67 | topJoin: '┬', 68 | topLeft: '┌', 69 | topRight: '┐', 70 | }); 71 | }); 72 | }); 73 | 74 | context('given name \'ramac\'', () => { 75 | it('returns the \'ramac\' template', () => { 76 | expect(getBorderCharacters('ramac')).to.be.deep.equal({ 77 | headerJoin: '+', 78 | 79 | bodyJoin: '|', 80 | bodyLeft: '|', 81 | bodyRight: '|', 82 | 83 | bottomJoin: '+', 84 | bottomLeft: '+', 85 | bottomRight: '+', 86 | bottomBody: '-', 87 | 88 | joinJoin: '|', 89 | joinLeft: '|', 90 | joinRight: '|', 91 | joinBody: '-', 92 | joinMiddleDown: '+', 93 | joinMiddleLeft: '+', 94 | joinMiddleRight: '+', 95 | joinMiddleUp: '+', 96 | 97 | topBody: '-', 98 | topJoin: '+', 99 | topLeft: '+', 100 | topRight: '+', 101 | }); 102 | }); 103 | }); 104 | 105 | context('given name \'void\'', () => { 106 | it('returns the \'void\' template', () => { 107 | expect(getBorderCharacters('void')).to.be.deep.equal({ 108 | headerJoin: '', 109 | 110 | bodyJoin: '', 111 | bodyLeft: '', 112 | bodyRight: '', 113 | 114 | bottomJoin: '', 115 | bottomLeft: '', 116 | bottomRight: '', 117 | bottomBody: '', 118 | 119 | joinJoin: '', 120 | joinLeft: '', 121 | joinRight: '', 122 | joinBody: '', 123 | joinMiddleDown: '', 124 | joinMiddleLeft: '', 125 | joinMiddleRight: '', 126 | joinMiddleUp: '', 127 | 128 | topBody: '', 129 | topJoin: '', 130 | topLeft: '', 131 | topRight: '', 132 | }); 133 | }); 134 | }); 135 | 136 | context('given another name', () => { 137 | it('throws an error', () => { 138 | expect(() => { 139 | return getBorderCharacters('bold'); 140 | }).to.throw(Error, 'Unknown border template "bold".'); 141 | }); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /test/makeStreamConfig.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-nested-callbacks */ 2 | 3 | import { 4 | expect, 5 | } from 'chai'; 6 | import type { 7 | StreamUserConfig, 8 | } from '../src'; 9 | import { 10 | makeStreamConfig, 11 | } from '../src/makeStreamConfig'; 12 | 13 | const baseStreamConfig: StreamUserConfig = { 14 | columnCount: 1, 15 | columnDefault: { 16 | width: 5, 17 | }, 18 | }; 19 | 20 | describe('makeStreamConfig', () => { 21 | it('does not affect the parameter configuration object', () => { 22 | makeStreamConfig(baseStreamConfig); 23 | 24 | expect(baseStreamConfig).to.equal(baseStreamConfig); 25 | }); 26 | 27 | context('columnDefault', () => { 28 | context('not contains width', () => { 29 | it('throws an error', () => { 30 | expect(() => { 31 | return makeStreamConfig({columnCount: 2, 32 | columnDefault: {}} as never); 33 | }).to.be.throw('Must provide config.columnDefault.width when creating a stream.'); 34 | }); 35 | }); 36 | }); 37 | 38 | context('column', () => { 39 | context('alignment', () => { 40 | context('is not provided', () => { 41 | it('defaults to "left"', () => { 42 | const config = makeStreamConfig(baseStreamConfig); 43 | 44 | expect(config.columns[0].alignment).to.equal('left'); 45 | }); 46 | }); 47 | 48 | context('is provided', () => { 49 | it('uses the custom value', () => { 50 | const config = makeStreamConfig({...baseStreamConfig, 51 | columns: { 52 | 0: { 53 | alignment: 'center', 54 | }, 55 | }}); 56 | 57 | expect(config.columns[0].alignment).to.equal('center'); 58 | }); 59 | }); 60 | }); 61 | 62 | context('paddingLeft', () => { 63 | context('is not provided', () => { 64 | it('defaults to 1', () => { 65 | const config = makeStreamConfig(baseStreamConfig); 66 | 67 | expect(config.columns[0].paddingLeft).to.equal(1); 68 | }); 69 | }); 70 | 71 | context('is provided', () => { 72 | it('uses the custom value', () => { 73 | const config = makeStreamConfig({...baseStreamConfig, 74 | columns: { 75 | 0: { 76 | paddingLeft: 3, 77 | }, 78 | }}); 79 | 80 | expect(config.columns[0].paddingLeft).to.equal(3); 81 | }); 82 | }); 83 | }); 84 | 85 | context('paddingRight', () => { 86 | context('is not provided', () => { 87 | it('defaults to 1', () => { 88 | const config = makeStreamConfig(baseStreamConfig); 89 | 90 | expect(config.columns[0].paddingRight).to.equal(1); 91 | }); 92 | }); 93 | 94 | context('is provided', () => { 95 | it('uses the custom value', () => { 96 | const config = makeStreamConfig({...baseStreamConfig, 97 | columns: { 98 | 0: { 99 | paddingRight: 3, 100 | }, 101 | }}); 102 | 103 | expect(config.columns[0].paddingRight).to.equal(3); 104 | }); 105 | }); 106 | }); 107 | }); 108 | 109 | context('"drawVerticalLine', () => { 110 | context('is not provided', () => { 111 | it('defaults to retuning true', () => { 112 | const config = makeStreamConfig(baseStreamConfig); 113 | 114 | expect(config.drawVerticalLine(-1, -1)).to.equal(true); 115 | }); 116 | }); 117 | 118 | context('is provided', () => { 119 | it('uses the custom function', () => { 120 | const config = makeStreamConfig({ 121 | ...baseStreamConfig, 122 | drawVerticalLine: () => { 123 | return false; 124 | }, 125 | }); 126 | 127 | expect(config.drawVerticalLine(-1, -1)).to.equal(false); 128 | }); 129 | }); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /test/mapDataUsingRowHeights.ts: -------------------------------------------------------------------------------- 1 | import { 2 | expect, 3 | } from 'chai'; 4 | import chalk from 'chalk'; 5 | import { 6 | makeTableConfig, 7 | } from '../src/makeTableConfig'; 8 | import { 9 | mapDataUsingRowHeights, 10 | } from '../src/mapDataUsingRowHeights'; 11 | 12 | describe('mapDataUsingRowHeights', () => { 13 | context('no data spans multiple rows', () => { 14 | it('maps data to a single cell', () => { 15 | const rowHeights: number[] = [1]; 16 | 17 | const data = [ 18 | ['aa'], 19 | ]; 20 | 21 | const config = makeTableConfig(data, { 22 | columns: { 23 | 0: { 24 | width: 2, 25 | }, 26 | }, 27 | }); 28 | 29 | const mappedData = mapDataUsingRowHeights(data, rowHeights, config); 30 | 31 | expect(mappedData).to.deep.equal([ 32 | ['aa'], 33 | ]); 34 | }); 35 | }); 36 | 37 | context('single cell spans multiple rows', () => { 38 | it('maps data to multiple rows', () => { 39 | const rowHeights: number[] = [5]; 40 | 41 | const data = [ 42 | ['aabbccddee'], 43 | ]; 44 | 45 | const config = makeTableConfig(data, { 46 | columns: { 47 | 0: { 48 | width: 2, 49 | }, 50 | }, 51 | }); 52 | 53 | const mappedData = mapDataUsingRowHeights(data, rowHeights, config); 54 | 55 | expect(mappedData).to.deep.equal([ 56 | ['aa'], 57 | ['bb'], 58 | ['cc'], 59 | ['dd'], 60 | ['ee'], 61 | ]); 62 | }); 63 | }); 64 | 65 | context('single cell contains newlines', () => { 66 | it('maps data to multiple rows', () => { 67 | const rowHeights = [5]; 68 | 69 | const data = [ 70 | [ 71 | 'aa\nbb\ncc\ndd\nee', 72 | ], 73 | ]; 74 | 75 | const config = makeTableConfig(data, { 76 | columns: { 77 | 0: { 78 | width: 100, 79 | }, 80 | }, 81 | }); 82 | 83 | const mappedData = mapDataUsingRowHeights(data, rowHeights, config); 84 | 85 | expect(mappedData).to.deep.equal([ 86 | ['aa'], 87 | ['bb'], 88 | ['cc'], 89 | ['dd'], 90 | ['ee'], 91 | ]); 92 | }); 93 | 94 | it('maps data with color coding to multiple rows', () => { 95 | const rowHeights = [ 96 | 5, 97 | ]; 98 | 99 | const data = [ 100 | [ 101 | chalk.red('aa\nbb\ncc\ndd\nee'), 102 | ], 103 | ]; 104 | 105 | const config = makeTableConfig(data, { 106 | columns: { 107 | 0: { 108 | width: 100, 109 | }, 110 | }, 111 | }); 112 | 113 | const mappedData = mapDataUsingRowHeights(data, rowHeights, config); 114 | 115 | expect(mappedData).to.deep.equal([ 116 | [chalk.red('aa')], 117 | [chalk.red('bb')], 118 | [chalk.red('cc')], 119 | [chalk.red('dd')], 120 | [chalk.red('ee')], 121 | ]); 122 | }); 123 | }); 124 | 125 | context('multiple cells spans multiple rows', () => { 126 | it('maps data to multiple rows', () => { 127 | const rowHeights = [ 128 | 5, 129 | ]; 130 | 131 | const data = [ 132 | [ 133 | 'aabbccddee', 134 | '00001111', 135 | ], 136 | ]; 137 | 138 | const config = makeTableConfig(data, { 139 | columns: { 140 | 0: { 141 | width: 2, 142 | }, 143 | 1: { 144 | width: 4, 145 | }, 146 | }, 147 | }); 148 | 149 | const mappedData = mapDataUsingRowHeights(data, rowHeights, config); 150 | 151 | expect(mappedData).to.deep.equal([ 152 | [ 153 | 'aa', 154 | '0000', 155 | ], 156 | [ 157 | 'bb', 158 | '1111', 159 | ], 160 | [ 161 | 'cc', 162 | '', 163 | ], 164 | [ 165 | 'dd', 166 | '', 167 | ], 168 | [ 169 | 'ee', 170 | '', 171 | ], 172 | ]); 173 | }); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /test/spanningCellFixtures.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CellUserConfig, DrawHorizontalLine, DrawVerticalLine, SpanningCellConfig, 3 | } from '../src'; 4 | import type { 5 | SpanningCellContext, 6 | } from '../src/spanningCellManager'; 7 | import type { 8 | ColumnConfig, Row, 9 | } from '../src/types/internal'; 10 | 11 | export const baseRows: Row[] = [ 12 | ['ECMAScript (or ES) is a general-purpose programming language, standardised by Ecma International according to the document ECMA-262.', 13 | 'It is a JavaScript standard meant to ensure the interoperability of web pages across different web browsers.', 14 | 'ECMAScript is commonly used for client-side scripting on the World Wide Web, and it is increasingly being used for writing server applications and services using Node.js.', 15 | 'ECMA-262 or the ECMAScript Language Specification defines the ECMAScript Language, or just ECMAScript (aka JavaScript).'], 16 | ['JavaScript often abbreviated as JS, is a programming language that conforms to the ECMAScript specification.', 17 | 'JavaScript is high-level, often just-in-time compiled and multi-paradigm. It has dynamic typing, prototype-based object-orientation and first-class functions.', 18 | 'Alongside HTML and CSS, JavaScript is one of the core technologies of the World Wide Web. Over 97% of websites use it client-side for web page behavior, often incorporating third-party libraries.', 19 | 'All major web browsers have a dedicated JavaScript engine to execute the code on the user\'s device.'], 20 | ['Node.js is an open-source, cross-platform, back-end JavaScript runtime environment that runs on the V8 engine and executes JavaScript code outside a web browser.', 21 | 'Node.js lets developers use JavaScript to write command line tools and for server-side scripting—running scripts server-side to produce dynamic web page content before the page is sent to the user\'s web browser.', 22 | 'Consequently, Node.js represents a "JavaScript everywhere" paradigm, unifying web-application development around a single programming language, rather than different languages for server-side and client-side scripts.', 23 | 'Though .js is the standard filename extension for JavaScript code, the name "Node.js" doesn\'t refer to a particular file in this context and is merely the name of the product.'], 24 | ['npm is a package manager for the JavaScript programming language maintained by npm, Inc.', 25 | 'npm is the default package manager for the JavaScript runtime environment Node.js.', 26 | 'It consists of a command line client, also called npm, and an online database of public and paid-for private packages, called the npm registry.', 27 | 'The registry is accessed via the client, and the available packages can be browsed and searched via the npm website. The package manager and the registry are managed by npm, Inc.'], 28 | ]; 29 | 30 | export const baseSpanningCellConfig: SpanningCellConfig[] = [ 31 | { 32 | col: 0, 33 | colSpan: 2, 34 | row: 0, 35 | }, 36 | {col: 3, 37 | row: 0, 38 | rowSpan: 2}, 39 | 40 | {col: 1, 41 | colSpan: 2, 42 | row: 1}, 43 | 44 | {col: 0, 45 | colSpan: 2, 46 | row: 2, 47 | rowSpan: 2}, 48 | 49 | {col: 2, 50 | row: 2, 51 | rowSpan: 2}, 52 | ]; 53 | 54 | export const baseDrawHorizontalLine: DrawHorizontalLine = () => { 55 | return true; 56 | }; 57 | 58 | export const baseDrawVerticalLine: DrawVerticalLine = () => { 59 | return true; 60 | }; 61 | 62 | export const baseCellConfig: Required = { 63 | alignment: 'left', 64 | paddingLeft: 1, 65 | paddingRight: 1, 66 | truncate: Number.POSITIVE_INFINITY, 67 | verticalAlignment: 'top', 68 | wrapWord: true, 69 | }; 70 | 71 | export const baseColumnConfig: ColumnConfig[] = [ 72 | {width: 15, 73 | ...baseCellConfig}, 74 | {width: 10, 75 | ...baseCellConfig}, 76 | {width: 20, 77 | ...baseCellConfig}, 78 | {width: 15, 79 | ...baseCellConfig}, 80 | ]; 81 | 82 | export const baseRowHeight: number[] = [8, 9, 10, 11]; 83 | 84 | export const baseSpanningCellContext: SpanningCellContext = { 85 | columnsConfig: baseColumnConfig, 86 | drawHorizontalLine: baseDrawHorizontalLine, 87 | drawVerticalLine: baseDrawVerticalLine, 88 | rowHeights: baseRowHeight, 89 | rows: baseRows, 90 | spanningCellConfigs: baseSpanningCellConfig, 91 | }; 92 | -------------------------------------------------------------------------------- /test/makeConfig.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-nested-callbacks */ 2 | 3 | import { 4 | expect, 5 | } from 'chai'; 6 | import { 7 | makeTableConfig, 8 | } from '../src/makeTableConfig'; 9 | 10 | describe('makeConfig', () => { 11 | const rows = [['aaaaa']]; 12 | 13 | it('does not affect the parameter configuration object', () => { 14 | const config = {}; 15 | makeTableConfig(rows, config); 16 | 17 | expect(config).to.deep.equal({}); 18 | }); 19 | 20 | context('column', () => { 21 | context('"alignment"', () => { 22 | context('is not provided', () => { 23 | it('defaults to "left"', () => { 24 | const config = makeTableConfig(rows); 25 | 26 | expect(config.columns[0].alignment).to.equal('left'); 27 | }); 28 | }); 29 | 30 | context('is provided', () => { 31 | it('uses the custom value', () => { 32 | const config = makeTableConfig(rows, {columns: { 33 | 0: {alignment: 'center'}, 34 | }}); 35 | 36 | expect(config.columns[0].alignment).to.equal('center'); 37 | }); 38 | }); 39 | }); 40 | 41 | context('"width"', () => { 42 | context('is not provided', () => { 43 | it('defaults to the maximum column width', () => { 44 | const config = makeTableConfig(rows); 45 | 46 | expect(config.columns[0].width).to.equal(5); 47 | }); 48 | }); 49 | 50 | context('is provided', () => { 51 | it('uses the custom value', () => { 52 | const config = makeTableConfig(rows, {columns: {0: {width: 7}}}); 53 | 54 | expect(config.columns[0].width).to.equal(7); 55 | }); 56 | }); 57 | }); 58 | 59 | context('"padding"', () => { 60 | context('is not provided', () => { 61 | it('defaults to 1', () => { 62 | const config = makeTableConfig(rows); 63 | 64 | expect(config.columns[0].paddingLeft).to.equal(1); 65 | expect(config.columns[0].paddingRight).to.equal(1); 66 | }); 67 | }); 68 | 69 | context('is provided', () => { 70 | it('uses the custom value', () => { 71 | const config = makeTableConfig(rows, {columns: {0: {paddingLeft: 3, 72 | paddingRight: 2}}}); 73 | 74 | expect(config.columns[0].paddingLeft).to.equal(3); 75 | expect(config.columns[0].paddingRight).to.equal(2); 76 | }); 77 | }); 78 | }); 79 | }); 80 | 81 | context('"drawVerticalLine', () => { 82 | context('is not provided', () => { 83 | it('defaults to retuning true', () => { 84 | const config = makeTableConfig(rows); 85 | 86 | expect(config.drawVerticalLine(-1, -1)).to.equal(true); 87 | }); 88 | }); 89 | 90 | context('is provided', () => { 91 | it('uses the custom function', () => { 92 | const config = makeTableConfig(rows, {drawVerticalLine: () => { 93 | return false; 94 | }}); 95 | 96 | expect(config.drawVerticalLine(-1, -1)).to.equal(false); 97 | }); 98 | }); 99 | }); 100 | 101 | context('"drawHorizontalLine', () => { 102 | context('is not provided', () => { 103 | it('defaults to retuning true', () => { 104 | const config = makeTableConfig([['aaaaa']]); 105 | 106 | expect(config.drawHorizontalLine(-1, -1)).to.equal(true); 107 | }); 108 | }); 109 | 110 | context('is provided', () => { 111 | it('uses the custom function', () => { 112 | const config = makeTableConfig(rows, {drawHorizontalLine: () => { 113 | return false; 114 | }}); 115 | 116 | expect(config.drawHorizontalLine(-1, -1)).to.be.equal(false); 117 | }); 118 | }); 119 | }); 120 | 121 | context('"singleLine', () => { 122 | context('is not provided', () => { 123 | it('defaults to retuning false', () => { 124 | const config = makeTableConfig(rows); 125 | 126 | expect(config.singleLine).to.equal(false); 127 | }); 128 | }); 129 | 130 | context('is provided', () => { 131 | it('uses the custom value', () => { 132 | const config = makeTableConfig(rows, {singleLine: true}); 133 | 134 | expect(config.singleLine).to.equal(true); 135 | }); 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import slice from 'slice-ansi'; 2 | import stringWidth from 'string-width'; 3 | import stripAnsi from 'strip-ansi'; 4 | import { 5 | getBorderCharacters, 6 | } from './getBorderCharacters'; 7 | import type { 8 | BorderConfig, 9 | BorderUserConfig, 10 | SpanningCellConfig, 11 | } from './types/api'; 12 | import type { 13 | BaseConfig, 14 | CellCoordinates, 15 | RangeCoordinate, 16 | } from './types/internal'; 17 | 18 | /** 19 | * Converts Windows-style newline to Unix-style 20 | * 21 | * @internal 22 | */ 23 | export const normalizeString = (input: string): string => { 24 | return input.replace(/\r\n/g, '\n'); 25 | }; 26 | 27 | /** 28 | * Splits ansi string by newlines 29 | * 30 | * @internal 31 | */ 32 | export const splitAnsi = (input: string): string[] => { 33 | const lengths = stripAnsi(input).split('\n').map(stringWidth); 34 | 35 | const result: string[] = []; 36 | let startIndex = 0; 37 | 38 | lengths.forEach((length) => { 39 | result.push(length === 0 ? '' : slice(input, startIndex, startIndex + length)); 40 | 41 | // Plus 1 for the newline character itself 42 | startIndex += length + 1; 43 | }); 44 | 45 | return result; 46 | }; 47 | 48 | /** 49 | * Merges user provided border characters with the default border ("honeywell") characters. 50 | * 51 | * @internal 52 | */ 53 | export const makeBorderConfig = (border: BorderUserConfig | undefined): BorderConfig => { 54 | return { 55 | ...getBorderCharacters('honeywell'), 56 | ...border, 57 | }; 58 | }; 59 | 60 | /** 61 | * Groups the array into sub-arrays by sizes. 62 | * 63 | * @internal 64 | * @example 65 | * groupBySizes(['a', 'b', 'c', 'd', 'e'], [2, 1, 2]) = [ ['a', 'b'], ['c'], ['d', 'e'] ] 66 | */ 67 | 68 | export const groupBySizes = (array: T[], sizes: number[]): T[][] => { 69 | let startIndex = 0; 70 | 71 | return sizes.map((size) => { 72 | const group = array.slice(startIndex, startIndex + size); 73 | 74 | startIndex += size; 75 | 76 | return group; 77 | }); 78 | }; 79 | 80 | /** 81 | * Counts the number of continuous spaces in a string 82 | * 83 | * @internal 84 | * @example 85 | * countGroupSpaces('a bc de f') = 3 86 | */ 87 | export const countSpaceSequence = (input: string): number => { 88 | return input.match(/\s+/g)?.length ?? 0; 89 | }; 90 | 91 | /** 92 | * Creates the non-increasing number array given sum and length 93 | * whose the difference between maximum and minimum is not greater than 1 94 | * 95 | * @internal 96 | * @example 97 | * distributeUnevenly(6, 3) = [2, 2, 2] 98 | * distributeUnevenly(8, 3) = [3, 3, 2] 99 | */ 100 | export const distributeUnevenly = (sum: number, length: number): number[] => { 101 | const result = Array.from({length}).fill(Math.floor(sum / length)); 102 | 103 | return result.map((element, index) => { 104 | return element + (index < sum % length ? 1 : 0); 105 | }); 106 | }; 107 | 108 | export const sequence = (start: number, end: number): number[] => { 109 | return Array.from({length: end - start + 1}, (_, index) => { 110 | return index + start; 111 | }); 112 | }; 113 | 114 | export const sumArray = (array: number[]): number => { 115 | return array.reduce((accumulator, element) => { 116 | return accumulator + element; 117 | }, 0); 118 | }; 119 | 120 | export const extractTruncates = (config: BaseConfig): number[] => { 121 | return config.columns.map(({truncate}) => { 122 | return truncate; 123 | }); 124 | }; 125 | 126 | export const flatten = (array: T[][]): T[] => { 127 | const destination = []; 128 | const arrayLength = array.length; 129 | for (let index = 0; index < arrayLength; index++) { 130 | destination.push(...array[index]); 131 | } 132 | 133 | return destination; 134 | }; 135 | 136 | export const calculateRangeCoordinate = (spanningCellConfig: SpanningCellConfig): RangeCoordinate => { 137 | const {row, col, colSpan = 1, rowSpan = 1} = spanningCellConfig; 138 | 139 | return {bottomRight: {col: col + colSpan - 1, 140 | row: row + rowSpan - 1}, 141 | topLeft: {col, 142 | row}}; 143 | }; 144 | 145 | export const areCellEqual = (cell1: CellCoordinates, cell2: CellCoordinates): boolean => { 146 | return cell1.row === cell2.row && cell1.col === cell2.col; 147 | }; 148 | 149 | export const isCellInRange = (cell: CellCoordinates, {topLeft, bottomRight}: RangeCoordinate): boolean => { 150 | return ( 151 | topLeft.row <= cell.row && 152 | cell.row <= bottomRight.row && 153 | topLeft.col <= cell.col && 154 | cell.col <= bottomRight.col 155 | ); 156 | }; 157 | -------------------------------------------------------------------------------- /src/types/api.ts: -------------------------------------------------------------------------------- 1 | export type DrawLinePredicate = (index: number, size: number) => boolean; 2 | export type DrawVerticalLine = DrawLinePredicate; 3 | export type DrawHorizontalLine = DrawLinePredicate; 4 | 5 | export type BorderUserConfig = { 6 | readonly topLeft?: string, 7 | readonly topRight?: string, 8 | readonly topBody?: string, 9 | readonly topJoin?: string, 10 | 11 | readonly bottomLeft?: string, 12 | readonly bottomRight?: string, 13 | readonly bottomBody?: string, 14 | readonly bottomJoin?: string, 15 | 16 | readonly joinLeft?: string, 17 | readonly joinRight?: string, 18 | readonly joinBody?: string, 19 | readonly joinJoin?: string, 20 | readonly joinMiddleUp?: string, 21 | readonly joinMiddleDown?: string, 22 | readonly joinMiddleLeft?: string, 23 | readonly joinMiddleRight?: string, 24 | 25 | readonly headerJoin?: string, 26 | 27 | readonly bodyRight?: string, 28 | readonly bodyLeft?: string, 29 | readonly bodyJoin?: string, 30 | }; 31 | 32 | export type BorderConfig = Required; 33 | 34 | export type Alignment = 'center' | 'justify' | 'left' | 'right'; 35 | 36 | export type VerticalAlignment = 'bottom' | 'middle' | 'top'; 37 | 38 | export type CellUserConfig = { 39 | 40 | /** 41 | * Cell content horizontal alignment (default: left) 42 | */ 43 | readonly alignment?: Alignment, 44 | 45 | /** 46 | * Cell content vertical alignment (default: top) 47 | */ 48 | readonly verticalAlignment?: VerticalAlignment, 49 | 50 | /** 51 | * Number of characters are which the content will be truncated (default: Infinity) 52 | */ 53 | readonly truncate?: number, 54 | 55 | /** 56 | * Cell content padding width left (default: 1) 57 | */ 58 | readonly paddingLeft?: number, 59 | 60 | /** 61 | * Cell content padding width right (default: 1) 62 | */ 63 | readonly paddingRight?: number, 64 | 65 | /** 66 | * If true, the text is broken at the nearest space or one of the special characters: "\|/_.,;-" 67 | */ 68 | readonly wrapWord?: boolean, 69 | }; 70 | 71 | export type ColumnUserConfig = CellUserConfig & { 72 | 73 | /** 74 | * Column width (default: auto calculation based on the cell content) 75 | */ 76 | readonly width?: number, 77 | 78 | }; 79 | 80 | /** 81 | * @deprecated Use spanning cell API instead 82 | */ 83 | export type HeaderUserConfig = Omit & { 84 | readonly content: string, 85 | }; 86 | 87 | export type BaseUserConfig = { 88 | 89 | /** 90 | * Custom border 91 | */ 92 | readonly border?: BorderUserConfig, 93 | 94 | /** 95 | * Default values for all columns. Column specific settings overwrite the default values. 96 | */ 97 | readonly columnDefault?: ColumnUserConfig, 98 | 99 | /** 100 | * Column specific configuration. 101 | */ 102 | readonly columns?: Indexable, 103 | 104 | /** 105 | * Used to tell whether to draw a vertical line. 106 | * This callback is called for each non-content line of the table. 107 | * The default behavior is to always return true. 108 | */ 109 | readonly drawVerticalLine?: DrawVerticalLine, 110 | }; 111 | 112 | export type TableUserConfig = BaseUserConfig & { 113 | 114 | /** 115 | * The header configuration 116 | */ 117 | readonly header?: HeaderUserConfig, 118 | 119 | /** 120 | * Used to tell whether to draw a horizontal line. 121 | * This callback is called for each non-content line of the table. 122 | * The default behavior is to always return true. 123 | */ 124 | readonly drawHorizontalLine?: DrawHorizontalLine, 125 | 126 | /** 127 | * Horizontal lines inside the table are not drawn. 128 | */ 129 | readonly singleLine?: boolean, 130 | 131 | readonly spanningCells?: SpanningCellConfig[], 132 | }; 133 | 134 | export type SpanningCellConfig = CellUserConfig & { 135 | readonly row: number, 136 | readonly col: number, 137 | readonly rowSpan?: number, 138 | readonly colSpan?: number, 139 | }; 140 | 141 | export type StreamUserConfig = BaseUserConfig & { 142 | 143 | /** 144 | * The number of columns 145 | */ 146 | readonly columnCount: number, 147 | 148 | /** 149 | * Default values for all columns. Column specific settings overwrite the default values. 150 | */ 151 | readonly columnDefault: ColumnUserConfig & { 152 | 153 | /** 154 | * The default width for each column 155 | */ 156 | readonly width: number, 157 | }, 158 | }; 159 | 160 | export type WritableStream = { 161 | readonly write: (rows: string[]) => void, 162 | }; 163 | 164 | export type Indexable = { 165 | readonly [index: number]: T, 166 | }; 167 | 168 | -------------------------------------------------------------------------------- /src/schemas/shared.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "shared.json", 3 | "$schema": "http://json-schema.org/draft-07/schema#", 4 | "definitions": { 5 | "columns": { 6 | "oneOf": [ 7 | { 8 | "type": "object", 9 | "patternProperties": { 10 | "^[0-9]+$": { 11 | "$ref": "#/definitions/column" 12 | } 13 | }, 14 | "additionalProperties": false 15 | }, 16 | { 17 | "type": "array", 18 | "items": { 19 | "$ref": "#/definitions/column" 20 | } 21 | } 22 | ] 23 | }, 24 | "column": { 25 | "type": "object", 26 | "properties": { 27 | "alignment": { 28 | "$ref": "#/definitions/alignment" 29 | }, 30 | "verticalAlignment": { 31 | "$ref": "#/definitions/verticalAlignment" 32 | }, 33 | "width": { 34 | "type": "integer", 35 | "minimum": 1 36 | }, 37 | "wrapWord": { 38 | "type": "boolean" 39 | }, 40 | "truncate": { 41 | "type": "integer" 42 | }, 43 | "paddingLeft": { 44 | "type": "integer" 45 | }, 46 | "paddingRight": { 47 | "type": "integer" 48 | } 49 | }, 50 | "additionalProperties": false 51 | }, 52 | "borders": { 53 | "type": "object", 54 | "properties": { 55 | "topBody": { 56 | "$ref": "#/definitions/border" 57 | }, 58 | "topJoin": { 59 | "$ref": "#/definitions/border" 60 | }, 61 | "topLeft": { 62 | "$ref": "#/definitions/border" 63 | }, 64 | "topRight": { 65 | "$ref": "#/definitions/border" 66 | }, 67 | "bottomBody": { 68 | "$ref": "#/definitions/border" 69 | }, 70 | "bottomJoin": { 71 | "$ref": "#/definitions/border" 72 | }, 73 | "bottomLeft": { 74 | "$ref": "#/definitions/border" 75 | }, 76 | "bottomRight": { 77 | "$ref": "#/definitions/border" 78 | }, 79 | "bodyLeft": { 80 | "$ref": "#/definitions/border" 81 | }, 82 | "bodyRight": { 83 | "$ref": "#/definitions/border" 84 | }, 85 | "bodyJoin": { 86 | "$ref": "#/definitions/border" 87 | }, 88 | "headerJoin": { 89 | "$ref": "#/definitions/border" 90 | }, 91 | "joinBody": { 92 | "$ref": "#/definitions/border" 93 | }, 94 | "joinLeft": { 95 | "$ref": "#/definitions/border" 96 | }, 97 | "joinRight": { 98 | "$ref": "#/definitions/border" 99 | }, 100 | "joinJoin": { 101 | "$ref": "#/definitions/border" 102 | }, 103 | "joinMiddleUp": { 104 | "$ref": "#/definitions/border" 105 | }, 106 | "joinMiddleDown": { 107 | "$ref": "#/definitions/border" 108 | }, 109 | "joinMiddleLeft": { 110 | "$ref": "#/definitions/border" 111 | }, 112 | "joinMiddleRight": { 113 | "$ref": "#/definitions/border" 114 | } 115 | }, 116 | "additionalProperties": false 117 | }, 118 | "border": { 119 | "type": "string" 120 | }, 121 | "alignment": { 122 | "type": "string", 123 | "enum": [ 124 | "left", 125 | "right", 126 | "center", 127 | "justify" 128 | ] 129 | }, 130 | "verticalAlignment": { 131 | "type": "string", 132 | "enum": [ 133 | "top", 134 | "middle", 135 | "bottom" 136 | ] 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /test/wrapWord.ts: -------------------------------------------------------------------------------- 1 | import { 2 | expect, 3 | } from 'chai'; 4 | import { 5 | wrapWord, 6 | } from '../src/wrapWord'; 7 | import { 8 | arrayToRed, closeBold, closeRed, openBold, openRed, stringToRed, 9 | } from './utils'; 10 | 11 | describe('wrapWord', () => { 12 | it('wraps a string at a nearest whitespace', () => { 13 | expect(wrapWord('aaa bbb', 5)).to.deep.equal(['aaa', 'bbb']); 14 | expect(wrapWord('a a a bbb', 5)).to.deep.equal(['a a a', 'bbb']); 15 | 16 | expect(wrapWord(stringToRed('aaa bbb'), 5)).to.deep.equal(arrayToRed(['aaa', 'bbb'])); 17 | expect(wrapWord(stringToRed('a a a bbb'), 5)).to.deep.equal(arrayToRed(['a a a', 'bbb'])); 18 | }); 19 | context('a single word is longer than chunk size', () => { 20 | it('cuts the word', () => { 21 | expect(wrapWord('aaaaa', 2)).to.deep.equal(['aa', 'aa', 'a']); 22 | 23 | expect(wrapWord(stringToRed('aaaaa'), 2)).to.deep.equal(arrayToRed(['aa', 'aa', 'a'])); 24 | }); 25 | }); 26 | context('empty string', () => { 27 | it('should return empty string as well', () => { 28 | expect(wrapWord('', 0)).to.deep.equal(['']); 29 | expect(wrapWord('', 1)).to.deep.equal(['']); 30 | expect(wrapWord('', 2)).to.deep.equal(['']); 31 | expect(wrapWord('', 3)).to.deep.equal(['']); 32 | }); 33 | }); 34 | context('a long word with a special character', () => { 35 | it('cuts the word at the special character', () => { 36 | expect(wrapWord('aaa\\bbb', 5)).to.deep.equal(['aaa\\', 'bbb']); 37 | expect(wrapWord('aaa/bbb', 5)).to.deep.equal(['aaa/', 'bbb']); 38 | expect(wrapWord('aaa_bbb', 5)).to.deep.equal(['aaa_', 'bbb']); 39 | expect(wrapWord('aaa-bbb', 5)).to.deep.equal(['aaa-', 'bbb']); 40 | expect(wrapWord('aaa.bbb', 5)).to.deep.equal(['aaa.', 'bbb']); 41 | expect(wrapWord('aaa,bbb', 5)).to.deep.equal(['aaa,', 'bbb']); 42 | expect(wrapWord('aaa;bbb', 5)).to.deep.equal(['aaa;', 'bbb']); 43 | 44 | expect(wrapWord(stringToRed('aaa\\bbb'), 5)).to.deep.equal(arrayToRed(['aaa\\', 'bbb'])); 45 | expect(wrapWord(stringToRed('aaa/bbb'), 5)).to.deep.equal(arrayToRed(['aaa/', 'bbb'])); 46 | expect(wrapWord(stringToRed('aaa_bbb'), 5)).to.deep.equal(arrayToRed(['aaa_', 'bbb'])); 47 | expect(wrapWord(stringToRed('aaa-bbb'), 5)).to.deep.equal(arrayToRed(['aaa-', 'bbb'])); 48 | expect(wrapWord(stringToRed('aaa.bbb'), 5)).to.deep.equal(arrayToRed(['aaa.', 'bbb'])); 49 | expect(wrapWord(stringToRed('aaa,bbb'), 5)).to.deep.equal(arrayToRed(['aaa,', 'bbb'])); 50 | expect(wrapWord(stringToRed('aaa;bbb'), 5)).to.deep.equal(arrayToRed(['aaa;', 'bbb'])); 51 | }); 52 | }); 53 | context('a special character after the length of a container', () => { 54 | it('does not include special character', () => { 55 | expect(wrapWord('aa-bbbbb-cccc', 5)).to.deep.equal(['aa-', 'bbbbb', '-cccc']); 56 | 57 | expect(wrapWord(stringToRed('aa-bbbbb-cccc'), 5)).to.deep.equal(arrayToRed(['aa-', 'bbbbb', '-cccc'])); 58 | }); 59 | }); 60 | 61 | context('mixed ansi and plain', () => { 62 | it('returns proper strings', () => { 63 | expect(wrapWord(`${openRed}Lorem ${closeRed}ipsum dolor ${openRed}sit amet${closeRed}`, 5)).to.deep.equal([ 64 | `${openRed}Lorem${closeRed}`, 65 | 'ipsum', 66 | 'dolor', 67 | `${openRed}sit${closeRed}`, 68 | `${openRed}amet${closeRed}`, 69 | ]); 70 | 71 | expect(wrapWord(`${openRed}Lorem ${closeRed}ipsum dolor ${openRed}sit amet${closeRed}`, 11)).to.deep.equal([ 72 | `${openRed}Lorem ${closeRed}ipsum`, 73 | `dolor ${openRed}sit${closeRed}`, 74 | `${openRed}amet${closeRed}`, 75 | ]); 76 | 77 | expect(wrapWord(`${openRed}Lorem ip${closeRed}sum dolor si${openRed}t amet${closeRed}`, 5)).to.deep.equal([ 78 | `${openRed}Lorem${closeRed}`, 79 | `${openRed}ip${closeRed}sum`, 80 | 'dolor', 81 | `si${openRed}t${closeRed}`, 82 | `${openRed}amet${closeRed}`, 83 | ]); 84 | }); 85 | }); 86 | 87 | context('multiple ansi', () => { 88 | it('returns proper strings', () => { 89 | expect(wrapWord(`${openBold}${openRed}Lorem ipsum dolor sit${closeRed}${closeBold}`, 4)).to.deep.equal( 90 | [ 91 | `${openBold}${openRed}Lore${closeRed}${closeBold}`, 92 | `${openBold}${openRed}m${closeRed}${closeBold}`, 93 | `${openBold}${openRed}ipsu${closeRed}${closeBold}`, 94 | `${openBold}${openRed}m${closeRed}${closeBold}`, 95 | `${openBold}${openRed}dolo${closeRed}${closeBold}`, 96 | `${openBold}${openRed}r${closeRed}${closeBold}`, 97 | `${openBold}${openRed}sit${closeBold}${closeRed}`], 98 | ); 99 | 100 | expect(wrapWord(`${openBold}${openRed}Lorem ipsum dolor sit${closeRed}${closeBold}`, 5)).to.deep.equal( 101 | [ 102 | `${openBold}${openRed}Lorem${closeRed}${closeBold}`, 103 | `${openBold}${openRed}ipsum${closeRed}${closeBold}`, 104 | `${openBold}${openRed}dolor${closeRed}${closeBold}`, 105 | `${openBold}${openRed}sit${closeBold}${closeRed}`], 106 | ); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /test/drawBorder.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys-fix/sort-keys-fix */ 2 | 3 | import { 4 | expect, 5 | } from 'chai'; 6 | import type { 7 | DrawVerticalLine, 8 | } from '../src'; 9 | import { 10 | getBorderCharacters, 11 | } from '../src'; 12 | import { 13 | drawBorder, 14 | drawBorderTop, 15 | drawBorderJoin, 16 | drawBorderBottom, 17 | createTableBorderGetter, 18 | } from '../src/drawBorder'; 19 | import { 20 | makeTableConfig, 21 | } from '../src/makeTableConfig'; 22 | import type { 23 | TableConfig, 24 | } from '../src/types/internal'; 25 | 26 | const defaultBorderConfig = getBorderCharacters('honeywell'); 27 | 28 | const defaultDrawVerticalLine: DrawVerticalLine = () => { 29 | return true; 30 | }; 31 | 32 | const customDrawVerticalLine: DrawVerticalLine = (index, size) => { 33 | return index === size - 1; 34 | }; 35 | 36 | context('drawBorder', () => { 37 | it('draws a border using parts', () => { 38 | const config = { 39 | drawVerticalLine: defaultDrawVerticalLine, 40 | separator: { 41 | left: '╔', 42 | right: '╗', 43 | body: '═', 44 | join: '╤', 45 | }, 46 | }; 47 | 48 | expect(drawBorder([1], config)).to.equal('╔═╗\n'); 49 | expect(drawBorder([1, 1], config)).to.equal('╔═╤═╗\n'); 50 | expect(drawBorder([5, 10], config)).to.equal('╔═════╤══════════╗\n'); 51 | 52 | expect(drawBorder([5, 10], 53 | { 54 | ...config, 55 | drawVerticalLine: customDrawVerticalLine, 56 | })).to.equal('═════╤══════════\n'); 57 | }); 58 | }); 59 | 60 | context('drawBorderTop', () => { 61 | it('draws a border using parts', () => { 62 | const config: Parameters[1] = { 63 | border: { 64 | ...defaultBorderConfig, 65 | topLeft: '╔', 66 | topRight: '╗', 67 | topBody: '═', 68 | topJoin: '╤', 69 | }, 70 | drawVerticalLine: defaultDrawVerticalLine, 71 | }; 72 | 73 | expect(drawBorderTop([1], config)).to.equal('╔═╗\n'); 74 | expect(drawBorderTop([1, 1], config)).to.equal('╔═╤═╗\n'); 75 | expect(drawBorderTop([5, 10], config)).to.equal('╔═════╤══════════╗\n'); 76 | 77 | expect(drawBorderTop([5, 10], 78 | { 79 | ...config, 80 | drawVerticalLine: customDrawVerticalLine, 81 | })).to.equal('═════╤══════════\n'); 82 | }); 83 | 84 | it('no leading new line if borderless', () => { 85 | const config = { 86 | border: { 87 | ...defaultBorderConfig, 88 | topLeft: '', 89 | topRight: '', 90 | topBody: '', 91 | topJoin: '', 92 | }, 93 | drawVerticalLine: defaultDrawVerticalLine, 94 | }; 95 | 96 | expect(drawBorderTop([1], config)).to.equal(''); 97 | expect(drawBorderTop([1, 1], config)).to.equal(''); 98 | expect(drawBorderTop([5, 10], config)).to.equal(''); 99 | 100 | expect(drawBorderTop([5, 10], 101 | { 102 | ...config, 103 | drawVerticalLine: customDrawVerticalLine, 104 | })).to.equal(''); 105 | }); 106 | }); 107 | 108 | context('drawBorderJoin', () => { 109 | it('draws a border using parts', () => { 110 | const config = { 111 | border: { 112 | ...defaultBorderConfig, 113 | joinBody: '─', 114 | joinLeft: '╟', 115 | joinRight: '╢', 116 | joinJoin: '┼', 117 | }, 118 | drawVerticalLine: defaultDrawVerticalLine, 119 | }; 120 | 121 | expect(drawBorderJoin([1], config)).to.equal('╟─╢\n'); 122 | expect(drawBorderJoin([1, 1], config)).to.equal('╟─┼─╢\n'); 123 | expect(drawBorderJoin([5, 10], config)).to.equal('╟─────┼──────────╢\n'); 124 | 125 | expect(drawBorderJoin([5, 10], 126 | { 127 | ...config, 128 | drawVerticalLine: customDrawVerticalLine, 129 | })).to.equal('─────┼──────────\n'); 130 | }); 131 | }); 132 | 133 | context('drawBorderBottom', () => { 134 | it('draws a border using parts', () => { 135 | const config = { 136 | border: { 137 | ...defaultBorderConfig, 138 | bottomBody: '═', 139 | bottomJoin: '╧', 140 | bottomLeft: '╚', 141 | bottomRight: '╝', 142 | }, 143 | drawVerticalLine: defaultDrawVerticalLine, 144 | }; 145 | 146 | expect(drawBorderBottom([1], config)).to.equal('╚═╝\n'); 147 | expect(drawBorderBottom([1, 1], config)).to.equal('╚═╧═╝\n'); 148 | expect(drawBorderBottom([5, 10], config)).to.equal('╚═════╧══════════╝\n'); 149 | 150 | expect(drawBorderBottom([5, 10], 151 | { 152 | ...config, 153 | drawVerticalLine: customDrawVerticalLine, 154 | })).to.equal('═════╧══════════\n'); 155 | }); 156 | }); 157 | 158 | context('tableBorderGetter', () => { 159 | context('when config.header is undefined', () => { 160 | it('draw the table border normally', () => { 161 | const config: TableConfig = makeTableConfig([['a', 'b', 'c']], { 162 | header: undefined, 163 | }); 164 | 165 | const getter = createTableBorderGetter([2, 1, 3], config); 166 | 167 | expect(getter(0, 3)).to.equal('╔══╤═╤═══╗\n'); 168 | expect(getter(1, 3)).to.equal('╟──┼─┼───╢\n'); 169 | expect(getter(2, 3)).to.equal('╟──┼─┼───╢\n'); 170 | expect(getter(3, 3)).to.equal('╚══╧═╧═══╝\n'); 171 | }); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /test/drawHeader.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-nested-callbacks */ 2 | 3 | import type { 4 | TableUserConfig, 5 | } from '../src'; 6 | import { 7 | table, 8 | } from '../src'; 9 | import type { 10 | Row, 11 | } from '../src/types/internal'; 12 | import { 13 | closeBold, 14 | closeRed, 15 | expectTable, 16 | openBold, 17 | openRed, 18 | } from './utils'; 19 | 20 | const basicContent = 'Lorem ipsum dolor sit amet'; 21 | 22 | const basicRows: Row[] = [['aaa', 'bb', 'c']]; 23 | 24 | const createHeader = (headerConfig?: TableUserConfig['header']): string => { 25 | return table(basicRows, {header: headerConfig}); 26 | }; 27 | 28 | context('drawHeader', () => { 29 | context('when no given header', () => { 30 | it('throws an error', () => { 31 | const header = createHeader(undefined); 32 | 33 | expectTable(header, ` 34 | ╔═════╤════╤═══╗ 35 | ║ aaa │ bb │ c ║ 36 | ╚═════╧════╧═══╝`); 37 | }); 38 | }); 39 | 40 | context('truncate', () => { 41 | it('truncates to the truncation value', () => { 42 | const header = createHeader({ 43 | content: basicContent, 44 | truncate: 7, 45 | }); 46 | 47 | expectTable(header, ` 48 | ╔══════════════╗ 49 | ║ Lorem … ║ 50 | ╟─────┬────┬───╢ 51 | ║ aaa │ bb │ c ║ 52 | ╚═════╧════╧═══╝ 53 | `); 54 | }); 55 | }); 56 | 57 | context('wrapWord', () => { 58 | context('wrapWord = false', () => { 59 | it('slices to the given width', () => { 60 | const header = createHeader({ 61 | content: basicContent, 62 | wrapWord: false, 63 | }); 64 | 65 | expectTable(header, ` 66 | ╔══════════════╗ 67 | ║ Lorem ipsum ║ 68 | ║ dolor sit am ║ 69 | ║ et ║ 70 | ╟─────┬────┬───╢ 71 | ║ aaa │ bb │ c ║ 72 | ╚═════╧════╧═══╝ 73 | `); 74 | }); 75 | }); 76 | 77 | context('wrapWord = true', () => { 78 | it('wraps word properly', () => { 79 | const header = createHeader({ 80 | content: basicContent, 81 | wrapWord: true, 82 | }); 83 | 84 | expectTable(header, ` 85 | ╔══════════════╗ 86 | ║ Lorem ipsum ║ 87 | ║ dolor sit ║ 88 | ║ amet ║ 89 | ╟─────┬────┬───╢ 90 | ║ aaa │ bb │ c ║ 91 | ╚═════╧════╧═══╝ 92 | `); 93 | }); 94 | }); 95 | }); 96 | 97 | context('alignment', () => { 98 | context('left', () => { 99 | it('aligns left', () => { 100 | const header = createHeader({ 101 | alignment: 'left', 102 | content: basicContent, 103 | wrapWord: true, 104 | }); 105 | 106 | expectTable(header, ` 107 | ╔══════════════╗ 108 | ║ Lorem ipsum ║ 109 | ║ dolor sit ║ 110 | ║ amet ║ 111 | ╟─────┬────┬───╢ 112 | ║ aaa │ bb │ c ║ 113 | ╚═════╧════╧═══╝ 114 | `); 115 | }); 116 | }); 117 | 118 | context('center', () => { 119 | it('aligns center', () => { 120 | const header = createHeader({ 121 | alignment: 'center', 122 | content: basicContent, 123 | wrapWord: true, 124 | }); 125 | 126 | expectTable(header, ` 127 | ╔══════════════╗ 128 | ║ Lorem ipsum ║ 129 | ║ dolor sit ║ 130 | ║ amet ║ 131 | ╟─────┬────┬───╢ 132 | ║ aaa │ bb │ c ║ 133 | ╚═════╧════╧═══╝ 134 | `); 135 | }); 136 | }); 137 | 138 | context('right', () => { 139 | it('aligns right', () => { 140 | const header = createHeader({ 141 | alignment: 'right', 142 | content: basicContent, 143 | wrapWord: true, 144 | }); 145 | 146 | expectTable(header, ` 147 | ╔══════════════╗ 148 | ║ Lorem ipsum ║ 149 | ║ dolor sit ║ 150 | ║ amet ║ 151 | ╟─────┬────┬───╢ 152 | ║ aaa │ bb │ c ║ 153 | ╚═════╧════╧═══╝ 154 | `); 155 | }); 156 | }); 157 | 158 | context('justify', () => { 159 | it('aligns justify', () => { 160 | const header = createHeader({ 161 | alignment: 'justify', 162 | content: basicContent, 163 | wrapWord: true, 164 | }); 165 | 166 | expectTable(header, ` 167 | ╔══════════════╗ 168 | ║ Lorem ipsum ║ 169 | ║ dolor sit ║ 170 | ║ amet ║ 171 | ╟─────┬────┬───╢ 172 | ║ aaa │ bb │ c ║ 173 | ╚═════╧════╧═══╝ 174 | `); 175 | }); 176 | }); 177 | }); 178 | 179 | context('padding', () => { 180 | it('pads properly', () => { 181 | const header = createHeader({ 182 | content: basicContent, 183 | paddingLeft: 2, 184 | paddingRight: 3, 185 | }); 186 | 187 | expectTable(header, ` 188 | ╔══════════════╗ 189 | ║ Lorem ips ║ 190 | ║ um dolor ║ 191 | ║ sit amet ║ 192 | ╟─────┬────┬───╢ 193 | ║ aaa │ bb │ c ║ 194 | ╚═════╧════╧═══╝ 195 | `); 196 | }); 197 | }); 198 | 199 | context('mixed with ansi word', () => { 200 | it('works properly', () => { 201 | const header = createHeader({ 202 | content: `${openBold}This is the header with ${openRed}ansi words${closeRed}${closeBold}`, 203 | wrapWord: true, 204 | }); 205 | 206 | expectTable(header, ` 207 | ╔══════════════╗ 208 | ║ ${openBold}This is the${closeBold} ║ 209 | ║ ${openBold}header with${closeBold} ║ 210 | ║ ${openBold}${openRed}ansi words${closeBold}${closeRed} ║ 211 | ╟─────┬────┬───╢ 212 | ║ aaa │ bb │ c ║ 213 | ╚═════╧════╧═══╝ 214 | `); 215 | }); 216 | }); 217 | }); 218 | -------------------------------------------------------------------------------- /src/spanningCellManager.ts: -------------------------------------------------------------------------------- 1 | import { 2 | wrapRangeContent, alignVerticalRangeContent, 3 | } from './alignSpanningCell'; 4 | import { 5 | calculateSpanningCellWidth, 6 | } from './calculateSpanningCellWidth'; 7 | import { 8 | makeRangeConfig, 9 | } from './makeRangeConfig'; 10 | import type { 11 | DrawHorizontalLine, 12 | DrawVerticalLine, 13 | SpanningCellConfig, 14 | } from './types/api'; 15 | import type { 16 | CellCoordinates, 17 | ColumnConfig, 18 | RangeConfig, 19 | ResolvedRangeConfig, 20 | Row, 21 | } from './types/internal'; 22 | import { 23 | areCellEqual, 24 | flatten, 25 | isCellInRange, sequence, sumArray, 26 | } from './utils'; 27 | 28 | export type SpanningCellManager = { 29 | getContainingRange: (cell: CellCoordinates, options?: {mapped: true, }) => ResolvedRangeConfig | undefined, 30 | inSameRange: (cell1: CellCoordinates, cell2: CellCoordinates) => boolean, 31 | rowHeights: number[], 32 | setRowHeights: (rowHeights: number[]) => void, 33 | rowIndexMapping: number[], 34 | setRowIndexMapping: (mappedRowHeights: number[]) => void, 35 | }; 36 | 37 | export type SpanningCellParameters = { 38 | spanningCellConfigs: SpanningCellConfig[], 39 | rows: Row[], 40 | columnsConfig: ColumnConfig[], 41 | drawVerticalLine: DrawVerticalLine, 42 | drawHorizontalLine: DrawHorizontalLine, 43 | }; 44 | 45 | export type SpanningCellContext = SpanningCellParameters & { 46 | rowHeights: number[], 47 | }; 48 | 49 | const findRangeConfig = (cell: CellCoordinates, rangeConfigs: RangeConfig[]): RangeConfig | undefined => { 50 | return rangeConfigs.find((rangeCoordinate) => { 51 | return isCellInRange(cell, rangeCoordinate); 52 | }); 53 | }; 54 | 55 | const getContainingRange = (rangeConfig: RangeConfig, context: SpanningCellContext): ResolvedRangeConfig | undefined => { 56 | const width = calculateSpanningCellWidth(rangeConfig, context); 57 | 58 | const wrappedContent = wrapRangeContent(rangeConfig, width, context); 59 | 60 | const alignedContent = alignVerticalRangeContent(rangeConfig, wrappedContent, context); 61 | 62 | const getCellContent = (rowIndex: number) => { 63 | const {topLeft} = rangeConfig; 64 | const {drawHorizontalLine, rowHeights} = context; 65 | 66 | const totalWithinHorizontalBorderHeight = rowIndex - topLeft.row; 67 | const totalHiddenHorizontalBorderHeight = sequence(topLeft.row + 1, rowIndex).filter((index) => { 68 | /* istanbul ignore next */ 69 | return !drawHorizontalLine?.(index, rowHeights.length); 70 | }).length; 71 | 72 | const offset = sumArray(rowHeights.slice(topLeft.row, rowIndex)) + totalWithinHorizontalBorderHeight - totalHiddenHorizontalBorderHeight; 73 | 74 | return alignedContent.slice(offset, offset + rowHeights[rowIndex]); 75 | }; 76 | 77 | const getBorderContent = (borderIndex: number) => { 78 | const {topLeft} = rangeConfig; 79 | const offset = sumArray(context.rowHeights.slice(topLeft.row, borderIndex)) + (borderIndex - topLeft.row - 1); 80 | 81 | return alignedContent[offset]; 82 | }; 83 | 84 | return { 85 | ...rangeConfig, 86 | extractBorderContent: getBorderContent, 87 | extractCellContent: getCellContent, 88 | height: wrappedContent.length, 89 | width, 90 | }; 91 | }; 92 | 93 | const inSameRange = (cell1: CellCoordinates, cell2: CellCoordinates, ranges: RangeConfig[]): boolean => { 94 | const range1 = findRangeConfig(cell1, ranges); 95 | const range2 = findRangeConfig(cell2, ranges); 96 | 97 | if (range1 && range2) { 98 | return areCellEqual(range1.topLeft, range2.topLeft); 99 | } 100 | 101 | return false; 102 | }; 103 | 104 | const hashRange = (range: RangeConfig): string => { 105 | const {row, col} = range.topLeft; 106 | 107 | return `${row}/${col}`; 108 | }; 109 | 110 | export const createSpanningCellManager = (parameters: SpanningCellParameters): SpanningCellManager => { 111 | const {spanningCellConfigs, columnsConfig} = parameters; 112 | const ranges = spanningCellConfigs.map((config) => { 113 | return makeRangeConfig(config, columnsConfig); 114 | }); 115 | 116 | const rangeCache: Record = {}; 117 | 118 | let rowHeights: number[] = []; 119 | let rowIndexMapping: number[] = []; 120 | 121 | return {getContainingRange: (cell, options) => { 122 | const originalRow = options?.mapped ? rowIndexMapping[cell.row] : cell.row; 123 | 124 | const range = findRangeConfig({...cell, 125 | row: originalRow}, ranges); 126 | if (!range) { 127 | return undefined; 128 | } 129 | 130 | if (rowHeights.length === 0) { 131 | return getContainingRange(range, {...parameters, 132 | rowHeights}); 133 | } 134 | 135 | const hash = hashRange(range); 136 | rangeCache[hash] ??= getContainingRange(range, {...parameters, 137 | rowHeights}); 138 | 139 | return rangeCache[hash]; 140 | }, 141 | inSameRange: (cell1, cell2) => { 142 | return inSameRange(cell1, cell2, ranges); 143 | }, 144 | rowHeights, 145 | rowIndexMapping, 146 | setRowHeights: (_rowHeights: number[]) => { 147 | rowHeights = _rowHeights; 148 | }, 149 | setRowIndexMapping: (mappedRowHeights: number[]) => { 150 | rowIndexMapping = flatten(mappedRowHeights.map((height, index) => { 151 | return Array.from({length: height}, () => { 152 | return index; 153 | }); 154 | })); 155 | }}; 156 | }; 157 | -------------------------------------------------------------------------------- /test/table.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-nested-callbacks */ 2 | 3 | import { 4 | expect, 5 | } from 'chai'; 6 | import { 7 | table, 8 | } from '../src'; 9 | import type { 10 | TableUserConfig, 11 | } from '../src'; 12 | import { 13 | expectTable, 14 | } from './utils'; 15 | 16 | const data = [ 17 | ['Lorem ipsum', 'dolor sit'], 18 | ['amet', 'consectetur'], 19 | ['adipiscing', 'elit'], 20 | ]; 21 | 22 | const basicConfig: TableUserConfig = { 23 | columnDefault: { 24 | width: 5, 25 | }, 26 | }; 27 | 28 | describe('drawTable', () => { 29 | describe('drawHorizontalLine', () => { 30 | context('only draw top and bottom borders', () => { 31 | it('draws proper borders', () => { 32 | const config: TableUserConfig = { 33 | ...basicConfig, 34 | drawHorizontalLine: (index, size) => { 35 | return index === 0 || index === size; 36 | }, 37 | }; 38 | 39 | expect(table(data, config)).to.be.deep.equal(` 40 | ╔═══════╤═══════╗ 41 | ║ Lorem │ dolor ║ 42 | ║ ipsum │ sit ║ 43 | ║ amet │ conse ║ 44 | ║ │ ctetu ║ 45 | ║ │ r ║ 46 | ║ adipi │ elit ║ 47 | ║ scing │ ║ 48 | ╚═══════╧═══════╝ 49 | `.trimLeft()); 50 | }); 51 | }); 52 | 53 | context('only draw inner borders', () => { 54 | it('draws proper borders', () => { 55 | const config: TableUserConfig = { 56 | ...basicConfig, 57 | drawHorizontalLine: (index, size) => { 58 | return index > 0 && index < size; 59 | }, 60 | }; 61 | 62 | expect(table(data, config)).to.be.deep.equal(` 63 | ║ Lorem │ dolor ║ 64 | ║ ipsum │ sit ║ 65 | ╟───────┼───────╢ 66 | ║ amet │ conse ║ 67 | ║ │ ctetu ║ 68 | ║ │ r ║ 69 | ╟───────┼───────╢ 70 | ║ adipi │ elit ║ 71 | ║ scing │ ║ 72 | `.trimLeft()); 73 | }); 74 | }); 75 | 76 | context('only draw top and next-to-last borders', () => { 77 | it('draws proper borders', () => { 78 | const config: TableUserConfig = { 79 | ...basicConfig, 80 | drawHorizontalLine: (index, size) => { 81 | return index === 0 || index === size - 1; 82 | }, 83 | }; 84 | 85 | expect(table(data, config)).to.be.deep.equal(` 86 | ╔═══════╤═══════╗ 87 | ║ Lorem │ dolor ║ 88 | ║ ipsum │ sit ║ 89 | ║ amet │ conse ║ 90 | ║ │ ctetu ║ 91 | ║ │ r ║ 92 | ╟───────┼───────╢ 93 | ║ adipi │ elit ║ 94 | ║ scing │ ║ 95 | `.trimLeft()); 96 | }); 97 | }); 98 | }); 99 | 100 | context('header', () => { 101 | it('draw properly', () => { 102 | const config: TableUserConfig = { 103 | ...basicConfig, 104 | header: { 105 | content: 'This is the long long header', 106 | }, 107 | }; 108 | 109 | expect(table(data, config)).to.be.deep.equal(` 110 | ╔═══════════════╗ 111 | ║ This is the l ║ 112 | ║ ong long head ║ 113 | ║ er ║ 114 | ╟───────┬───────╢ 115 | ║ Lorem │ dolor ║ 116 | ║ ipsum │ sit ║ 117 | ╟───────┼───────╢ 118 | ║ amet │ conse ║ 119 | ║ │ ctetu ║ 120 | ║ │ r ║ 121 | ╟───────┼───────╢ 122 | ║ adipi │ elit ║ 123 | ║ scing │ ║ 124 | ╚═══════╧═══════╝ 125 | `.trimLeft()); 126 | }); 127 | }); 128 | 129 | context('vertical alignment', () => { 130 | // eslint-disable-next-line @typescript-eslint/no-shadow 131 | const data = [ 132 | ['Lorem ipsum dolor sit amet, consectetur adipiscing elit', 133 | 'Phasellus pulvinar nibh sed', 134 | 'Phasellus pulvinar nibh sed', 135 | 'Phasellus pulvinar nibh sed'], 136 | ]; 137 | it('works properly', () => { 138 | const result = table(data, { 139 | columnDefault: { 140 | width: 10, 141 | }, 142 | columns: [ 143 | {}, 144 | {verticalAlignment: 'top'}, 145 | {verticalAlignment: 'middle'}, 146 | {verticalAlignment: 'bottom'}], 147 | }); 148 | 149 | expectTable(result, ` 150 | ╔════════════╤════════════╤════════════╤════════════╗ 151 | ║ Lorem ipsu │ Phasellus │ │ ║ 152 | ║ m dolor si │ pulvinar n │ Phasellus │ ║ 153 | ║ t amet, co │ ibh sed │ pulvinar n │ ║ 154 | ║ nsectetur │ │ ibh sed │ Phasellus ║ 155 | ║ adipiscing │ │ │ pulvinar n ║ 156 | ║ elit │ │ │ ibh sed ║ 157 | ╚════════════╧════════════╧════════════╧════════════╝`); 158 | }); 159 | 160 | it('works with horizontal alignment', () => { 161 | const result = table(data, { 162 | columnDefault: { 163 | width: 8, 164 | wrapWord: true, 165 | }, 166 | columns: [ 167 | {}, 168 | {alignment: 'center', 169 | verticalAlignment: 'top'}, 170 | {alignment: 'right', 171 | verticalAlignment: 'middle'}, 172 | {alignment: 'left', 173 | verticalAlignment: 'bottom'}], 174 | }); 175 | 176 | expectTable(result, ` 177 | ╔══════════╤══════════╤══════════╤══════════╗ 178 | ║ Lorem │ Phasellu │ │ ║ 179 | ║ ipsum │ s │ │ ║ 180 | ║ dolor │ pulvinar │ Phasellu │ ║ 181 | ║ sit │ nibh sed │ s │ ║ 182 | ║ amet, │ │ pulvinar │ ║ 183 | ║ consecte │ │ nibh sed │ Phasellu ║ 184 | ║ tur │ │ │ s ║ 185 | ║ adipisci │ │ │ pulvinar ║ 186 | ║ ng elit │ │ │ nibh sed ║ 187 | ╚══════════╧══════════╧══════════╧══════════╝`); 188 | }); 189 | }); 190 | 191 | context('readonly array data type input', () => { 192 | it('works properly', () => { 193 | const dataReadonly = data as ReadonlyArray; 194 | 195 | const result = table(dataReadonly); 196 | 197 | expectTable(result, ` 198 | ╔═════════════╤═════════════╗ 199 | ║ Lorem ipsum │ dolor sit ║ 200 | ╟─────────────┼─────────────╢ 201 | ║ amet │ consectetur ║ 202 | ╟─────────────┼─────────────╢ 203 | ║ adipiscing │ elit ║ 204 | ╚═════════════╧═════════════╝`); 205 | }); 206 | }); 207 | }); 208 | -------------------------------------------------------------------------------- /test/tableConfigSamples.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TableUserConfig, 3 | } from '../src'; 4 | 5 | export const tableConfigSamples: {invalid: unknown[], valid: TableUserConfig[], } = { 6 | invalid: [ 7 | {border: 1}, 8 | {border: {unknown: '-'}}, 9 | {border: {topBody: 1}}, 10 | {border: {topJoin: 1}}, 11 | {border: {topLeft: 1}}, 12 | {border: {topRight: 1}}, 13 | {border: {bottomBody: 1}}, 14 | {border: {bottomJoin: 1}}, 15 | {border: {bottomLeft: 1}}, 16 | {border: {bottomRight: 1}}, 17 | {border: {bodyLeft: 1}}, 18 | {border: {bodyRight: 1}}, 19 | {border: {bodyJoin: 1}}, 20 | {border: {joinBody: 1}}, 21 | {border: {joinLeft: 1}}, 22 | {border: {joinRight: 1}}, 23 | {border: {joinJoin: 1}}, 24 | {columns: 1}, 25 | {columns: {a: {width: 5}}}, 26 | {columns: {1: 1}}, 27 | {columns: {1: {unknown: 1}}}, 28 | {columns: {1: {alignment: 1}}}, 29 | {columns: {1: {alignment: '1'}}}, 30 | {columns: {0: {alignment: 'middle'}}}, 31 | {columns: {1: {verticalAlignment: 1}}}, 32 | {columns: {1: {verticalAlignment: '1'}}}, 33 | {columns: {0: {verticalAlignment: 'center'}}}, 34 | {columns: {1: {width: 0}}}, 35 | {columns: {1: {width: 1.5}}}, 36 | {columns: {1: {width: '5'}}}, 37 | {columns: {1: {wrapWord: 1}}}, 38 | {columns: {1: {wrapWord: 'true'}}}, 39 | {columns: {1: {truncate: '1'}}}, 40 | {columns: {1: {paddingLeft: '1'}}}, 41 | {columns: {1: {paddingRight: '1'}}}, 42 | {columns: [1]}, 43 | {columns: ['']}, 44 | {columns: [{unknown: 1}]}, 45 | {columnDefault: 1}, 46 | {columnDefault: {unknown: 1}}, 47 | {columnDefault: {alignment: 1}}, 48 | {columnDefault: {alignment: '1'}}, 49 | {columnDefault: {alignment: 'middle'}}, 50 | {columnDefault: {verticalAlignment: 1}}, 51 | {columnDefault: {verticalAlignment: '1'}}, 52 | {columnDefault: {verticalAlignment: 'center'}}, 53 | {columnDefault: {width: 0}}, 54 | {columnDefault: {width: 1.5}}, 55 | {columnDefault: {width: '5'}}, 56 | {columnDefault: {wrapWord: 1}}, 57 | {columnDefault: {wrapWord: 'true'}}, 58 | {columnDefault: {truncate: '1'}}, 59 | {columnDefault: {paddingLeft: '1'}}, 60 | {columnDefault: {paddingRight: '1'}}, 61 | {drawHorizontalLine: 1}, 62 | {unknown: 1}, 63 | {header: 'a'}, 64 | {header: {unknown: 'x'}}, 65 | {header: {content: 1}}, 66 | {header: {content: 'x', 67 | width: 10}}, 68 | ], 69 | valid: [ 70 | {}, 71 | { 72 | columns: { 73 | 0: { 74 | alignment: 'left', 75 | verticalAlignment: 'bottom', 76 | width: 10, 77 | }, 78 | 1: { 79 | alignment: 'center', 80 | verticalAlignment: 'middle', 81 | width: 10, 82 | }, 83 | 2: { 84 | alignment: 'right', 85 | verticalAlignment: 'top', 86 | width: 10, 87 | }, 88 | }, 89 | }, 90 | { 91 | border: { 92 | bodyJoin: '│', 93 | bodyLeft: '│', 94 | bodyRight: '│', 95 | bottomBody: '─', 96 | bottomJoin: '┴', 97 | bottomLeft: '└', 98 | bottomRight: '┘', 99 | joinBody: '─', 100 | joinJoin: '┼', 101 | joinLeft: '├', 102 | joinRight: '┤', 103 | topBody: '─', 104 | topJoin: '┬', 105 | topLeft: '┌', 106 | topRight: '┐', 107 | }, 108 | }, 109 | { 110 | columns: { 111 | 0: { 112 | paddingLeft: 3, 113 | }, 114 | 1: { 115 | paddingRight: 3, 116 | width: 2, 117 | }, 118 | }, 119 | }, 120 | { 121 | border: {}, 122 | columnDefault: { 123 | paddingLeft: 0, 124 | paddingRight: 1, 125 | }, 126 | drawHorizontalLine: () => { 127 | return false; 128 | }, 129 | }, 130 | 131 | { 132 | columnDefault: { 133 | width: 50, 134 | }, 135 | columns: { 136 | 0: { 137 | alignment: 'right', 138 | width: 10, 139 | }, 140 | 1: { 141 | alignment: 'center', 142 | }, 143 | 2: { 144 | width: 10, 145 | }, 146 | }, 147 | }, 148 | {columns: {0: {alignment: 'left'}}}, 149 | {columns: {1: {alignment: 'right'}}}, 150 | {columns: {2: {alignment: 'center'}}}, 151 | {columns: {3: {alignment: 'justify'}}}, 152 | {columns: {0: {verticalAlignment: 'top'}}}, 153 | {columns: {1: {verticalAlignment: 'middle'}}}, 154 | {columns: {2: {verticalAlignment: 'bottom'}}}, 155 | {border: {topBody: '-'}}, 156 | {border: {topJoin: '-'}}, 157 | {border: {topLeft: '-'}}, 158 | {border: {topRight: '-'}}, 159 | {border: {bottomBody: '-'}}, 160 | {border: {bottomJoin: '-'}}, 161 | {border: {bottomLeft: '-'}}, 162 | {border: {bottomRight: '-'}}, 163 | {border: {bodyLeft: '-'}}, 164 | {border: {bodyRight: '-'}}, 165 | {border: {bodyJoin: '-'}}, 166 | {border: {joinBody: '-'}}, 167 | {border: {joinLeft: '-'}}, 168 | {border: {joinRight: '-'}}, 169 | {border: {joinJoin: '-'}}, 170 | {columns: {1: {alignment: 'left'}}}, 171 | {columns: {1: {width: 1}}}, 172 | {columns: {1: {width: 5}}}, 173 | {columns: {1: {wrapWord: true}}}, 174 | {columns: {1: {wrapWord: false}}}, 175 | {columns: {1: {truncate: 1}}}, 176 | {columns: {1: {paddingLeft: 1}}}, 177 | {columns: {1: {paddingRight: 1}}}, 178 | {columns: []}, 179 | {columns: [{width: 5}]}, 180 | {columns: [{wrapWord: true}, {truncate: 1}]}, 181 | {columns: [{paddingLeft: 1}, {paddingRight: 1}, {alignment: 'right'}]}, 182 | {columnDefault: {alignment: 'left'}}, 183 | {columnDefault: {width: 1}}, 184 | {columnDefault: {width: 5}}, 185 | {columnDefault: {wrapWord: true}}, 186 | {columnDefault: {wrapWord: false}}, 187 | {columnDefault: {truncate: 1}}, 188 | {columnDefault: {paddingLeft: 1}}, 189 | {columnDefault: {paddingRight: 1}}, 190 | {drawHorizontalLine: (): boolean => { 191 | return false; 192 | }}, 193 | {header: {content: ''}}, 194 | {header: { 195 | alignment: 'justify', 196 | content: 'a', 197 | paddingLeft: 5, 198 | paddingRight: 8, 199 | truncate: 3, 200 | wrapWord: true, 201 | }}, 202 | ], 203 | }; 204 | -------------------------------------------------------------------------------- /test/alignString.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-nested-callbacks */ 2 | 3 | import { 4 | expect, 5 | } from 'chai'; 6 | import chalk from 'chalk'; 7 | import { 8 | alignString, 9 | } from '../src/alignString'; 10 | import { 11 | stringToRed, 12 | } from './utils'; 13 | 14 | describe('alignString', () => { 15 | context('subject parameter value width is greater than the container width', () => { 16 | it('throws an error', () => { 17 | expect(() => { 18 | alignString('aa', 1, 'left'); 19 | }).to.throw(Error, 'Subject parameter value width cannot be greater than the container width.'); 20 | }); 21 | }); 22 | 23 | context('subject parameter value', () => { 24 | context('0 width', () => { 25 | it('produces a string consisting of container width number of whitespace characters', () => { 26 | expect(alignString('', 5, 'left')).to.equal(' ', 'left'); 27 | expect(alignString('', 5, 'center')).to.equal(' ', 'center'); 28 | expect(alignString('', 5, 'justify')).to.equal(' ', 'justify'); 29 | expect(alignString('', 5, 'right')).to.equal(' ', 'right'); 30 | }); 31 | }); 32 | context('plain text', () => { 33 | context('alignment', () => { 34 | context('left', () => { 35 | it('pads the string on the right side using a whitespace character', () => { 36 | expect(alignString('aa', 6, 'left')).to.equal('aa '); 37 | }); 38 | }); 39 | context('right', () => { 40 | it('pads the string on the left side using a whitespace character', () => { 41 | expect(alignString('aa', 6, 'right')).to.equal(' aa'); 42 | }); 43 | }); 44 | context('center', () => { 45 | it('pads the string on both sides using a whitespace character', () => { 46 | expect(alignString('aa', 8, 'center')).to.equal(' aa '); 47 | }); 48 | context('uneven number of available with', () => { 49 | it('floors the available width; adds extra space to the end of the string', () => { 50 | expect(alignString('aa', 7, 'center')).to.equal(' aa '); 51 | }); 52 | }); 53 | }); 54 | 55 | context('justify', () => { 56 | it('align left if not contain spaces', () => { 57 | expect(alignString('aa', 5, 'justify')).to.equal('aa '); 58 | }); 59 | 60 | it('add missing spaces between two words', () => { 61 | expect(alignString('a a', 5, 'justify')).to.equal('a a'); 62 | expect(alignString('a a', 5, 'justify')).to.equal('a a'); 63 | expect(alignString('a a', 5, 'justify')).to.equal('a a'); 64 | }); 65 | 66 | it('multiple words, distribute spaces from left to right when maximum adding spaces in one place are not greater than 3', () => { 67 | expect(alignString('a b c', 5, 'justify')).to.equal('a b c'); 68 | expect(alignString('a b c', 6, 'justify')).to.equal('a b c'); 69 | expect(alignString('a b c', 7, 'justify')).to.equal('a b c'); 70 | expect(alignString('a b c', 8, 'justify')).to.equal('a b c'); 71 | expect(alignString('a b c', 9, 'justify')).to.equal('a b c'); 72 | expect(alignString('a b c', 10, 'justify')).to.equal('a b c'); 73 | expect(alignString('a b c', 11, 'justify')).to.equal('a b c'); 74 | expect(alignString('a b c', 12, 'justify')).to.equal('a b c '); 75 | 76 | expect(alignString('a bbb cc d', 11, 'justify')).to.equal('a bbb cc d'); 77 | expect(alignString('a bbb cc d', 12, 'justify')).to.equal('a bbb cc d'); 78 | expect(alignString('a bbb cc d', 13, 'justify')).to.equal('a bbb cc d'); 79 | expect(alignString('a bbb cc d', 14, 'justify')).to.equal('a bbb cc d'); 80 | }); 81 | }); 82 | }); 83 | }); 84 | context('text containing ANSI escape codes', () => { 85 | context('alignment', () => { 86 | context('left', () => { 87 | it('pads the string on the right side using a whitespace character', () => { 88 | expect(alignString(chalk.red('aa'), 6, 'left')).to.equal(chalk.red('aa') + ' '); 89 | }); 90 | }); 91 | context('right', () => { 92 | it('pads the string on the left side using a whitespace character', () => { 93 | expect(alignString(chalk.red('aa'), 6, 'right')).to.equal(' ' + chalk.red('aa')); 94 | }); 95 | }); 96 | context('center', () => { 97 | it('pads the string on both sides using a whitespace character', () => { 98 | expect(alignString(chalk.red('aa'), 6, 'center')).to.equal(' ' + chalk.red('aa') + ' '); 99 | }); 100 | context('uneven number of available with', () => { 101 | it('floors the available width; adds extra space to the end of the string', () => { 102 | expect(alignString(chalk.red('aa'), 7, 'center')).to.equal(' ' + chalk.red('aa') + ' '); 103 | }); 104 | }); 105 | }); 106 | context('justify', () => { 107 | it('align left if not contain spaces', () => { 108 | expect(alignString(chalk.red('aa'), 5, 'justify')).to.equal(chalk.red('aa') + ' '); 109 | }); 110 | 111 | it('add missing spaces between two words', () => { 112 | expect(alignString(stringToRed('a a'), 5, 'justify')).to.equal(stringToRed('a a')); 113 | expect(alignString(stringToRed('a a'), 5, 'justify')).to.equal(stringToRed('a a')); 114 | expect(alignString(stringToRed('a a'), 5, 'justify')).to.equal(stringToRed('a a')); 115 | }); 116 | 117 | it('multiple words, uneven spaces add from left to right', () => { 118 | expect(alignString(stringToRed('a b c'), 5, 'justify')).to.equal(stringToRed('a b c')); 119 | expect(alignString(stringToRed('a b c'), 6, 'justify')).to.equal(stringToRed('a b c')); 120 | expect(alignString(stringToRed('a b c'), 7, 'justify')).to.equal(stringToRed('a b c')); 121 | expect(alignString(stringToRed('a b c'), 8, 'justify')).to.equal(stringToRed('a b c')); 122 | expect(alignString(stringToRed('a b c'), 9, 'justify')).to.equal(stringToRed('a b c')); 123 | expect(alignString(stringToRed('a b c'), 10, 'justify')).to.equal(stringToRed('a b c')); 124 | 125 | expect(alignString(stringToRed('a bbb cc d'), 11, 'justify')).to.equal(stringToRed('a bbb cc d')); 126 | expect(alignString(stringToRed('a bbb cc d'), 12, 'justify')).to.equal(stringToRed('a bbb cc d')); 127 | expect(alignString(stringToRed('a bbb cc d'), 13, 'justify')).to.equal(stringToRed('a bbb cc d')); 128 | expect(alignString(stringToRed('a bbb cc d'), 14, 'justify')).to.equal(stringToRed('a bbb cc d')); 129 | }); 130 | }); 131 | }); 132 | }); 133 | }); 134 | }); 135 | --------------------------------------------------------------------------------