上一部分把文本拆成记录、字段和键,并用流水线完成筛选、排序与聚合。现在数据已经算对了,新的问题才刚出现:同一组值怎样排成读得懂的报告,长段落怎样适应宽度,表格怎样对齐,分页怎样预览,一份标记又怎样分别生成终端文本、HTML 或 PDF。
格式化不是“让输出好看一点”这么简单。只要结果要被人阅读、被脚本继续处理或交给另一种媒介,布局就成了接口的一部分。字段被截断、中文宽度算错、小数点随 locale 改变、颜色转义被折断,都可能让内容本身失真。
我们用一条贯穿全文的判断线来处理这些问题:先确认数据内容,再声明布局规则,最后选择目标媒介。工具只是这三项契约确定之后的实现。

先看一条常见输出:
阿青 7 91.5
小林 12 8.0这里至少混着两类信息。阿青、7、91.5 是数据;列顺序、字段宽度、右对齐和小数位数是布局。如果我们靠手工空格把它们粘在一起,下游很难知道连续空格是分隔符、填充,还是某个字段的真实内容。
更可靠的做法是先保留结构,再在展示边界上格式化。例如源数据用 TAB 或明确分隔符保存,最后一步才用 column 或 printf 生成阅读版。机器接口和人类报告最好分成两个输出:前者强调可解析,后者强调可读。
一份格式契约至少回答:
终端、纯文本文件、HTML 和 PDF 对宽度的理解并不相同。终端通常按单元格显示:ASCII 字母多占一列,常见中文多占两列,TAB 跳到下一个制表位,ANSI SGR 序列改变样式却不应占可见列。纯文本文件只保存字节,不保存“这段文字应该有多宽”的统一答案。
HTML 由浏览器、CSS 和字体共同排版;PDF 则需要确定页面尺寸、字体字形与坐标。一个在终端对齐的空格表,换成比例字体后通常会散开;一份能输出 UTF-8 字节的文档,如果所选 PDF 字体没有相应字形,仍会缺字。
所以,“宽 40”必须带单位和媒介:
fold -w 40 默认谈终端显示列。fold -b -w 40 明确谈 40 个字节。printf '%40s' 的宽度语义受实现、locale 和转换类型影响。40ch 是字体中数字零的推进宽度,不等同于 40 个 Unicode 字符。如果输出中含中文、emoji、组合附加符、TAB 或 ANSI 控制序列,就不要把“字符串长度”直接当作“眼睛看到的列宽”。先在真实目标媒介预览,再决定折行和截断策略。
fmt 从文件或标准输入读取文本,把同一段落中的短行合并,再按目标宽度重新断行。它不是简单地对每一行做截断;它会先观察空行和缩进。
LC_ALL=C.utf8 fmt -w 60 notes.txt默认情况下,空行仍是段落边界;相邻行的缩进不同,就不会被随意合并。这个规则适合整理自然语言草稿,也适合保持引用块、列表说明和代码旁注的左边界。
几个常用选项解决不同意图:
-p 的价值不是“识别任意编程语言”,而是明确指定一段前缀契约。它会剥离前缀、格式化正文,再把前缀放回每条输出行。如果注释前缀不一致,先统一输入再重排。
隔离环境中对一段中英文混排文本执行 fmt -w 34,英语部分会在单词边界重新组合,但连续中文句子仍达到 62 个显示列,无空白的长单词也整行保留。原因很直接:fmt 面向段落与词边界,它不会为了凑宽度就随意从一个词或连续文本内部下刀。
这也是 fmt 和 fold 的分界:
fmt。fold。不要直接覆盖原稿。先把结果写到新文件并比较:
LC_ALL=C.utf8 fmt -w 60 draft.txt > draft.formatted.txt
diff -u draft.txt draft.formatted.txt为什么先比较:段落重排会改变物理行,代码块、地址、表格和逐行有意义的数据不应该被当成自然语言合并。fmt -s 能降低风险,但不能替你识别所有结构。

