"""文本排版: CJK 宽度感知的对齐 / 表格 / 横线 / 整块输出. [为什么单独一个文件] 面向老板的界面一律中文, 而且要对齐成表.中文是**双宽**字符, 直接 len() 排出来一定是歪的: len("状态") == 2, 但屏幕上占 4 列, 混着 ASCII 列就会越往后越乱. 内核和引导器都要打这种表, 所以抽出来只此一份实现, 不复制 (设计 02 §7 "一套职责一个文件"). [依赖] 纯 stdlib (unicodedata).引导器是零第三方依赖的, 这个文件不能破例. [宽度规则] * unicodedata.combining(c) 非 0 -> 组合符 (声调符之类), 占 0 列 * east_asian_width(c) 是 W / F -> 宽 / 全角, 占 2 列 * 其余 (含 A 歧义 / H 半角 / Na) -> 占 1 列 歧义字符按 1 列算是有意的: Linux 终端默认窄渲染, 按 2 算会多留空格. [约定] 表格类函数**返回 list[str] 而不是直接打印**, 这样调用方能自己拼标题 / 汇总行, 也能在 --json 分支里复用同一份宽度计算. """ from __future__ import annotations import sys import unicodedata # 对齐参数的字面量.不用第三方枚举 (零依赖), 也不要用 True/False 这种看不懂的写法. 左: str = "左" 右: str = "右" def 字符宽(字符: str) -> int: """单个字符在终端里占几列. 参数: 字符: 长度为 1 的字符串.传更长的串不会报错, 但结果没意义 (只按第一个属性判). 返回: 0 (组合符) / 1 (半角) / 2 (全角或宽). 说明: 只看 east_asian_width 的 W / F 两类.A (Ambiguous, 如全角破折号,某些符号) 故意按 1 列算, 因为 Linux 终端默认是窄渲染, 按 2 算会跟实际屏幕对不上. """ if unicodedata.combining(字符): return 0 if unicodedata.east_asian_width(字符) in ("W", "F"): return 2 return 1 def 显示宽度(文本: str) -> int: """整串在终端里占几列 (逐字符累加, 不是 len()). 参数: 文本: 任意字符串, 可以混中英文. 返回: 占用的列数.空串返回 0. """ 总 = 0 for 字符 in 文本: 总 += 字符宽(字符) return 总 def 截断(文本: str, 宽: int) -> str: """超宽就截断, 并在末尾补一个省略号 (省略号本身按 1 列算). 参数: 文本: 原串. 宽: 允许的最大列数. 返回: 截断后的串; 没超宽则原样返回. 宽 <= 0 返回空串. 说明: 截断点按"列"算而不是按"字符"算, 所以不会把一个全角字符砍成半截. 预留 1 列给省略号: 条件是 用 + 宽2 > 宽 - 1. """ if 宽 <= 0: return "" if 显示宽度(文本) <= 宽: return 文本 出: list[str] = [] 用 = 0 for 字符 in 文本: 宽2 = 字符宽(字符) if 用 + 宽2 > 宽 - 1: break 出.append(字符) 用 += 宽2 return "".join(出) + "…" def 填充(文本: str, 宽: int, 对齐: str = 左) -> str: """把一串补空格到指定列数 (先截断再补, 所以结果一定不超宽). 参数: 文本: 原串. 宽: 目标列数. 对齐: 左 (默认, 末尾补空格) 或 右 (开头补空格, 数字列好看). 返回: 补齐后的串, len() 可能不等于 宽, 但 显示宽度() 一定 <= 宽. """ 文本 = 截断(文本, 宽) 空 = 宽 - 显示宽度(文本) if 空 < 0: 空 = 0 if 对齐 == 右: return " " * 空 + 文本 return 文本 + " " * 空 def 横线(宽: int, 字符: str = "─") -> str: """画一条分隔线 (表头下面那条). 参数: 宽: 列数 (表格里传某一列的列宽). 字符: 线用的字符, 默认 U+2500 制表符横线 (终端里一定等宽). 返回: 重复 宽 次的字符串; 宽 <= 0 返回空串. """ if 宽 <= 0: return "" return 字符 * 宽 def 表格( 表头: list[str], 行表: list[list[str]], 对齐表: list[str] | None = None, 间隔: str = " ", ) -> list[str]: """渲染一张对齐的中文表格, 返回各行文本 (不打印). 参数: 表头: 列名列表, 长度即列数. 行表: 每行是一个字符串列表; 某行短于列数时, 缺的列按空串处理 (不报错). 对齐表: 每列的对齐方式 (左 / 右); 缺省全左. 间隔: 列与列之间塞几个空格, 默认 2 个 (中文表看着不挤). 返回: 行文本列表: [表头, 横线, 数据行...].调用方自己决定要不要再加汇总行. 说明: 列宽 = 该列表头与所有数据行里最宽的那个 (显示宽度), 全表一次性算好, 所以中途插入更长的行不会把别的行挤歪. """ 列数 = len(表头) 宽表: list[int] = [] for 列 in range(列数): 宽 = 显示宽度(表头[列]) for 行 in 行表: if 列 < len(行): 宽 = max(宽, 显示宽度(行[列])) 宽表.append(宽) 对表 = 对齐表 if 对齐表 is not None else [左] * 列数 出: list[str] = [] 出.append(间隔.join(填充(表头[列], 宽表[列]) for 列 in range(列数))) 出.append(间隔.join(横线(宽表[列]) for 列 in range(列数))) for 行 in 行表: 格: list[str] = [] for 列 in range(列数): 值 = 行[列] if 列 < len(行) else "" 对 = 对表[列] if 列 < len(对表) else 左 格.append(填充(值, 宽表[列], 对)) 出.append(间隔.join(格)) return 出 def 打印(行: list[str]) -> None: """整块输出一行行文本. 参数: 行: 要输出的每行内容 (通常直接用 表格() 的返回值 + 自己加的汇总行). 返回: None. 说明: 一次 write 写完再 flush, 不逐行 print: 逐行打印时终端会一行行挤出来, 管道重定向 (老板要 `> 文件`) 时也不会被行缓冲切得七零八落. """ sys.stdout.write("\n".join(行) + "\n") sys.stdout.flush()