一. 前言:終端機很漂亮,寄出去卻只剩截圖? #
拍拍君整理完一輪任務,終端機裡的表格很清楚。 但要交給別人看時,截圖不好搜尋,複製貼上又把對齊弄亂。 我們真正需要的,其實是一份有限、可保存、能重新產生的報表產物。
Rich 入門已介紹表格與文字樣式; Logging Dashboard處理持續更新的觀察窗。 今天把目標縮小:同一批公開摘要,只渲染一次,產生 TXT、HTML、SVG 三份檔案。 不錄整個終端機工作階段,也不把 Live 動畫當成靜態報表。
二. 先分清楚:畫面、輸出串流與錄製緩衝區 #
Console(record=True) 會為寫進這個 Console 的內容保留錄製資料。
它不會自動攔截其他 Console、Python print() 或整個 shell 的輸出。
匯出依據是錄下來的渲染片段,不是原本的資料模型。
因此表格若在渲染時已被折行或省略,匯出不會神奇還原完整欄位。
| 對象 | 本篇用途 | 清除方式的差別 |
|---|---|---|
| 終端機畫面 | 互動顯示 | Console.clear() 處理畫面,不刪錄製歷史 |
file 輸出串流 |
接住一般輸出 | 清空 StringIO 不代表清空 record buffer |
| record buffer | TXT/HTML/SVG 匯出的來源 | export_*(clear=True) 在匯出後清除 |
本篇讓 Console 寫進 StringIO,避免報表內容順便印到目前終端機。
真正準備分享的資料,則從 export_*() 取得。
官方行為見 Console 匯出說明。
三. 安裝:把範例環境固定 #
本文以 Python 3.12、Rich 15.0.0、pytest 9.2.0 驗證。 在新的空目錄建立環境:
uv venv --python 3.12
uv pip install rich==15.0.0 pytest==9.2.0
後面的執行命令都用 uv run --no-project,讓 uv 使用這個目錄的 .venv。
TXT 適合搜尋與差異比較;HTML 適合瀏覽器閱讀;SVG 適合插入文件。
它們是展示產物,不是替代原始 JSON/CSV 的資料交換格式。
四. 完整實作:一份公開快照,三種輸出 #
把下面存成 report.py。
範例的原始資料故意帶一個人工假秘密欄位,證明它不應進入錄製流程。
公開投影只留下 ID、顯示名稱與完成數;不把整個 dict 交給 Rich。
from dataclasses import dataclass
from io import StringIO
from pathlib import Path
import xml.etree.ElementTree as ET
from rich import box
from rich.cells import cell_len
from rich.console import Console
from rich.table import Table
from rich.terminal_theme import MONOKAI
from rich.text import Text
from rich.theme import Theme
AS_OF = "2026-11-04T00:00:00Z"
SOURCE = [
{"id": "P02", "owner": "拍拍醬", "done": 8,
"token": "CANARY_NOT_FOR_EXPORT"},
{"id": "P01", "owner": "[red]拍拍君[/red]", "done": 12,
"token": "CANARY_NOT_FOR_EXPORT"},
]
@dataclass(frozen=True)
class PublicRow:
id: str
owner: str
done: int
def label(value: object, max_chars: int) -> str:
if not isinstance(value, str):
raise ValueError("label must be text")
if not value or len(value) > max_chars or not value.isprintable():
raise ValueError("invalid label")
return value
def public_rows(raw: list[dict]) -> tuple[PublicRow, ...]:
if len(raw) > 100:
raise ValueError("too many rows")
rows = []
seen = set()
for item in raw:
key = label(item["id"], 8)
owner = label(item["owner"], 24)
count = item["done"]
if key in seen or type(count) is not int or not 0 <= count <= 9999:
raise ValueError("duplicate id or invalid count")
seen.add(key)
rows.append(PublicRow(key, owner, count))
return tuple(sorted(rows, key=lambda row: row.id))
def make_console() -> Console:
return Console(
file=StringIO(), record=True, width=96, height=25,
color_system="truecolor", force_terminal=True,
force_jupyter=False, force_interactive=False, no_color=False,
markup=False, emoji=False, highlight=False,
theme=Theme({"report.title": "bold cyan"}),
)
def render_report(rows: tuple[PublicRow, ...], as_of: str) -> Console:
# 每份報表都建立新的 Console,不借用應用程式的共用 Console。
console = make_console()
console.print(Text("拍拍君任務摘要", style="report.title"))
console.print(Text(f"資料時間:{label(as_of, 30)}"))
table = Table(box=box.SIMPLE, padding=(0, 1))
table.add_column("ID", style="cyan", no_wrap=True)
table.add_column("負責人", no_wrap=True)
table.add_column("完成數", justify="right", style="green")
for row in rows:
# Table 的字串欄位也可能解析 markup;直接傳 Text 最清楚。
table.add_row(Text(row.id), Text(row.owner), Text(str(row.done)))
console.print(table)
return console
def svg_cell_width(svg: str) -> str:
# 限 Rich 15.0.0 的預設 20px、font_aspect_ratio=0.61 模板。
ns = "http://www.w3.org/2000/svg"
ET.register_namespace("", ns)
root = ET.fromstring(svg)
for node in root.findall(f".//{{{ns}}}text"):
if "textLength" in node.attrib:
node.set("textLength", str(round(cell_len(node.text or "") * 12.2, 4)))
return ET.tostring(root, encoding="unicode")
def build_artifacts(raw: list[dict], as_of: str = AS_OF) -> dict[str, str]:
rows = public_rows(raw) # 先驗證與投影,才允許錄製。
console = render_report(rows, as_of)
artifacts = {
"report.txt": console.export_text(clear=False, styles=False),
"report.html": console.export_html(theme=MONOKAI, clear=False),
"report.svg": svg_cell_width(console.export_svg(
title="Daily Pypy Report", theme=MONOKAI,
unique_id="pypy-report", clear=False,
)),
}
# 三份字串都成功取得後,再消耗這份錄製資料。
console.export_text(clear=True)
return artifacts
if __name__ == "__main__":
target = Path("out")
target.mkdir(exist_ok=True)
for name, content in build_artifacts(SOURCE).items():
(target / name).write_text(content, encoding="utf-8")
執行後會建立 out/report.txt、out/report.html、out/report.svg:
uv run --no-project python report.py
這裡的 AS_OF 是資料快照時間,不是程式啟動時間。
正式工具請從已保存的快照 metadata 傳入,別每次重建都塞現在時間。
我們也沒有呼叫 console.log(),避免它自動帶入時間、路徑或行號。
PublicRow 只描述本例的公開欄位,不代表任意來源都可以直接接受。
範例預期是小型本機 list[dict];缺欄位會直接失敗,不默默補假資料。
100 筆限制也不是網路請求大小限制;若接外部檔案,讀取層仍需自己的配額。
五. clear 的陷阱:第一份匯出把第二份吃掉 #
最容易踩到的是:export_text()、export_html()、export_svg()
都有 clear=True 的預設值。
假如依序不帶參數地呼叫,第一份有內容,後面可能只剩空外殼。
HTML/SVG 有標籤不代表真的有那份報表,不能只測檔案非空。
下面是獨立的小實驗,可存成 buffer_demo.py:
from report import make_console
from rich.text import Text
console = make_console()
console.print(Text("FIRST_REPORT"))
console.clear() # 清畫面,不是清 record buffer。
assert "FIRST_REPORT" in console.export_text(clear=False)
first = console.export_text() # 預設 clear=True。
assert "FIRST_REPORT" in first
assert console.export_text(clear=False) == ""
console.print(Text("SECOND_REPORT"))
assert console.export_text() == "SECOND_REPORT\n"
主程式刻意在三次匯出都寫上 clear=False,最後另做一次消耗。
若第二份匯出失敗,函式會拋出例外,不會回傳一組缺件的 artifacts。
本例 Console 不共用,離開失敗的呼叫後就丟棄,不讓下一份報表接著用。
但要注意:清 buffer 不是安全抹除記憶體。
已取得的字串、輸出串流副本或已寫檔內容仍然存在。
同樣地,後來在畫面印上 [REDACTED],也不會改寫之前錄下的內容。
這是為什麼投影必須放在 render_report() 前面。
六. 字面文字:Rich markup 與 HTML escaping 是兩層 #
範例故意讓名稱等於 [red]拍拍君[/red]。
Text(value) 把它當字面文字;若你需要在自行編寫的 markup 中插值,
則應使用 rich.markup.escape() 處理插入部分。
詳見 Rich markup escaping。
只設定 Console(markup=False) 還不夠表達所有 renderable 的政策。
像 Table 自己就會處理字串儲存格,所以我們明確傳 Text。
這也避免輸入偷偷變成帶有 URL 的 Rich link 樣式。
接下來才是 HTML/XML 層:<script>x</script> 必須成為可見文字,
而不是 HTML 的 script 元素或 SVG 的可執行節點。
本例用 Rich 內建 exporter,沒有把原始輸入拼進 code_format 模板。
不要把這個結論擴大成「任意 Rich renderable 或自訂模板都是 sanitizer」。
我們的保證只涵蓋已驗證、用 Text 呈現的公開欄位與固定模板。
白名單也不是靠刪掉所有叫 token 的欄位達成。
若允許的 owner 本身含私密內容,仍需要資料政策;markup escaping 不會幫你判斷。
七. 可重現:固定產物輸入,不宣稱像素永遠相同 #
這份報表固定了六件事:
- 公開欄位與 ID 排序,重複 ID 拒收。
width=96, height=25,明確固定 Console 尺寸,避免 dumb terminal 的尺寸退回值。- Rich 版本、語意樣式與匯出用
MONOKAI配色。 - 快照時間由呼叫端提供,不加入當下執行時間。
- SVG 的標題與
unique_id,不用每次新生的隨機值。 - 不使用動畫、進度條或持續追加的共用 Console。
Rich Theme 管理 report.title 這種語意樣式;
TerminalTheme 則控制 HTML/SVG 的終端配色,兩者不要混成一個參數。
這裡保留 Rich 內建主題的繼承,因為 Table 還會查找 table.header 等樣式。
SVG 的 unique_id 是內部 CSS、clipPath 等識別名稱的前綴,
不是報表的業務 ID;同頁內嵌多張 SVG 時,請給每張不同且穩定的前綴。
在同一個固定環境,本例可測三份字串是否逐字相等。
跨版本或跨平台時,Unicode 字寬與字型可能改變,不保證位元組永遠相同。
而且即使 SVG 字串相同,瀏覽器選到不同的中文字型,畫面也可能不同。
目視檢查發現 Rich 15.0.0 以字元數計算 SVG textLength,中文可能擠在一起。
本例 svg_cell_width() 改用 Rich 的終端格數 cell_len();12.2 是預設 20px × 0.61。
這是固定版本、預設模板的有限修正,不是任意字型、emoji 組合或複雜文字的排版引擎。
更換字級、font_aspect_ratio 或模板就必須重算並目視驗收,不能照抄這個常數。
Rich 預設 SVG 還引用外部 Fira Code 字型;離線展示要另外規劃字型來源。
這裡的 height=25 固定 Console 尺寸,不會把一般 print() 或匯出裁成 25 行。
SVG 高度仍隨錄製行數產生;我們沒有使用會垂直裁切的互動畫面。
record=True 也沒有自動保留筆數上限。
本篇限定小型、有限報表,每次建立新 Console;不是拿來長期收集無限 log。
若表格必須展示很長的文字,請重新設計欄寬或另外附原始資料,
不要關掉換行後就假設匯出內容一定完整。
八. 驗收:解析內容,不只看檔案大小 #
存成 test_report.py。HTML 使用標準庫 parser,SVG 使用 XML parser,
不另外安裝網頁框架;這些檢查不等於已經看過瀏覽器畫面。
from html.parser import HTMLParser
import xml.etree.ElementTree as ET
import pytest
from rich.text import Text
from report import SOURCE, build_artifacts, make_console
class Page(HTMLParser):
def __init__(self):
super().__init__()
self.tags = []
self.parts = []
def handle_starttag(self, tag, attrs):
self.tags.append(tag)
def handle_data(self, data):
self.parts.append(data)
def test_three_complete_artifacts():
files = build_artifacts(SOURCE)
page = Page()
page.feed(files["report.html"])
svg = ET.fromstring(files["report.svg"])
assert svg.tag == "{http://www.w3.org/2000/svg}svg"
assert "pre" in page.tags
title = next(n for n in svg.iter() if n.text == "拍拍君任務摘要")
assert float(title.attrib["textLength"]) == pytest.approx(170.8)
for content in (files["report.txt"], "".join(page.parts),
"".join(svg.itertext())):
assert "拍拍君任務摘要" in content
assert "P01" in content and "P02" in content
assert "[red]拍拍君[/red]" in content
assert "CANARY_NOT_FOR_EXPORT" not in content
assert "\x1b" not in files["report.txt"]
def test_stable_order_and_fresh_console():
assert build_artifacts(SOURCE) == build_artifacts(list(reversed(SOURCE)))
other = [{"id": "Z01", "owner": "chatPTT", "done": 1}]
for content in build_artifacts(other).values():
assert "chatPTT" in content
assert "P01" not in content and "P02" not in content
def test_literal_html_and_svg():
raw = [{"id": "P03", "owner": "<script>x</script>", "done": 2}]
files = build_artifacts(raw)
page = Page()
page.feed(files["report.html"])
assert "script" not in page.tags
assert "<script>x</script>" in "".join(page.parts)
svg = ET.fromstring(files["report.svg"])
assert not any(node.tag.endswith("}script") for node in svg.iter())
assert "<script>x</script>" in "".join(svg.itertext())
@pytest.mark.parametrize("method", ["export_text", "export_html", "export_svg"])
def test_each_export_clears_by_default(method):
console = make_console()
console.print(Text("BUFFER_CANARY"))
console.clear()
assert "BUFFER_CANARY" in console.export_text(clear=False)
assert "BUFFER_CANARY" in getattr(console, method)()
assert console.export_text(clear=False) == ""
@pytest.mark.parametrize("bad", [True, -1, 10000, "3"])
def test_invalid_count(bad):
with pytest.raises(ValueError):
build_artifacts([{"id": "P01", "owner": "拍拍君", "done": bad}])
執行 uv run --no-project pytest -q,這份測試應有 10 項通過。
它會抓出提早清空、混入上份報表、把 markup 當樣式與把布林值當計數等問題。
最後用瀏覽器實際打開 HTML 和 SVG,確認中文字型、標題、欄位與長文字沒被切掉。 本篇範例已以 Chromium 檢查兩種產物的內容與呈現, 並測試拒絕外部字型請求時仍能顯示;不代表每個瀏覽器排版都相同。
九. 分享前:一組檔案不是一筆交易 #
build_artifacts() 先把三份字串準備好,才交給檔案寫入迴圈。
但磁碟在第二份寫到一半出錯,仍可能留下不完整的 out/。
範例沒有宣稱三個檔案具備原子發布保證,也會覆寫同名檔案。
需要交付正式 artifact 時,可先寫入全新的暫存目錄, 解析驗收、計算檔案雜湊後,再交給 CI artifact 或版本化目錄發布。 雜湊能比較內容是否一致,但不能單獨證明來源身分可信。
結語:先把報表邊界畫好,再把框線畫漂亮 #
拍拍君會把流程記成:公開投影 → 固定輸入 → 單次錄製 → 三種匯出 → 驗收 → 分享。
關鍵不是多呼叫兩個 save_*(),而是每一份產物都來自同一個乾淨的快照。
當 clear 順序、文字政策與 Console 生命週期都清楚,報表才真的適合交出去。