↓快轉到主要內容
  1. 教學文章/

Rich 報表匯出:Record Buffer、HTML/SVG 與可重現分享

·8 分鐘· loading · loading · ·
Python Rich HTML SVG Reports Testing
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 153: 本文

一. 前言:終端機很漂亮,寄出去卻只剩截圖?
#

拍拍君整理完一輪任務,終端機裡的表格很清楚。 但要交給別人看時,截圖不好搜尋,複製貼上又把對齊弄亂。 我們真正需要的,其實是一份有限、可保存、能重新產生的報表產物。

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 不會幫你判斷。

七. 可重現:固定產物輸入,不宣稱像素永遠相同
#

這份報表固定了六件事:

  1. 公開欄位與 ID 排序,重複 ID 拒收。
  2. width=96, height=25,明確固定 Console 尺寸,避免 dumb terminal 的尺寸退回值。
  3. Rich 版本、語意樣式與匯出用 MONOKAI 配色。
  4. 快照時間由呼叫端提供,不加入當下執行時間。
  5. SVG 的標題與 unique_id,不用每次新生的隨機值。
  6. 不使用動畫、進度條或持續追加的共用 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 生命週期都清楚,報表才真的適合交出去。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 153: 本文

相關文章

Streamlit Form 提交生命週期:草稿、已套用狀態與失敗保留
·9 分鐘· loading · loading
Python Streamlit Forms Session State Validation Testing
Textual DataTable 身分契約:Row Key、排序篩選與選取還原
·7 分鐘· loading · loading
Python Textual DataTable RowKey TUI Testing
Streamlit URL 狀態契約:Query Params、篩選還原與雙向同步
·8 分鐘· loading · loading
Python Streamlit Query-Params URL State Testing
Rich Traceback 實戰:例外鏈、敏感資料遮蔽與 CLI 退出契約
·7 分鐘· loading · loading
Python Rich Traceback Cli Stderr Exception-Chaining
Textual Modes 實戰:Workspace 切換、畫面生命週期與路由測試
·7 分鐘· loading · loading
Python Textual TUI Modes Screen-Routing Lifecycle Testing
Streamlit SQL 連線實戰:st.connection、查詢快取 TTL 與交易邊界
·7 分鐘· loading · loading
Python Streamlit SQL SQLConnection Cache Testing