183 lines
6.0 KiB
Python
183 lines
6.0 KiB
Python
|
|
"""文本排版: 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()
|