Skip to content

Commit ee38d57

Browse files
committed
feat: support counting ANSI escapes
1 parent 75f8337 commit ee38d57

8 files changed

Lines changed: 123 additions & 15 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ tablemark (input: InputData, options?: TablemarkOptions): string
7474
| :----------------------: | :------------------------------------------: | :--------------: | -------------------------------------------------------------------------------------------------- |
7575
| `align` | `"left" \| "center" \| "right"` | `"left"` | Horizontal alignment to use for all columns. |
7676
| `columns` | `Array<string \| ColumnDescriptor>` | - | Array of [column descriptors](#optionscolumns). |
77+
| `countAnsiEscapeCodes` | `boolean` | `false` | Whether to count ANSI escape codes when calculating string width. |
7778
| `headerCase` | `"preserve" \| ...` | `"sentenceCase"` | Casing to use for headers derived from input object keys ([read more](#optionsheadercase)). |
7879
| `lineBreakStrategy` | `"preserve" \| "strip" \| "truncate"` | `"preserve"` | What to do when cell content contains line breaks. |
7980
| `lineEnding` | `string` | `"\n"` | String used at end-of-line. |
@@ -169,6 +170,31 @@ tablemark(
169170
| Lee | 23 | true |
170171
<!-- prettier-ignore-end -->
171172

173+
### `options.countAnsiEscapeCodes`
174+
175+
Control whether to count ANSI escape codes when calculating string width. The default is `false`, meaning ANSI codes are ignored. Setting this to `true` is useful when the output is not intended for a terminal, such as when generating a markdown table for an example in a README file.
176+
177+
```ts
178+
const data = [
179+
{ text: "\u001B[31mRed\u001B[0m", note: "Normal text" },
180+
{ text: "\u001B[32mGreen\u001B[0m", note: "More text" }
181+
];
182+
183+
tablemark(data, { countAnsiEscapeCodes: false });
184+
185+
// | Text | Note |
186+
// | :---- | :---------- |
187+
// | Red | Normal text |
188+
// | Green | More text |
189+
190+
tablemark(data, { countAnsiEscapeCodes: true });
191+
192+
// | Text | Note |
193+
// | :------------- | :---------- |
194+
// | Red | Normal text |
195+
// | Green | More text |
196+
```
197+
172198
### `options.headerCase`
173199

174200
Control the casing of headers derived from input object keys. The default is `"sentenceCase"`. The options are:

src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export const textHandlingStrategies = {
2929

3030
export const columnsMinimumWidth = 3;
3131

32+
// oxlint-disable-next-line no-control-regex
33+
export const ansiCodeRegex = /\u001B/g;
3234
export const lineEndingRegex = /\r?\n/g;
3335

3436
export const truncationCharacter = "\u2026";

src/types.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,14 +130,20 @@ export interface TablemarkOptions<
130130
*/
131131
columns?: (ColumnDescriptor | string)[];
132132

133+
/**
134+
* Whether to count ANSI escape codes when calculating string width. The
135+
* default is `false`, meaning ANSI escape codes are treated as zero-width.
136+
*/
137+
countAnsiEscapeCodes?: boolean;
138+
133139
/**
134140
* Text casing method for header titles derived from input object keys.
135141
*/
136142
headerCase?: HeaderCase;
137143

138144
/**
139145
* What to do when cell content contains line breaks. The default is
140-
* "preserve"
146+
* "preserve".
141147
*/
142148
lineBreakStrategy?: LineBreakStrategy;
143149

@@ -159,7 +165,7 @@ export interface TablemarkOptions<
159165

160166
/**
161167
* What to do when body cell content reaches `maxWidth`. The default is
162-
* "truncateEnd".
168+
* "wrap".
163169
*/
164170
overflowStrategy?: OverflowStrategy;
165171

@@ -193,12 +199,19 @@ export interface TablemarkOptions<
193199
unknownKeyStrategy?: UnknownKeyStrategy;
194200

195201
/**
196-
* Set to `true` to enable broader support of languages, Unicode entities
197-
* like emoji and [fullwidth](https://en.wikipedia.org/wiki/Halfwidth_and_fullwidth_forms)
202+
* The default "auto" strategy detects and provides broader support of
203+
* languages, Unicode entities like emoji and
204+
* [fullwidth](https://en.wikipedia.org/wiki/Halfwidth_and_fullwidth_forms)
198205
* characters, and ANSI escape codes (like terminal colors and styles).
199206
*
200-
* By default, tablemark uses "naive" text processing that is much faster,
201-
* but is unaware of these "wider" or invisible characters.
207+
* To force this enhanced support, set this to `"advanced"`.
208+
*
209+
* To force using a faster, more "naive" strategy, set this to `"basic"`.
210+
* However, this can result in misaligned columns and improper ANSI styling.
211+
*
212+
* Note that ANSI escape codes are treated as zero-width regardless of the
213+
* strategy. If you want to change that behavior, use the
214+
* `countAnsiEscapeCodes` option.
202215
*/
203216
textHandlingStrategy?: TextHandlingStrategy;
204217

src/utilities.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import wrapAnsi from "wrap-ansi";
66

77
import {
88
alignmentOptions,
9+
ansiCodeRegex,
910
lineBreakStrategies,
1011
lineEndingRegex,
1112
overflowStrategies,
@@ -24,7 +25,10 @@ import type {
2425
} from "./types.js";
2526

2627
type StringWrapMethod = (string: string, width: number) => string[];
27-
type StringWidthMethod = (string: string) => number;
28+
type StringWidthMethod = (
29+
string: string,
30+
shouldCountAnsiEscapeCodes?: boolean
31+
) => number;
2832

2933
const pipeRegex = /\|/g;
3034
export const ansiRegex = getAnsiRegex({ onlyFirst: true });
@@ -126,12 +130,29 @@ export const getStringWrapMethod = (
126130
}
127131
};
128132

129-
export const advancedStringWidth: StringWidthMethod = (string) =>
130-
stringWidth(string);
133+
export const advancedStringWidth: StringWidthMethod = (
134+
string,
135+
shouldCountAnsiEscapeCodes = false
136+
) => {
137+
const width = stringWidth(string, {
138+
countAnsiEscapeCodes: shouldCountAnsiEscapeCodes
139+
});
140+
141+
if (shouldCountAnsiEscapeCodes) {
142+
// `stringWidth` doesn't count the ASCII escape character, but we need to if
143+
// we want to align column sides accurately
144+
return width + (string.match(ansiCodeRegex) ?? []).length;
145+
}
131146

132-
export const autoStringWidth: StringWidthMethod = (string) => {
147+
return width;
148+
};
149+
150+
export const autoStringWidth: StringWidthMethod = (
151+
string,
152+
shouldCountAnsiEscapeCodes
153+
) => {
133154
if (hasSpecialCharacters(string)) {
134-
return advancedStringWidth(string);
155+
return advancedStringWidth(string, shouldCountAnsiEscapeCodes);
135156
}
136157

137158
return string.length;
@@ -144,13 +165,13 @@ export const autoStringWidth: StringWidthMethod = (string) => {
144165
* Note that `content` is expected to be a string _not_ containing newlines.
145166
*/
146167
export const pad = (
147-
_config: TablemarkOptionsNormalized,
168+
config: TablemarkOptionsNormalized,
148169
content: string,
149170
_columnIndex: number,
150171
alignment: Alignment | undefined,
151172
width: number
152173
): string => {
153-
const contentWidth = autoStringWidth(content);
174+
const contentWidth = autoStringWidth(content, config.countAnsiEscapeCodes);
154175

155176
if (alignment == null || alignment === alignmentOptions.left) {
156177
return content + " ".repeat(Math.max(0, width - contentWidth));
@@ -182,6 +203,7 @@ export const toCellText: ToCellText = ({ value }) => {
182203
const defaultOptions: TablemarkOptionsNormalized = {
183204
align: alignmentOptions.left,
184205
columns: [],
206+
countAnsiEscapeCodes: false,
185207
headerCase: "sentenceCase",
186208
lineBreakStrategy: lineBreakStrategies.preserve,
187209
lineEnding: "\n",
@@ -309,7 +331,10 @@ export const getMaxStringWidth = (
309331
const longestLineWidth =
310332
lines.reduce<number>(
311333
(currentMax, nextString) =>
312-
Math.max(currentMax, stringWidth(nextString)),
334+
Math.max(
335+
currentMax,
336+
autoStringWidth(nextString, config.countAnsiEscapeCodes)
337+
),
313338
0
314339
) +
315340
(config.lineBreakStrategy === lineBreakStrategies.truncate
@@ -319,7 +344,7 @@ export const getMaxStringWidth = (
319344
return longestLineWidth;
320345
}
321346

322-
return stringWidth(text);
347+
return autoStringWidth(text, config.countAnsiEscapeCodes);
323348
};
324349

325350
/**
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
| Text | Note |
2+
| :---- | :---------- |
3+
| Red | Normal text |
4+
| Green | More text |
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
| Text | Note |
2+
| :------------- | :---------- |
3+
| Red | Normal text |
4+
| Green | More text |

tests/basic-usage.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,4 +391,30 @@ describe("basic usage", () => {
391391
})
392392
).toMatchFileSnapshot(snapshotFile("combo-maxWidth-overflow-lineBreak"));
393393
});
394+
395+
test("counts width of ANSI escape codes when `countAnsiEscapeCodes: false`", async () => {
396+
const data = [
397+
{ text: "\u001B[31mRed\u001B[0m", note: "Normal text" },
398+
{ text: "\u001B[32mGreen\u001B[0m", note: "More text" }
399+
];
400+
401+
await expect(
402+
tablemark(data, {
403+
countAnsiEscapeCodes: false
404+
})
405+
).toMatchFileSnapshot(snapshotFile("countAnsiEscapeCodes-false"));
406+
});
407+
408+
test("counts width of ANSI escape codes when `countAnsiEscapeCodes: true`", async () => {
409+
const data = [
410+
{ text: "\u001B[31mRed\u001B[0m", note: "Normal text" },
411+
{ text: "\u001B[32mGreen\u001B[0m", note: "More text" }
412+
];
413+
414+
await expect(
415+
tablemark(data, {
416+
countAnsiEscapeCodes: true
417+
})
418+
).toMatchFileSnapshot(snapshotFile("countAnsiEscapeCodes-true"));
419+
});
394420
});

tests/utilities.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,14 @@ test("pad (center alignment): even content width and given width", () => {
109109
).toBe(" hi ");
110110
});
111111

112+
test("advancedStringWidth: ignores ANSI escape codes when `countAnsiEscapeCodes: false`", () => {
113+
expect(utilites.advancedStringWidth("\u001B[31mRed\u001B[0m", false)).toBe(3);
114+
});
115+
116+
test("advancedStringWidth: counts ANSI escape codes when `countAnsiEscapeCodes: true`", () => {
117+
expect(utilites.advancedStringWidth("\u001B[31mRed\u001B[0m", true)).toBe(12);
118+
});
119+
112120
test("toCellText: renders its argument as a string suitable for a table cell", () => {
113121
expect(utilites.toCellText({ key: "undefined", value: undefined })).toBe("");
114122

0 commit comments

Comments
 (0)