fold 不重组段落,它逐行扫描输入,把过长的行拆成多行:
LC_ALL=C.utf8 fold -w 72 input.txtGNU fold 默认按屏幕列计算。TAB 会跳到制表位,退格会让列计数减少,回车会把列位置复位。加 -b 后,它改为按字节计数,TAB、退格与回车都只算一个字节。
实测 中文ABCD<TAB>Z 在宽度 6 下:默认模式先输出 中文,再输出 ABCD,随后 TAB 和 Z 落到后续行;-b 模式则把两个中文的 6 个 UTF-8 字节放在第一行。两个结果都“宽 6”,单位却完全不同。
较新的 GNU coreutils 提供 -c 按字符计数,但 Debian 12 的 coreutils 9.1 没有这个选项。跨环境脚本应先检查:
fold --help | grep -- '--characters'可移植基线仍是 -b、-s 和 -w。不要因为网页里看到新选项,就假定长期支持发行版已经具备。
fold -s -w 12 会在上限之前寻找最后一个空白,并在该处折行:
printf '%s\n' 'alpha beta gamma delta' | fold -s -w 12如果某一段从头到上限都没有空白,它仍会在宽度处切开。隔离测试中的 verylongtoken 被拆成 verylongtoke 和 n,这不是故障,而是“硬上限优先于完整单词”的定义。
因此,选择 -s 前先问:
fold -s 的断点可能把空格留在上一行。终端颜色是另一个坑。ANSI SGR 由 ESC、[、参数和结尾字母组成,fold 不把整段识别成“零宽样式指令”。宽度 5 的实测把 ESC[31mREDTEXTESC[0m 的控制序列拆开,输出中的换行插进了样式字节。正确顺序通常是先对无样式文本折行,最后再着色;或者使用真正理解终端单元格和 ANSI 序列的渲染器。
处理中文时,常见的四个数量分别是:
e 加组合重音可被看成一个字形。wc -c、wc -m、wc -L 分别提供字节、字符和最长显示列的观察角度,但没有一个命令能替所有渲染器给出最终答案。wc -L 适合检查纯文本终端输出;HTML/PDF 仍要在对应渲染器中验证。
如果必须截断面向人的 Unicode 文本,安全顺序是:先用理解字素簇的库分段,再按目标终端的 wcwidth 规则累计列,最后添加省略号。按字节截断可能制造无效 UTF-8;按码点截断可能把组合附加符或 emoji 序列拆开。

TAB 不是固定数量的空格,而是把光标移到下一个制表位。若制表位每 8 列一个,当前在第 1 列与第 7 列时,同一个 TAB 的可见宽度不同。把 TAB 保存到文件后,接收端使用不同 tabstop,表格就会变化。
可以在最终纯文本边界显式展开:
expand -t 4 report.tabs > report.spacesANSI SGR 同样是状态协议。[31m 开启红色,[0m 重置;字节存在于输出中,却不应该占文字列。日志、邮件和重定向文件不一定解释这些序列,可能显示成乱码或让搜索变得困难。
一个实用规则是:只有 stdout 连接到交互终端且用户没有关闭颜色时,才添加 ANSI;重定向到文件、管道或测试快照时输出无样式文本。test -t 1 能判断 stdout 是否连接终端,但脚本还应尊重 NO_COLOR 或自己的 --color 选项。
column -t 会先判断每行有多少项,再计算列宽并输出表格。默认输入分隔是空白;连续空白会被当作分隔区域,不适合表达“中间有一个空字段”。对明确的竖线数据,应把输入与输出分隔分别写出:
LC_ALL=C.utf8 column -t -s '|' -o ' | ' scores.psv输入:
姓名|分数|备注
阿青|90|稳定
小林||缺分数
老周|88实测输出:
姓名 | 分数 | 备注
阿青 | 90 | 稳定
小林 | | 缺分数
老周 | 88 |小林 的第二字段存在但为空;老周 只有两个字段。表面输出都出现空白第三格,所以校验不能只看成品。进入 column 前应检查字段数和必填值,例如用 awk -F'|' 打印 NF,坏记录直接报错。
-N '姓名,分数,备注' 可以提供列名,-R 2 把第二列右对齐。数字右对齐便于比较数量级,但这只是视觉布局;它不会验证输入真的能解析成数字。

现代 util-linux column 在交互模式参考终端宽度,在非交互模式采用自己的默认输出宽度,当前文档给出的默认是 80 列。重定向后没有交互终端,不能假定它仍按刚才窗口大小排版。若输出要进入快照或文件,显式设置输出宽度,或者允许无限宽并由后续媒介负责折行。
超长单元格的策略也要写清:允许扩大整表、指定某列折行、指定某列截断,还是拒绝输入。截断适合冗长说明,不适合 ID、校验和与安全告警;这些字段少一个字符就可能变成另一条数据。
column 不是 CSV、JSON 或 SQL 结果解析器。下面的值含一个被双引号包住的逗号:
阿青,"值班,远程"使用 -s ',' 的实测会把它切成三项,因为 column 只看分隔字符,不理解 CSV 引号。结构化数据应先交给对应解析器,得到清晰字段后再排版。
pr 从标准输入或文件读取文本,向标准输出写分页结果。默认页长是 66 行,页眉占 5 行,页脚占 5 行,因此正文区默认只有 56 行。-l 设置的是整页长度,不是正文行数。
LC_ALL=C pr -l 60 -w 90 -h '夜间巡检' -n:4 report.txt > report.pages.txt-l 60:每页共 60 行。-w 90 或 -W 90:控制页面宽度,具体截断语义要看是否使用多栏。-h:替换页眉中的文件名。-n:4:加四位行号,并用冒号分隔。-t:省略常规页眉与页脚。+2:4:只输出第二至第四页。隔离实验把 11 条记录放进页长 14 的页面。页眉与页脚各占 5 行,所以每页最多 4 条正文,结果正好 3 页。页码从输入第一页开始计算;先跳页再编号时,要区分输入页号和第一条输出行号。
换页符 \f 也是数据。输入中的换页符会强制开始新页;-f 可用换页符分隔输出页。纯文本查看器是否真的翻页取决于接收端,因此测试时应数 0x0c 字节,而不是只凭屏幕空白猜测。

pr -2 file 把单个文件排成两栏,默认先向下填满左栏再填右栏;加 -a 后改为按行横向填充。两种布局包含同样的记录,阅读顺序却不同。
pr -2 -a -t -w 72 records.txt-m 则把多个文件并排,每个文件占一栏:
pr -m -t -w 100 --sep-string=' | ' left.txt right.txt多栏会把页面宽度分给各栏,长行可能被截断。不能因为单栏预览完整,就认为多栏也完整。分页前先检测最长显示列,分页后再对比记录数、关键字段和截断策略。
pr 的名字里带有打印语境,但它只生成格式化 stdout。重定向到 report.pages.txt、管给 less 或再交给别的转换器都不会创建打印任务。本章到这里的正确动作是预览:
LC_ALL=C pr -l 60 -w 90 report.txt | less后续是否发送、排队和选择设备是另一层责任。
Shell 中输入 printf,通常先命中 builtin。隔离环境的真实查找结果是:
printf is a shell builtin
printf is /usr/bin/printf
printf is /bin/printf可以用这些形式明确实现:
builtin printf '%s\n' "$value"
/usr/bin/printf '%s\n' "$value"
env printf '%s\n' "$value"Bash builtin 支持 -v variable,把结果写入变量而不写 stdout;GNU 外部命令没有这个 Bash 变量作用域能力。两者都提供许多相同转换,但 %q 等扩展的具体输出形式可以不同:Bash 实测生成 $'space and\nnewline',GNU 外部命令生成由普通单引号和 ANSI-C 引用组合的等价形式。
可移植脚本应先选择基线。若脚本声明 #!/usr/bin/env bash,可以明确使用 Bash 的 printf -v 和 %q;若目标是 POSIX sh,就避免依赖这些扩展,并在目标 Shell 测试。
printf 的第一个操作数是格式串。它包含三类对象:原样复制的普通字符、反斜杠转义,以及消费参数的 % 转换说明。
printf 'name=%s score=%d ratio=%.2f\n' '阿青' 90 0.875常用转换包括:
格式串会重复使用直到参数耗尽:
printf '<%s>\n' A B C输出三行 <A>、<B>、<C>。如果格式需要更多参数,缺少的字符串按空串、数值按零处理。printf 'x=<%s> n=%d\n' 会得到 x=<> n=0。这很方便,也可能掩盖字段缺失;严肃报表应在调用前检查参数数量和类型。
转换说明可以拆成:
% [标志] [最小字段宽度] [.精度] 转换字符隔离实验执行:
printf '|%10s|%-10s|%08d|%8.2f|\n' right left 42 3.14159真实输出:
| right|left |00000042| 3.14|%10s:字符串至少占 10 列,默认右对齐。%-10s:负号表示左对齐。%08d:整数最小宽度 8,用零填充。%8.2f:整体至少宽 8,小数点后保留 2 位。%.3s:最多输出字符串转换允许的前三个单位;多字节文本的单位依实现和 locale 而变,不要把它当 Unicode 字素截断器。“最小宽度”不是“最大宽度”。%5s 遇到十个字符会完整输出十个,而不是自动截断。字符串精度可以限制输出,但对 Unicode 展示应先用专门逻辑安全截断,再交给 printf 对齐。
格式串中的 \n、\t、\\ 会被解释为换行、TAB 和反斜杠。%b 还会让参数中的反斜杠再次被解释:
printf '%b' 'first\nsecond\n'这会输出两行。若参数来自不可信输入,%b 会让对方控制换行、回车等布局,不适合直接用于日志字段。
数值转换受 locale 影响,尤其是小数点和分组。测试环境只有 C、C.utf8 与 POSIX,LC_ALL=C printf '%.2f\n' 1234.5 和 LC_ALL=C.utf8 ... 都输出 1234.50。这个结果只证明两个 locale 在该数值规则下相同,不代表所有地区设置都用点号。
如果输出是机器接口,常见做法是固定:
LC_ALL=C printf '%s\t%d\t%.2f\n' "$name" "$count" "$ratio"如果输出是给用户看的报告,则可尊重用户 locale,但测试用例也必须覆盖相应小数与分组规则。

下面的写法把变量当成格式程序:
untrusted='name=%s\n'
printf "$untrusted" 'CHANGED'真实输出是 name=CHANGED。变量里的 %s 消费了后续参数,\n 也变成换行。这个示例没有执行外部命令,但已经证明数据能控制输出结构;如果变量含回车、多个转换或大量宽度,日志、终端和报表都会被误导。
固定格式才是正确边界:
printf '%s\n' "$untrusted"此时 %s 和反斜杠只是数据。变量仍要加双引号,避免 Shell 在调用前做分词和路径展开。
%q 适合诊断“一个 Shell 参数到底包含哪些空白和控制字符”,但它输出的是 Shell 引用形式,不是 JSON、CSV 或 HTML 转义。把 %q 填进另一种格式,并不会自动满足那种格式的安全规则。

printf 很适合生成人读表格,但空格对齐的表不适合被另一个程序重新解析。名称中可以有空格,宽度不足时字段会贴近,Unicode 显示列又可能与格式宽度不同。
更稳妥的双轨输出是:
$ LC_ALL=C printf '%s\t%d\t%.2f\n' "$name" "$count" "$ratio" > report.tsv
$ LC_ALL=C.utf8 column -t -s $'\t' report.tsv > report.txt如果字段可能含 TAB、换行、引号或嵌套对象,就使用真正的 CSV/JSON 序列化器。HTML 还需要上下文正确的文本节点或属性转义,不能用 %q 代替。
一个好测试会同时验证:机器输出能被解析器往返读取,人类输出在目标宽度下没有丢字段。只比较截图,无法证明结构仍完整。
groff 处理的是带标记的文本。输入中既有正文,也有以控制字符开头的请求或宏;排版器把它们变成设备相关的中间布局,后处理器再生成终端字节、HTML、PDF 或 PostScript。
三个名字处在不同层次。troff 是解释 roff 语言并计算布局的排版器;nroff 是面向终端或打字机式设备的兼容前端,在 GNU 环境中会根据 locale 选择合适的终端编码;groff 则是总前端,负责按选项组织预处理器、troff 和输出驱动。平时直接运行 groff 最方便,但理解这三层有助于阅读 man 的调用链和后端错误。
正文 + roff 标记
→ 可选预处理器
→ troff 排版器
→ 设备相关中间输出
→ grotty / grohtml / gropdf / grops
→ UTF-8 终端 / HTML / PDF / PostScript一份最小 ms 文档可以这样写:
.TL
Formatting Pipeline
.AU
WeLearn Lab
.NH 1
Contract
.PP
One source describes content and structure.然后选择目标:
groff -Kutf8 -ms -Tutf8 report.ms > report.txt
groff -Kutf8 -ms -Thtml report.ms > report.html
groff -Kutf8 -ms -Tpdf report.ms > report.pdf-Kutf8 指定输入编码,-ms 加载 ms 宏包,-T... 选择输出设备。把输入编码和输出设备分开写,能避免“文件是 UTF-8,所以每个后端自然支持所有字形”的误解。

roff 的请求是排版器提供的底层动作,例如 .br 强制断行、.sp 增加竖直间距。宏则把一组请求封装成更有语义的接口;宏包再为某类文档提供一整套布局约定。
ms:适合报告、文章和普通技术文档,提供标题、段落、分级标题、脚注等宏。man:适合传统手册页,常见 .TH、.SH、.B、.TP。mdoc:同样面向手册页,但语义标记更丰富。一份文档通常选择一个主宏包,不要把两个负责整体页面布局的宏包随意混用。宏名由宏包定义,.PP 在某个包里有效,不代表所有环境和所有包都赋予它相同语义。
反斜杠转义用于特殊字符、字体切换、字符串与寄存器引用。它是 roff 输入语言的一部分;若正文来自不可信输入,就不能简单拼进源文件。先转义控制行开头的点、反斜杠和对应上下文,再在安全模式下处理。
隔离实验对 ASCII ms 输入成功生成了三种结果:HTML 中能找到标题,PDF 头是 %PDF-1.4,UTF-8 终端预览包含粗体和下划线的 ANSI 序列。groff -V 还显示了 HTML 的规划链路:
preconv → soelim / pre-grohtml → troff → post-grohtml但中文测试揭示了重要边界。同一份中文标记在 -Tutf8 终端后端能显示;-Thtml 和 -Tpdf 命令虽然退出 0,却各自向 stderr 写了 11 条缺字警告,因为随包的排版字体没有覆盖这些 CJK 字形。
所以验证不能只看退出状态:
groff -Kutf8 -ms -Tpdf report.ms > report.pdf 2> report.err
test -s report.pdf
test ! -s report.err
pdftotext report.pdf - | grep -F '预期标题'必要时配置覆盖目标文字的字体并实际渲染页面。终端能显示中文,只证明终端字体和 utf8 设备链可用,不证明 PDF 字体可用。
同一条命令在不同环境下可能改变:
printf 的小数点、分组和数字解析受 locale 影响。pr 页眉中的日期受 locale、时区和文件时间影响。column 的交互宽度来自终端,重定向后规则不同。fold 的 Unicode 与新选项能力随 coreutils 版本变化。机器生成物可以固定 LC_ALL=C、TZ=UTC 和明确宽度;给人看的本地化报告则应把所选 locale 作为测试矩阵的一部分。不要在正文里硬编码某次动态日期作为金样本,可以通过 pr -D 指定可预测日期格式,或在比较时忽略明确的动态字段。
记录版本能帮助解释差异:
fmt --version | head -n 1
column --version
groff --version | head -n 1
locale
printf 'COLUMNS=%s\n' "${COLUMNS-unset}"不同输出需要不同检查:
所有命令都应分别捕获 stdout 与 stderr。stderr 为空不是永远的成功条件,但任何警告都必须被分类:可以接受的兼容提示要写入基线,缺字、截断和坏输入不能被忽略。
最后再做内容对账。例如输入 1,000 条记录,格式化后仍应能确认 1,000 条都存在;对金额列计算总和;对 ID 列检查无重复、无截断。视觉检查负责版面,数据断言负责内容,两者不能替代。
本章到生成与验证为止。pr 产生分页文本,groff 产生页面描述或文档文件,都还没有选择设备、提交任务或管理队列。
下面把全章收束成一条可复现流程。所有文件都放进一次性目录,结束后删除;示例只生成输出,不触碰任何设备或队列。
先创建明确的竖线数据,包含正常值、空字段和缺字段。用 awk -F'|' 检查每行 NF,把结构错误留在 stderr,并在校验失败时返回非零。为什么先做:后面的对齐工具会把空字段与缺字段都显示成空白,视觉上无法区分。
从合法字段生成两条支路。机器支路用固定 locale 和明确分隔符写 TSV;阅读支路用 column -t 对齐,并为数字列指定右对齐。为什么分支:机器需要可逆结构,人类需要快速比较,不能让空格表同时承担两种接口。
对自然语言说明先用 fmt 重排段落,再用 fold 检查硬宽度;若含无空白中文、长 token 或 ANSI,就停止自动硬切并选择对应渲染器。为什么分两步:段落语义与单行上限不是同一个问题。
本次隔离实测得到几条可直接复用的诊断结论:fmt 的目标宽度会被无空白长串突破;coreutils 9.1 没有 fold -c;column -s ',' 会误切带引号的逗号;pr -l 14 的默认正文区只有 4 行;Bash 与 GNU %q 可生成不同但等价的 Shell 引用;groff 的 UTF-8 终端后端能显示中文,而默认 HTML/PDF 排版字体会报告 CJK 缺字。
我们可以用下面的选择链结束本章:
要重排自然段落? → fmt
要给单行设置硬边界? → fold
已有明确字段,只想对齐阅读? → column
要页眉、页脚、页长或多栏? → pr
要按模板控制值、宽度和精度? → printf
要一份标记生成多个设备后端? → groff
无论选择哪个工具:
数据内容 → 布局单位 → 溢出策略 → locale/版本 → 目标媒介预览 → 内容对账使用固定 printf 格式生成标题、整数和小数。先验证参数数量与类型,固定机器输出的 LC_ALL=C,把所有外部文本放在 %s 参数位置。为什么固定格式:数据不能取得格式控制权。
用 pr 生成分页预览,显式设置页长、页宽和页眉,统计页码、换页符与记录数。为什么不只看第一页:多栏和后续页最容易出现截断与遗漏。
用同一份 roff 标记分别生成 UTF-8 终端、HTML 与 PDF。为每个后端独立保存 stderr,检查文件签名和预期文本,并渲染 PDF 页面。为什么按后端验收:字体、字形和布局能力属于具体设备,不属于抽象源文件。
最后对账数据断言,删除受控目录,并确认一次性容器已经消失。清理不是装饰:它证明实操没有把下载包、临时字体、分页结果或进程留到下一次测试。