← 中文资源

简体中文版本

在终端渲染对齐表格

在计算列宽时忽略 ANSI 颜色码,渲染带表头分隔线的终端表格并支持列对齐。

阅读英文原文

功能说明

该示例把二维字符串数组渲染成终端表格,支持表头、左中右对齐与自动列宽。它特别处理 ANSI 颜色转义序列:计算宽度时先剥离颜色码,只统计可见字符,否则带颜色的单元格会把整列撑歪。适合 CLI 报告、测试结果汇总与部署状态展示。

关键代码

stripAnsi 用正则删除 SGR 颜色序列。renderTable 先合并表头与数据行,取最长行的列数作为总列数,再逐列计算去除颜色码后的最大宽度。renderRow 按列宽补齐空格并用竖线连接,pad 根据对齐方式把多余空格放在左侧、右侧或两侧。表头存在时在第二行插入由横线与加号组成的分隔线。

使用注意

ANSI 正则只覆盖常见的颜色与样式序列,光标移动、超链接等控制序列需要扩展匹配规则。宽度按 JavaScript 字符串长度计算,中文、日文与表情符号的显示宽度通常大于一,可能出现错位,应改用显示宽度库。表格不自动换行,超长单元格会撑宽终端。输出没有尾随换行,方便调用方自行拼接。

验证方式

渲染带绿色 ready 与普通 failed 的两行数据,断言表格按表头对齐且颜色码不影响列宽。测试右对齐数字、居中对齐文本与空行输入。断言 stripAnsi 去除颜色后长度为五。再验证无表头、空数组与非法单元格类型分别返回空串或抛 TypeError。

代码示例

export type Alignment = "left" | "right" | "center";

export interface TableOptions {
  readonly headers?: readonly string[];
  readonly align?: readonly Alignment[];
}

const ANSI_PATTERN = /\u001B\[[0-9;]*m/g;

export function stripAnsi(value: string): string {
  return value.replace(ANSI_PATTERN, "");
}

export function renderTable(
  rows: readonly (readonly string[])[],
  options: TableOptions = {},
): string {
  if (!Array.isArray(rows)) throw new TypeError("rows must be an array");
  const headers = options.headers ?? [];
  if (!Array.isArray(headers)) throw new TypeError("headers must be an array");
  const allRows: readonly (readonly string[])[] = headers.length > 0 ? [headers, ...rows] : rows;
  if (allRows.length === 0) return "";
  const columnCount = Math.max(...allRows.map((row) => row.length));
  if (columnCount === 0) return "";
  for (const row of allRows) {
    if (!Array.isArray(row)) throw new TypeError("every row must be an array");
    for (const cell of row) {
      if (typeof cell !== "string") throw new TypeError("cells must be strings");
    }
  }
  const widths = Array.from({ length: columnCount }, (_, column) =>
    Math.max(...allRows.map((row) => stripAnsi(row[column] ?? "").length)),
  );
  const align = options.align ?? [];
  const renderRow = (row: readonly string[]): string =>
    Array.from({ length: columnCount }, (_, column) =>
      pad(row[column] ?? "", widths[column], align[column] ?? "left"),
    ).join(" | ");
  const separator = widths.map((width) => "-".repeat(width)).join("-+-");
  const lines = allRows.map(renderRow);
  if (headers.length > 0) lines.splice(1, 0, separator);
  return lines.join("\n");
}

function pad(value: string, width: number, alignment: Alignment): string {
  const missing = Math.max(0, width - stripAnsi(value).length);
  if (alignment === "right") return " ".repeat(missing) + value;
  if (alignment === "center") {
    const left = Math.floor(missing / 2);
    return " ".repeat(left) + value + " ".repeat(missing - left);
  }
  return value + " ".repeat(missing);
}

const table = renderTable(
  [
    ["\u001B[32mready\u001B[0m", "42 ms", "0.4%"],
    ["failed", "1204 ms", "12.5%"],
  ],
  { headers: ["status", "latency", "errors"], align: ["left", "right", "right"] },
);
console.log(table);
console.log(stripAnsi("\u001B[31mred\u001B[0m").length);