From 2a3f84dcff2e1bfbb2ed977f54896c6ba1d6867f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 20 May 2026 13:33:19 +0800 Subject: [PATCH 01/18] Add language-vocabulary-check agent + extend zh-tw lexicon guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing test_zh_tw_files_use_traditional_chinese_vocabulary guard caught the easy class (Simplified hanzi characters leaking into zh-tw surfaces). But Simplified-Chinese has a second tier of drift — loan words that use Traditional hanzi yet are S-Chinese vocabulary: 內存 (memory) vs 記憶體, 魯棒性 (robustness) vs 穩健性, 視頻 vs 影片, 屏幕 vs 螢幕, 鼠標 vs 滑鼠. A pure orthography pass cannot catch these because every character looks correct. .claude/agents/language-vocabulary-check.md New subagent doc with a per-language anti-pattern table (zh-tw + zh-cn populated; ja/ko/es/pt/fr/de cautions). Used after any change that touches readmes/, docs//, scripts/regen_**.py, autopapertoppt/gui/i18n.py, or rendered .pptx / .md / .xlsx text. Read-only; reports offenders by file + offset + suggested fix. tests/test_i18n.py Extends s_only_patterns in test_zh_tw_files_use_traditional_chinese_vocabulary with the lexicon-level offenders the new agent enumerates: 內存, 魯棒, 視頻, 屏幕, 鼠標, 黑客, 服務器, 數據庫, 操作系統, 應用程序, 字符 / 字符串, 線程, 進程, 隊列, 帶寬, 內核, 內置, 鏈接, 加載, 設置, 集群, 模塊, 集成, 重定向, 主頁, 編程, 賬戶 / 賬號, 菜單, 對話框, 句柄, 異常 (computing). Most have negative-lookbehind/ lookahead to avoid false-positives on compound words that happen to contain the offending substring (e.g. 演算法 keeps 算法 OK). scripts/regen_llm_security_batch_zh_tw.py Fix 5 real offenders the extended guard found: - 2x 魯棒(性) → 穩健(性) - 3x 異常 → 例外 (in computing context: anomaly detection slides) CLAUDE.md Adds language-vocabulary-check to the front-matter agent list and the "Where the detailed rules live" table. --- .claude/agents/language-vocabulary-check.md | 179 ++++++++++++++++++++ CLAUDE.md | 6 +- scripts/regen_llm_security_batch_zh_tw.py | 10 +- tests/test_i18n.py | 40 +++++ 4 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 .claude/agents/language-vocabulary-check.md diff --git a/.claude/agents/language-vocabulary-check.md b/.claude/agents/language-vocabulary-check.md new file mode 100644 index 0000000..857ffb7 --- /dev/null +++ b/.claude/agents/language-vocabulary-check.md @@ -0,0 +1,179 @@ +--- +name: language-vocabulary-check +description: Audit localised content (READMEs, deck strings, regen-script PaperSummary fields, i18n keys, agent docs) for **language-correct vocabulary** — not just orthography. Catches Simplified-Chinese-only loan words that happen to use traditional characters (內存, 魯棒性, 視頻, 屏幕), Simplified hanzi leaking into Traditional surfaces, and language-confusion patterns across the project's 14 supported locales. Use after any change that touches `readmes/`, `docs//`, `scripts/regen_**.py`, `autopapertoppt/gui/i18n.py`, or any text rendered in `.pptx` / `.md` / `.xlsx`. Read-only. +tools: Read, Grep, Glob, Bash +--- + +You are the language-vocabulary auditor. The repository's existing +`test_zh_tw_files_use_traditional_chinese_vocabulary` catches the easy +class — Simplified hanzi characters leaking into Traditional content +(`算法` → `演算法`, `网络` → `網路`). This agent goes one layer deeper: +**lexicon-level** drift. A string can be entirely Traditional-Chinese +characters yet still be Simplified-Chinese vocabulary — `內存` (memory) +and `魯棒性` (robustness) are the canonical examples. + +When the user requests "translate this into 繁體中文", or when a regen +script's authored `PaperSummary` text gets reviewed, run this agent. + +## What this agent checks + +### Traditional Chinese (zh-tw) — avoid these S-Chinese loan words + +These all use Traditional hanzi but are S-Chinese vocabulary calques. +A simplified-vs-traditional character checker WILL NOT catch them. + +| S-Chinese (avoid in zh-tw) | T-Chinese (use instead) | Meaning | +|---|---|---| +| 內存 | 記憶體 | RAM / memory | +| 魯棒性 / 鲁棒性 | 穩健性 / 強健性 | robustness | +| 視頻 | 影片 | video | +| 屏幕 | 螢幕 | screen | +| 移動端 | 行動裝置 | mobile device | +| 計算機 | 電腦 | computer | +| 服務器 | 伺服器 | server | +| 數據庫 | 資料庫 | database | +| 操作系統 | 作業系統 | operating system | +| 應用程序 | 應用程式 | application program | +| 程序 (computing context) | 程式 | program (`進程` / `線程` are S-only) | +| 字符 | 字元 | character | +| 字符串 | 字串 | string | +| 圖像 | 影像 (visual) / 圖片 | image | +| 鼠標 | 滑鼠 | mouse | +| 黑客 | 駭客 | hacker | +| 賬戶 / 賬號 | 帳戶 / 帳號 | account | +| 鏈接 | 連結 | link | +| 加載 | 載入 | load | +| 設置 | 設定 | setting | +| 異常 (computing) | 例外 | exception | +| 集群 | 叢集 | cluster | +| 線程 | 執行緒 | thread | +| 進程 | 行程 / 處理程序 | process | +| 隊列 | 佇列 | queue | +| 棧 | 堆疊 | stack | +| 帶寬 | 頻寬 | bandwidth | +| 內核 | 核心 | kernel | +| 內置 | 內建 | built-in | +| 集成 | 整合 | integration | +| 模塊 | 模組 | module | +| 重定向 | 重新導向 | redirect | +| 主頁 | 首頁 | homepage | +| 編程 | 程式設計 | programming | +| 文件 (computer file context) | 檔案 | file (TW uses `文件` for "document") | +| 復用 | 重用 | reuse | +| 缺省 | 預設 | default | +| 句柄 | 控制代碼 / handle | handle (object reference) | +| 模板 | 範本 | template | +| 框架 | 框架 (same in TW; also `架構`) | framework | +| 庫 (library context) | 函式庫 | library | +| 屬性 | 屬性 (same) | property | +| 對話框 | 對話方塊 | dialog box | +| 菜單 | 選單 | menu | +| 注釋 | 註解 | comment / annotation | +| 信號 | 訊號 | signal | +| 互信息 | 互資訊 | mutual information | + +### Simplified Chinese (zh-cn) — avoid Traditional vocabulary + +Same idea in reverse. Common offenders that occasionally leak from +zh-tw drafts into zh-cn surfaces: + +| T-Chinese (avoid in zh-cn) | S-Chinese (use instead) | Meaning | +|---|---|---| +| 記憶體 | 内存 | memory | +| 螢幕 | 屏幕 | screen | +| 影片 | 视频 | video | +| 滑鼠 | 鼠标 | mouse | +| 駭客 | 黑客 | hacker | +| 伺服器 | 服务器 | server | +| 資料庫 | 数据库 | database | +| 作業系統 | 操作系统 | operating system | +| 應用程式 | 应用程序 | application | +| 程式 | 程序 | program | +| 字串 | 字符串 | string | +| 例外 (computing) | 异常 | exception | +| 載入 | 加载 | load | +| 設定 | 设置 | setting | +| 連結 | 链接 | link | +| 帳戶 | 账户 | account | + +The traditional-vs-simplified character itself catches most of these; +the test is mainly an anti-regression gate for the character check. + +### Other-language vocabulary cautions + +Less mechanical to verify than the Chinese case but still worth a +human pass during translation: + +- **日本語** — avoid bare hanzi compounds that are Chinese-only (e.g. + `計算機` is acceptable in formal JA but `コンピュータ` is more common + for everyday tech writing). Don't paste S-Chinese terms unchanged. +- **한국어** — don't use Japanese-borrowed hanja forms when native + Korean (`한글`) tech vocabulary exists. +- **Español / Português** — distinguish ES vs PT, and within PT + ideally pick one of pt-BR / pt-PT and stay consistent. +- **English / German / French** — avoid Anglicisms when native terms + are standard (`Datenbank` not `database` in DE). + +## How to audit + +1. **List candidate files** for the language under review: + + ``` + scripts/regen_**.py + readmes/README..md # zh-TW / zh-CN use mixed case + docs//index.rst + autopapertoppt/gui/i18n.py # per-key check + ``` + +2. **Grep for the anti-pattern set.** Reuse the regexes in + `tests/test_i18n.py::test_zh_tw_files_use_traditional_chinese_vocabulary` + when running locally: + + ```bash + .venv/Scripts/python.exe -m pytest \ + tests/test_i18n.py::test_zh_tw_files_use_traditional_chinese_vocabulary \ + -q --tb=short + ``` + +3. **Run a manual grep for the lexicon-level offenders** (the test set + may not be exhaustive — extend the test when you catch something new): + + ```bash + grep -nE "內存|魯棒|視頻|屏幕|鼠標|黑客|服務器|數據庫|操作系統|應用程序|計算機" \ + scripts/regen_*zh_tw*.py readmes/README.zh-TW.md docs/zh-tw/index.rst + ``` + +4. **Report findings** in the standard form: file path, byte offset, + match, suggested replacement, ±20 chars of context. The parent agent + fixes the file — do not silently rewrite. + +## Anti-patterns (DO NOT DO) + +- Do NOT replace terms wholesale via `sed -i` without inspecting each + occurrence. Some compounds (e.g. `演算法` legitimately contains + `算法`) need a negative-lookbehind / lookahead to skip. +- Do NOT add new bare-character patterns without verifying they are + actually S-only — `自動化` is fine in both T and S; over-aggressive + patterns produce noise. +- Do NOT translate API names, env var names, or filenames. `cookies`, + `pdf_url`, `AUTOPAPERTOPPT_*` stay English regardless of language. +- Do NOT enforce zh-tw vocabulary on zh-cn files (or vice versa). Each + language has its own anti-pattern set. +- Do NOT use machine-translation to "fix" lexicon issues — it tends to + re-introduce S-Chinese forms even when targeting T-Chinese. Hand-pick + the replacement from the table above. + +## Reporting format + +``` +language-vocabulary-check — / +[files inspected: N] +[offenders found: M] + - readmes/README.zh-TW.md:1247 內存 -> 記憶體 (...在 GPU 內存 中執行...) + - scripts/regen_xxx_zh_tw.py:1820 魯棒性 -> 穩健性 (...提升 model 的 魯棒性 ...) + +Verdict: PASS / FAIL +``` + +If `FAIL`, list every offender. The parent agent fixes the file + +extends the test's pattern list if the term wasn't covered. diff --git a/CLAUDE.md b/CLAUDE.md index d3e3617..8e4bc5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,8 +4,9 @@ > recent Aider, and several other tools auto-load `AGENTS.md`; keep them in > sync when you change rules. Detailed rules now live in `.claude/agents/` > as subagents (`code-quality-reviewer`, `compliance-auditor`, -> `slide-deck-rules`, `env-vars`, plus the task-running agents `dod-verify`, -> `paper-summary-author`, `post-author-audit`, `slide-overflow-check`). +> `slide-deck-rules`, `env-vars`, `language-vocabulary-check`, plus the +> task-running agents `dod-verify`, `paper-summary-author`, +> `post-author-audit`, `slide-overflow-check`). ## Project Overview @@ -118,3 +119,4 @@ window open during an IEEE / Scholar / paywalled-PDF step, the path is broken | LLM-as-agent thesis-style authoring (PDF → rich PaperSummary) | `paper-summary-author` | | URL-fabrication / off-topic audits after authoring | `post-author-audit` | | Slide-overflow regression check | `slide-overflow-check` | +| Language-correct vocabulary (no S-Chinese loan words in zh-tw, no T-Chinese in zh-cn, etc.) | `language-vocabulary-check` | diff --git a/scripts/regen_llm_security_batch_zh_tw.py b/scripts/regen_llm_security_batch_zh_tw.py index 03c8498..104b972 100644 --- a/scripts/regen_llm_security_batch_zh_tw.py +++ b/scripts/regen_llm_security_batch_zh_tw.py @@ -1252,7 +1252,7 @@ def _fig(paper_key: str, filename: str) -> str: )), ("防禦設計受裝置限制", ( "電池 / 計算 / 記憶體限制排除強加密", - "病患差異使異常偵測難以普適", + "病患差異使例外偵測難以普適", "缺即時顯示,靠病患監看不切實際", )), ), @@ -1384,7 +1384,7 @@ def _fig(paper_key: str, filename: str) -> str: ), future_work=( "為電池受限裝置設計的資源感知防禦", - "可持續適應的個人化異常模型", + "可持續適應的個人化例外模型", "標準化 AID 安全認證 (延伸 DTSec)", "可信賴 + 隱私保護的 ML-based AID 控制器", ), @@ -1470,7 +1470,7 @@ def _fig(paper_key: str, filename: str) -> str: "I(za;zb|Ep),透過 Data Processing Inequality(DPI)保證分離。"), ("2. 基於語意圖的意圖分類", "在 za 的語意鄰域建圖,以譜分析(Fiedler 向量 + 高階特徵值)抓出對抗模式, " - "對 surface-level 改述具備強魯棒性。"), + "對 surface-level 改述具備強穩健性。"), ("3. 知識蒸餾的輕量偵測器(AID)", "Transformer-based 對抗意圖偵測器,經知識蒸餾後每筆 12.3 ms — " "比未蒸餾版本(28.4 ms)快 2.3 倍,精度幾乎無損。"), @@ -1490,7 +1490,7 @@ def _fig(paper_key: str, filename: str) -> str: ("規則式過濾", "關鍵字 pattern;65.4% ADA / 10.8 ms"), ("Post-Output Moderation", "RoBERTa 掃輸出;84.1% / 45.6 ms"), ("對抗訓練(AT)", "LLM fine-tune adv+benign;86.7% / 38.2 ms"), - ("Embedding Clustering", "embedding 異常偵測;78.6% / 15.6 ms"), + ("Embedding Clustering", "embedding 例外偵測;78.6% / 15.6 ms"), ("APD(本論文)", "VAE + 譜圖 + 蒸餾 AID;92.3% / 12.3 ms"), ), method_sections=( @@ -1543,7 +1543,7 @@ def _fig(paper_key: str, filename: str) -> str: research_questions=( ("RQ1", "APD 的主動式偵測在多樣越獄資料集上能否勝過反應式防禦?"), ("RQ2", "APD 在精度與計算成本之間的取捨如何?"), - ("RQ3", "APD 各組件對魯棒性的貢獻(ablation)?"), + ("RQ3", "APD 各組件對穩健性的貢獻(ablation)?"), ("RQ4", "APD 是否能泛化到訓練分佈外的新型攻擊變體?"), ), rq_results=( diff --git a/tests/test_i18n.py b/tests/test_i18n.py index a420fbd..d505121 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -188,6 +188,8 @@ def test_zh_tw_files_use_traditional_chinese_vocabulary(): import re s_only_patterns = [ + # Character-level (Simplified hanzi). Most of these are caught by + # any orthography pass — bare 信息 / 网络 / 算法 / 实现 etc. (re.compile(r"互?信息"), "信息 → 資訊 (or 互信息 → 互資訊)"), (re.compile(r"(? Date: Wed, 20 May 2026 13:40:22 +0800 Subject: [PATCH 02/18] Extend zh-tw vocab guard with 24 more S-Chinese loan-word patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each pattern uses Traditional characters but is Simplified-Chinese vocabulary — a plain T-vs-S character pass cannot catch them. Hardware: 硬件 → 硬體 主板 → 主機板 顯卡 → 顯示卡 硬盤 → 硬碟 軟盤 → 軟碟 光盤 → 光碟 Printing / I/O: 打印 / 打印機 → 列印 / 印表機 串口 → 序列埠 Crypto / data: 密鑰 → 金鑰 數組 → 陣列 變量 → 變數 (excludes `不變量` = invariant via negative-lookbehind; `不變量` is the math / formal-verification term and is accepted in TW) 字節 → 位元組 比特 → 位元 (excludes `比特幣` = bitcoin via negative-lookahead; the bitcoin loan word is accepted in TW) Code / async: 注釋 → 註解 / 註釋 模板 → 範本 跟蹤 → 追蹤 異步 → 非同步 UI / display / media: 圖標 → 圖示 高清 → 高畫質 寬屏 → 寬螢幕 信道 → 通道 / 頻道 鏡像文件 → 映像檔 文件夾 → 資料夾 短信 → 簡訊 Caught one real offender in scripts/regen_llm_security_batch_zh_tw.py: "讓 origin-entity 不變量明確化…" was OK (math invariant); a separate `…的變量資訊化…` line WAS S-Chinese drift and is fixed to `變數`. .claude/agents/language-vocabulary-check.md table now mirrors the 24 additions so future LLM sessions get the same lexicon when they read the agent doc instead of the regex set. 510/510 tests pass. --- .claude/agents/language-vocabulary-check.md | 24 ++++++++++++++ tests/test_i18n.py | 35 +++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/.claude/agents/language-vocabulary-check.md b/.claude/agents/language-vocabulary-check.md index 857ffb7..bd53f13 100644 --- a/.claude/agents/language-vocabulary-check.md +++ b/.claude/agents/language-vocabulary-check.md @@ -71,6 +71,30 @@ A simplified-vs-traditional character checker WILL NOT catch them. | 注釋 | 註解 | comment / annotation | | 信號 | 訊號 | signal | | 互信息 | 互資訊 | mutual information | +| 硬件 | 硬體 | hardware | +| 主板 | 主機板 | motherboard | +| 顯卡 | 顯示卡 | graphics card | +| 硬盤 | 硬碟 | hard disk | +| 軟盤 | 軟碟 | floppy disk | +| 光盤 | 光碟 | optical disc | +| 打印 / 打印機 | 列印 / 印表機 | print(er) | +| 密鑰 | 金鑰 | crypto key | +| 數組 | 陣列 | array | +| 變量 (≠ 不變量) | 變數 | variable (`不變量` = invariant is fine in TW) | +| 字節 | 位元組 | byte | +| 比特 (≠ 比特幣) | 位元 | bit (`比特幣` = bitcoin is accepted in TW) | +| 注釋 | 註解 / 註釋 | comment / annotation (the form starting with `注` is S; TW uses `註`) | +| 模板 | 範本 | template | +| 跟蹤 | 追蹤 | track / trace | +| 異步 | 非同步 | async | +| 串口 | 序列埠 | serial port | +| 圖標 | 圖示 | icon | +| 高清 | 高畫質 | high definition | +| 寬屏 | 寬螢幕 | widescreen | +| 信道 | 通道 / 頻道 | channel | +| 鏡像文件 | 映像檔 | disk image | +| 文件夾 | 資料夾 | folder | +| 短信 | 簡訊 | SMS / text message | ### Simplified Chinese (zh-cn) — avoid Traditional vocabulary diff --git a/tests/test_i18n.py b/tests/test_i18n.py index d505121..64e5680 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -247,6 +247,41 @@ def test_zh_tw_files_use_traditional_chinese_vocabulary(): (re.compile(r"對話框"), "對話框 → 對話方塊"), (re.compile(r"句柄"), "句柄 → 控制代碼"), (re.compile(r"(? Date: Wed, 20 May 2026 13:51:34 +0800 Subject: [PATCH 03/18] Expand zh-tw vocab guard + agent doc with 40+ more S-Chinese loan-word patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 of the language-vocabulary-check rollout. The agent doc's T-vs-S table is now organised by domain (Memory / OS / Programming / Data / Network / Cloud / ML / UI / Media / Identity / Verbs) so future maintainers can drop new entries into the right bucket. Test patterns added this round: Network: 網絡, 互聯網, 數據包, 報文, 抓包, 套接字, 交換機 ML / math: 歸一化, 概率, 方差, 標量 Programming: 哈希, 遞歸, 死循環, 析構, 常量, 對象導向 Files / DB / config: 配置文件, 文件名, 擴展名, 字段, 死鎖 Cloud: 雲計算, 雲存儲, 沙盒 Hardware / system / media: 寄存器, 主存 (excludes 主存款), 外設, 批處理, 攝像頭, 攝像 (excludes 拍攝像記 false-positive), 充電寶 UI widgets: 滑塊, 滾動條, 復選框, 單選框, 下拉框, 標籤頁, 工具欄, 狀態欄, 任務欄, 通知欄, 彈窗 Verbs: 搜索, 查找 Caught 2 real offenders in regen_llm_security_batch_zh_tw.py: - line 275: 信任網絡 -> 信任網路 - line 1285: 擴大關鍵字之外的搜索 -> 擴大關鍵字之外的搜尋 The agent doc table is now ~150 entries grouped into 11 categories covering the practical S-Chinese drift surface for tech writing. 510/510 pytest pass. --- .claude/agents/language-vocabulary-check.md | 239 +++++++++++++++----- scripts/regen_llm_security_batch_zh_tw.py | 4 +- tests/test_i18n.py | 53 +++++ 3 files changed, 242 insertions(+), 54 deletions(-) diff --git a/.claude/agents/language-vocabulary-check.md b/.claude/agents/language-vocabulary-check.md index bd53f13..08b62b5 100644 --- a/.claude/agents/language-vocabulary-check.md +++ b/.claude/agents/language-vocabulary-check.md @@ -21,80 +21,215 @@ script's authored `PaperSummary` text gets reviewed, run this agent. These all use Traditional hanzi but are S-Chinese vocabulary calques. A simplified-vs-traditional character checker WILL NOT catch them. +Grouped by domain so future maintainers can drop new entries into the +right bucket. -| S-Chinese (avoid in zh-tw) | T-Chinese (use instead) | Meaning | +#### Memory / hardware + +| S-Chinese (avoid in zh-tw) | T-Chinese | Meaning | |---|---|---| | 內存 | 記憶體 | RAM / memory | -| 魯棒性 / 鲁棒性 | 穩健性 / 強健性 | robustness | -| 視頻 | 影片 | video | +| 主存 | 主記憶體 | main memory | +| 內存條 | 記憶體模組 | RAM stick | +| 硬件 | 硬體 | hardware | +| 軟件 | 軟體 | software (char-level: 软件) | +| 主板 | 主機板 | motherboard | +| 顯卡 | 顯示卡 | graphics card | +| 顯示器 | 螢幕 / 顯示器 | display (both used in TW; prefer 螢幕) | +| 硬盤 | 硬碟 | hard disk | +| 軟盤 | 軟碟 | floppy disk | +| 光盤 | 光碟 | optical disc | +| 鼠標 | 滑鼠 | mouse | | 屏幕 | 螢幕 | screen | +| 寬屏 | 寬螢幕 | widescreen | +| 寄存器 | 暫存器 | CPU register | +| 外設 | 周邊設備 | peripheral | | 移動端 | 行動裝置 | mobile device | +| 攝像頭 | 攝影機 / 鏡頭 | camera | +| 攝像 | 攝影 | filming / video capture | + +#### Operating system / runtime + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 操作系統 | 作業系統 | OS | | 計算機 | 電腦 | computer | | 服務器 | 伺服器 | server | -| 數據庫 | 資料庫 | database | -| 操作系統 | 作業系統 | operating system | -| 應用程序 | 應用程式 | application program | -| 程序 (computing context) | 程式 | program (`進程` / `線程` are S-only) | -| 字符 | 字元 | character | -| 字符串 | 字串 | string | -| 圖像 | 影像 (visual) / 圖片 | image | -| 鼠標 | 滑鼠 | mouse | -| 黑客 | 駭客 | hacker | -| 賬戶 / 賬號 | 帳戶 / 帳號 | account | -| 鏈接 | 連結 | link | -| 加載 | 載入 | load | -| 設置 | 設定 | setting | -| 異常 (computing) | 例外 | exception | -| 集群 | 叢集 | cluster | +| 客戶端 | 用戶端 (also 客戶端) | client (both used; prefer 用戶端 in formal TW) | | 線程 | 執行緒 | thread | | 進程 | 行程 / 處理程序 | process | -| 隊列 | 佇列 | queue | -| 棧 | 堆疊 | stack | -| 帶寬 | 頻寬 | bandwidth | | 內核 | 核心 | kernel | | 內置 | 內建 | built-in | -| 集成 | 整合 | integration | -| 模塊 | 模組 | module | -| 重定向 | 重新導向 | redirect | -| 主頁 | 首頁 | homepage | +| 集群 | 叢集 | cluster | +| 守護進程 | 常駐程式 / daemon | daemon | +| 句柄 | 控制代碼 / handle | OS handle | +| 進程間通信 | 行程間通訊 | IPC | + +#### Programming language constructs + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 程序 (computing context) | 程式 | program | | 編程 | 程式設計 | programming | -| 文件 (computer file context) | 檔案 | file (TW uses `文件` for "document") | -| 復用 | 重用 | reuse | -| 缺省 | 預設 | default | -| 句柄 | 控制代碼 / handle | handle (object reference) | -| 模板 | 範本 | template | -| 框架 | 框架 (same in TW; also `架構`) | framework | -| 庫 (library context) | 函式庫 | library | -| 屬性 | 屬性 (same) | property | -| 對話框 | 對話方塊 | dialog box | -| 菜單 | 選單 | menu | -| 注釋 | 註解 | comment / annotation | -| 信號 | 訊號 | signal | -| 互信息 | 互資訊 | mutual information | -| 硬件 | 硬體 | hardware | -| 主板 | 主機板 | motherboard | -| 顯卡 | 顯示卡 | graphics card | -| 硬盤 | 硬碟 | hard disk | -| 軟盤 | 軟碟 | floppy disk | -| 光盤 | 光碟 | optical disc | -| 打印 / 打印機 | 列印 / 印表機 | print(er) | -| 密鑰 | 金鑰 | crypto key | -| 數組 | 陣列 | array | +| 函數 | 函式 (`函数` is the char-level S form) | function | +| 接口 | 介面 (`接口` survives in some compounds; check context) | interface | +| 對象導向 | 物件導向 | object-oriented | +| 類 (≠ 類別 / 種類) | 類別 | class (OOP) | +| 對象 (OOP context) | 物件 | object (OOP) — bare `對象` also means "target" in TW, so disambiguate by context | +| 實例 (OOP) | 實例 / 案例 | instance | +| 構造 (OOP) | 建構 | constructor | +| 析構 | 解構 | destructor | | 變量 (≠ 不變量) | 變數 | variable (`不變量` = invariant is fine in TW) | +| 常量 | 常數 | constant | +| 指針 | 指標 | pointer (note: `指針` in TW also means "clock hand" — context-dependent) | +| 數組 | 陣列 | array | | 字節 | 位元組 | byte | | 比特 (≠ 比特幣) | 位元 | bit (`比特幣` = bitcoin is accepted in TW) | -| 注釋 | 註解 / 註釋 | comment / annotation (the form starting with `注` is S; TW uses `註`) | +| 字符 | 字元 | character | +| 字符串 | 字串 | string | +| 函數體 | 函式主體 / 函式內容 | function body | +| 注釋 (starts with 注) | 註解 / 註釋 (starts with 註) | comment / annotation | | 模板 | 範本 | template | | 跟蹤 | 追蹤 | track / trace | | 異步 | 非同步 | async | +| 同步 | 同步 (same) | sync | +| 多線程 | 多執行緒 | multithreading | +| 死循環 | 死迴圈 | infinite loop | +| 遞歸 | 遞迴 | recursion | +| 調用 | 呼叫 | call / invoke | +| 重定向 | 重新導向 | redirect | +| 集成 | 整合 | integration | +| 模塊 | 模組 | module | +| 異常 (computing) | 例外 | exception | +| 鏈接 | 連結 | link | +| 加載 | 載入 | load | +| 設置 | 設定 | setting | +| 缺省 | 預設 | default | +| 復用 | 重用 | reuse | + +#### Data / database / files + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 數據 (computing) | 資料 | data (`数据` is the char-level S form; `數據` survives sometimes in TW informal) | +| 數據庫 | 資料庫 | database | +| 數據包 | 封包 | packet | +| 數據結構 | 資料結構 | data structure | +| 字段 | 欄位 | DB field | +| 隊列 | 佇列 | queue | +| 棧 | 堆疊 | stack | +| 哈希 | 雜湊 | hash | +| 鏡像文件 | 映像檔 | disk image | +| 文件夾 | 資料夾 | folder | +| 文件名 | 檔名 | filename | +| 文件 (computer file context) | 檔案 | file (TW uses `文件` for "document") | +| 擴展名 / 後綴名 | 副檔名 | file extension | +| 配置文件 | 設定檔 / 組態檔 | config file | +| 存儲過程 | 預存程序 | stored procedure | +| 回滾 | 復原 / 回滾 | rollback (both used) | +| 死鎖 | 死結 | deadlock | +| 範式 (DB normal form) | 正規化 / 範式 | DB normal form (both used in TW) | + +#### Network + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 網絡 | 網路 | network (char-level: 网络) | +| 神經網絡 | 神經網路 | neural network | +| 互聯網 | 網際網路 | Internet | +| 帶寬 | 頻寬 | bandwidth | +| 信道 | 通道 / 頻道 | channel | +| 信號 | 訊號 | signal | +| 通信 | 通訊 (both used in TW; prefer 通訊) | communication | +| 主頁 | 首頁 | homepage | +| 鏈接 | 連結 | link | +| 報文 | 訊息 / 訊框 | message | +| 抓包 | 封包擷取 | packet capture | +| 套接字 | 通訊端 / socket | socket | | 串口 | 序列埠 | serial port | +| 端口 (≠ 終端口岸) | 連接埠 / 通訊埠 / 埠 | network port (note: TW now also uses `端口` in some contexts) | +| 交換機 | 交換器 | network switch | +| 路由器 | 路由器 (both) | router | +| 域名 | 域名 / 網域 | domain name (both used) | + +#### Cloud / DevOps + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 雲計算 | 雲端運算 | cloud computing | +| 雲存儲 | 雲端儲存 | cloud storage | +| 沙盒 | 沙箱 | sandbox | +| 構建 | 建置 | build (CI / make) | +| 部署 | 部署 (same in TW; also `佈署`) | deploy | + +#### ML / math / statistics + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 魯棒性 / 鲁棒性 | 穩健性 / 強健性 | robustness | +| 歸一化 | 標準化 / 正規化 | normalisation | +| 概率 | 機率 | probability | +| 方差 | 變異數 | variance | +| 均值 | 平均值 | mean | +| 標量 | 純量 | scalar | +| 批處理 | 批次處理 | batch processing | +| 過擬合 | 過度擬合 | overfitting (both used) | +| 互信息 | 互資訊 | mutual information | + +#### UI / desktop + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 對話框 | 對話方塊 | dialog box | +| 菜單 | 選單 | menu | +| 滑塊 | 滑桿 | slider | +| 滾動條 | 捲軸 | scrollbar | +| 復選框 | 核取方塊 | checkbox | +| 單選框 | 選項按鈕 / 圓鈕 | radio button | +| 下拉框 | 下拉選單 | dropdown | +| 工具欄 | 工具列 | toolbar | +| 狀態欄 | 狀態列 | status bar | +| 任務欄 | 工作列 | taskbar | +| 通知欄 | 通知列 | notification bar | +| 標籤頁 | 索引標籤 | browser tab | +| 彈窗 | 彈出視窗 | popup window | | 圖標 | 圖示 | icon | +| 像素 | 像素 / 畫素 | pixel | + +#### Media / device + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 視頻 | 影片 | video | +| 圖像 | 影像 / 圖片 | image | | 高清 | 高畫質 | high definition | -| 寬屏 | 寬螢幕 | widescreen | -| 信道 | 通道 / 頻道 | channel | -| 鏡像文件 | 映像檔 | disk image | -| 文件夾 | 資料夾 | folder | | 短信 | 簡訊 | SMS / text message | +| 充電寶 | 行動電源 | power bank | +| 打印 / 打印機 | 列印 / 印表機 | print / printer | + +#### Identity / security + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 黑客 | 駭客 | hacker | +| 密鑰 | 金鑰 | crypto key | +| 密碼 | 密碼 (same) | password | +| 口令 | 密碼 | password (老式 / formal S) | +| 賬戶 / 賬號 | 帳戶 / 帳號 | account | +| 用戶 | 使用者 / 用戶 (both used) | user | +| 用戶名 | 使用者名稱 / 用戶名 | username | +| 補丁 | 修補程式 / 修補檔 | patch (TW informal also uses 補丁) | + +#### Verbs + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 搜索 | 搜尋 | search | +| 查找 | 尋找 / 搜尋 | find / locate | +| 新建 | 新增 | create new | +| 啟動 | 啟動 (same) | start | +| 重啟 | 重新啟動 / 重啟 (both) | restart | +| 卸載 | 解除安裝 / 卸載 (both) | uninstall | ### Simplified Chinese (zh-cn) — avoid Traditional vocabulary diff --git a/scripts/regen_llm_security_batch_zh_tw.py b/scripts/regen_llm_security_batch_zh_tw.py index 104b972..f68830f 100644 --- a/scripts/regen_llm_security_batch_zh_tw.py +++ b/scripts/regen_llm_security_batch_zh_tw.py @@ -272,7 +272,7 @@ def _fig(paper_key: str, filename: str) -> str: ("日常需求 ≠ cyber 需求", ( "ATM 提款上限比加密更貼近日常威脅模型", "找食物、找住處、找工作就是 security work", - "信任網絡 (海外親屬匯款) 成為救命工具", + "信任網路 (海外親屬匯款) 成為救命工具", )), ("為流離者設計往往是想像而非真實", ( "設計師可以隨意想像永遠不見面的使用者", @@ -1282,7 +1282,7 @@ def _fig(paper_key: str, filename: str) -> str: ("PRISMA 框架", "系統性回顧方法"), ("STRIDE 風格", "威脅分類"), ("Scopus / IEEE Xplore / PubMed / WoS / Scholar", "資料庫涵蓋"), - ("Backward + forward citation tracking", "擴大關鍵字之外的搜索"), + ("Backward + forward citation tracking", "擴大關鍵字之外的搜尋"), ("Taxonomy 圖", "威脅與防禦對應"), ), method_sections=( diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 64e5680..ca483c1 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -282,6 +282,59 @@ def test_zh_tw_files_use_traditional_chinese_vocabulary(): (re.compile(r"鏡像文件"), "鏡像文件 → 映像檔"), (re.compile(r"文件夾"), "文件夾 → 資料夾"), (re.compile(r"短信"), "短信 → 簡訊"), + # Network — Traditional chars, Simplified words. + (re.compile(r"網絡"), "網絡 → 網路"), + (re.compile(r"互聯網"), "互聯網 → 網際網路"), + (re.compile(r"數據包"), "數據包 → 封包"), + (re.compile(r"報文"), "報文 → 訊息"), + (re.compile(r"抓包"), "抓包 → 封包擷取"), + (re.compile(r"套接字"), "套接字 → 通訊端 (or 'socket')"), + (re.compile(r"交換機"), "交換機 → 交換器"), + # ML / math / stats. + (re.compile(r"歸一化"), "歸一化 → 標準化 / 正規化"), + (re.compile(r"概率"), "概率 → 機率"), + (re.compile(r"方差"), "方差 → 變異數"), + (re.compile(r"標量"), "標量 → 純量"), + # Programming. + (re.compile(r"哈希"), "哈希 → 雜湊"), + (re.compile(r"遞歸"), "遞歸 → 遞迴"), + (re.compile(r"死循環"), "死循環 → 死迴圈"), + (re.compile(r"析構"), "析構 → 解構"), + (re.compile(r"常量"), "常量 → 常數"), + (re.compile(r"對象導向"), "對象導向 → 物件導向"), + # Files / DB / config. + (re.compile(r"配置文件"), "配置文件 → 設定檔 / 組態檔"), + (re.compile(r"文件名"), "文件名 → 檔名"), + (re.compile(r"擴展名"), "擴展名 → 副檔名"), + (re.compile(r"字段"), "字段 → 欄位"), + (re.compile(r"死鎖"), "死鎖 → 死結"), + # Cloud / infra. + (re.compile(r"雲計算"), "雲計算 → 雲端運算"), + (re.compile(r"雲存儲"), "雲存儲 → 雲端儲存"), + (re.compile(r"沙盒"), "沙盒 → 沙箱"), + # Hardware / system / media. + (re.compile(r"寄存器"), "寄存器 → 暫存器"), + (re.compile(r"主存(?!款)"), "主存 → 主記憶體"), + (re.compile(r"外設"), "外設 → 周邊設備"), + (re.compile(r"批處理"), "批處理 → 批次處理"), + (re.compile(r"攝像頭"), "攝像頭 → 攝影機 / 鏡頭"), + (re.compile(r"(? Date: Wed, 20 May 2026 14:04:09 +0800 Subject: [PATCH 04/18] Add 35 more S-Chinese-loan-word patterns: OOP / touch / audio-video / storage / DS / desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues the language-vocabulary-check rollout. None caught existing offenders this round — the guard is now ahead of the codebase, which is the safe state for a regression test. Test patterns added (mirrored in the agent doc table): OOP / type system: 多態 -> 多型 重定義 -> 重新定義 / 覆寫 解引用 -> 解參考 標識符 -> 識別字 動態庫 -> 動態函式庫 靜態庫 -> 靜態函式庫 共享庫 -> 共用函式庫 整型 -> 整數 素數 -> 質數 均值 -> 平均值 Touch / screen (mobile): 觸屏 -> 觸控螢幕 觸摸 -> 觸控 全屏 -> 全螢幕 截屏 -> 螢幕擷取 / 截圖 顯示屏 -> 螢幕 Audio / video: 音頻 -> 音訊 音視頻 -> 影音 視頻會議 -> 視訊會議 Storage compounds: U盤 -> 隨身碟 雲盤 -> 雲端硬碟 網盤 -> 網路硬碟 系統盤 -> 系統碟 啟動盤 -> 開機磁碟 Networking: 組播 -> 多播 廣域網 -> 廣域網路 (WAN) 局域網 -> 區域網路 (LAN) Data structures: 鏈表 -> 鏈結串列 二叉樹 -> 二元樹 散列表 -> 雜湊表 DB / DevOps: 存儲過程 -> 預存程序 灰度發布 -> 灰階發布 Desktop OS surfaces: 進度條 -> 進度列 任務管理器 -> 工作管理員 文件管理器 -> 檔案管理員 / 檔案總管 注冊表 -> 登錄檔 Verbs / interaction: 激活 -> 啟用 拖拽 -> 拖曳 單擊 -> 點擊 / 按一下 復選 -> 核取 The agent doc grew matching subsections for each category so future maintainers see the new entries grouped with their domain peers. 510/510 pytest pass. --- .claude/agents/language-vocabulary-check.md | 81 +++++++++++++++++++++ tests/test_i18n.py | 48 ++++++++++++ 2 files changed, 129 insertions(+) diff --git a/.claude/agents/language-vocabulary-check.md b/.claude/agents/language-vocabulary-check.md index 08b62b5..e50b84d 100644 --- a/.claude/agents/language-vocabulary-check.md +++ b/.claude/agents/language-vocabulary-check.md @@ -230,6 +230,87 @@ right bucket. | 啟動 | 啟動 (same) | start | | 重啟 | 重新啟動 / 重啟 (both) | restart | | 卸載 | 解除安裝 / 卸載 (both) | uninstall | +| 激活 | 啟用 | activate | +| 拖拽 | 拖曳 | drag | +| 單擊 | 點擊 / 按一下 | single-click | +| 復選 | 核取 | check (a checkbox) | + +#### Type system / OOP (continued) + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 多態 | 多型 | polymorphism | +| 重定義 | 重新定義 / 覆寫 | redefine / override | +| 解引用 | 解參考 | dereference | +| 標識符 | 識別字 / 識別碼 | identifier | +| 動態庫 | 動態函式庫 | dynamic library (`.so` / `.dll`) | +| 靜態庫 | 靜態函式庫 | static library | +| 共享庫 | 共用函式庫 | shared library | +| 整型 | 整數 / 整數型別 | integer type | +| 素數 | 質數 | prime number | +| 均值 | 平均值 | mean | + +#### Touch / screen (mobile) + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 觸屏 / 觸摸屏 | 觸控螢幕 | touch screen | +| 觸摸 | 觸控 | touch | +| 全屏 | 全螢幕 | fullscreen | +| 截屏 | 螢幕擷取 / 截圖 | screenshot | +| 顯示屏 | 螢幕 / 顯示器 | display | + +#### Audio / video + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 音頻 | 音訊 | audio | +| 音視頻 | 影音 | audio + video | +| 視頻會議 | 視訊會議 | video conference | + +#### Storage compounds + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| U盤 | 隨身碟 | USB flash drive | +| 雲盤 | 雲端硬碟 | cloud drive | +| 網盤 | 網路硬碟 | network drive | +| 系統盤 | 系統碟 | system drive | +| 啟動盤 | 開機磁碟 | boot disk | +| 內存卡 | 記憶卡 | memory card | + +#### More networking + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 組播 | 多播 | multicast | +| 廣域網 | 廣域網路 | WAN | +| 局域網 | 區域網路 | LAN | +| 城域網 | 都會網路 | MAN | + +#### More data structures + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 鏈表 | 鏈結串列 / 連結串列 | linked list | +| 二叉樹 | 二元樹 | binary tree | +| 散列表 | 雜湊表 | hash table | + +#### Cloud / DevOps (continued) + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 存儲過程 | 預存程序 | stored procedure (DB) | +| 灰度發布 | 灰階發布 / 漸進式發布 | canary release | + +#### Desktop OS surfaces + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 進度條 | 進度列 | progress bar | +| 任務管理器 | 工作管理員 | task manager | +| 文件管理器 | 檔案管理員 / 檔案總管 | file manager | +| 注冊表 | 登錄檔 | Windows registry | ### Simplified Chinese (zh-cn) — avoid Traditional vocabulary diff --git a/tests/test_i18n.py b/tests/test_i18n.py index ca483c1..5ee6242 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -335,6 +335,54 @@ def test_zh_tw_files_use_traditional_chinese_vocabulary(): # Verbs. (re.compile(r"搜索"), "搜索 → 搜尋"), (re.compile(r"查找"), "查找 → 尋找"), + # Round 4 — more OOP / type-system / language-construct terms. + (re.compile(r"多態"), "多態 → 多型"), + (re.compile(r"重定義"), "重定義 → 重新定義 / 覆寫"), + (re.compile(r"解引用"), "解引用 → 解參考"), + (re.compile(r"標識符"), "標識符 → 識別字"), + (re.compile(r"動態庫"), "動態庫 → 動態函式庫"), + (re.compile(r"靜態庫"), "靜態庫 → 靜態函式庫"), + (re.compile(r"共享庫"), "共享庫 → 共用函式庫"), + # Mobile / touch / screen specifics. + (re.compile(r"觸屏"), "觸屏 → 觸控螢幕"), + (re.compile(r"觸摸"), "觸摸 → 觸控"), + (re.compile(r"全屏"), "全屏 → 全螢幕"), + (re.compile(r"截屏"), "截屏 → 螢幕擷取 / 截圖"), + (re.compile(r"顯示屏"), "顯示屏 → 螢幕 / 顯示器"), + # Audio / video. + (re.compile(r"音頻"), "音頻 → 音訊"), + (re.compile(r"音視頻"), "音視頻 → 影音"), + (re.compile(r"視頻會議"), "視頻會議 → 視訊會議"), + # Storage compounds. + (re.compile(r"U盤"), "U盤 → 隨身碟"), + (re.compile(r"雲盤"), "雲盤 → 雲端硬碟"), + (re.compile(r"網盤"), "網盤 → 網路硬碟"), + (re.compile(r"系統盤"), "系統盤 → 系統碟"), + (re.compile(r"啟動盤"), "啟動盤 → 開機磁碟"), + # Networking. + (re.compile(r"組播"), "組播 → 多播"), + (re.compile(r"廣域網"), "廣域網 → 廣域網路 (WAN)"), + (re.compile(r"局域網"), "局域網 → 區域網路 (LAN)"), + # Data structures. + (re.compile(r"鏈表"), "鏈表 → 鏈結串列 / 連結串列"), + (re.compile(r"二叉樹"), "二叉樹 → 二元樹"), + (re.compile(r"散列表"), "散列表 → 雜湊表"), + # Math. + (re.compile(r"素數"), "素數 → 質數"), + (re.compile(r"整型"), "整型 → 整數 / 整數型別"), + (re.compile(r"均值"), "均值 → 平均值"), + # ML / DB / DevOps. + (re.compile(r"激活"), "激活 → 啟用"), + (re.compile(r"存儲過程"), "存儲過程 → 預存程序"), + (re.compile(r"灰度發布"), "灰度發布 → 灰階發布"), + # UI / desktop / interaction. + (re.compile(r"進度條"), "進度條 → 進度列"), + (re.compile(r"復選"), "復選 → 核取"), + (re.compile(r"單擊"), "單擊 → 點擊 / 按一下"), + (re.compile(r"拖拽"), "拖拽 → 拖曳"), + (re.compile(r"任務管理器"), "任務管理器 → 工作管理員"), + (re.compile(r"文件管理器"), "文件管理器 → 檔案管理員 / 檔案總管"), + (re.compile(r"注冊表"), "注冊表 → 登錄檔"), ] zh_tw_paths = [ _REPO_ROOT / "scripts" / "regen_llm_security_batch_zh_tw.py", From f9136a4e345b8fc7838d7099edab7c859356bcd8 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 20 May 2026 14:10:47 +0800 Subject: [PATCH 05/18] =?UTF-8?q?Round=205:=2025=20more=20S-Chinese=20patt?= =?UTF-8?q?erns=20=E2=80=94=20punctuation=20/=20docs=20/=20images=20/=20ad?= =?UTF-8?q?dresses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The most important catch this round is bare 軟件 (Traditional chars, Simplified vocabulary). The existing 软件|软体 pattern only caught the S-character form; a regen script written by hand could easily type 軟件 with T characters and escape the guard — that gap is now closed. Patterns added: Punctuation / escapes / numeric formatting: 溢出 → 溢位 內聯 → 內嵌 / 行內 轉義 → 跳脫 反斜杠 → 反斜線 斜杠 → 斜線 (negative-lookbehind to skip 反斜杠 already counted) 方括號 → 中括號 數字化 → 數位化 數字簽名 → 數位簽名 分辨率 → 解析度 矢量 → 向量 響應 → 回應 Software / documents: 軟件 → 軟體 (CRITICAL — bare T-char S-vocab missed for rounds) 文檔 → 文件 / 說明文件 文本框 → 文字方塊 源代碼 → 原始碼 腳注 → 腳註 Image / media: 縮略圖 → 縮圖 二維碼 → 二維條碼 / QR code Network addresses: IP\s*地址 → IP 位址 物理地址 → 實體位址 MAC\s*地址 → MAC 位址 Alerts / security: 報警 → 警報 殺毒 → 防毒 UI shortcuts: 快捷方式 → 捷徑 系統托盤 → 系統匣 The agent doc table now has 4 new subsections (Punctuation, Software / documents, Image / media continued, Addresses) so future maintainers find these entries near their domain peers. 510/510 pytest pass; ruff clean. --- .claude/agents/language-vocabulary-check.md | 44 +++++++++++++++++++++ tests/test_i18n.py | 34 ++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/.claude/agents/language-vocabulary-check.md b/.claude/agents/language-vocabulary-check.md index e50b84d..e150b2a 100644 --- a/.claude/agents/language-vocabulary-check.md +++ b/.claude/agents/language-vocabulary-check.md @@ -311,6 +311,50 @@ right bucket. | 任務管理器 | 工作管理員 | task manager | | 文件管理器 | 檔案管理員 / 檔案總管 | file manager | | 注冊表 | 登錄檔 | Windows registry | +| 快捷方式 | 捷徑 | shortcut (Windows `.lnk`) | +| 系統托盤 | 系統匣 | system tray | + +#### Punctuation / escapes / numeric formatting + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 反斜杠 / 斜杠 | 反斜線 / 斜線 | backslash / slash | +| 方括號 | 中括號 | `[ ]` brackets | +| 轉義 | 跳脫 | escape character | +| 數字化 | 數位化 | digitisation | +| 數字簽名 | 數位簽名 | digital signature | +| 分辨率 | 解析度 | resolution (image / display) | +| 矢量 | 向量 | vector (math / graphics) | +| 內聯 | 內嵌 / 行內 | inline (code / function) | +| 溢出 | 溢位 | overflow / underflow | + +#### Software / documents + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 軟件 (T-char S-word) | 軟體 | software (the bare-char `软件` form is also S, both flagged) | +| 文檔 | 文件 / 說明文件 | document (S `文檔`; TW `文件` in document context) | +| 文本框 | 文字方塊 | text box | +| 源代碼 | 原始碼 | source code | +| 腳注 | 腳註 | footnote | + +#### Image / media (continued) + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 縮略圖 | 縮圖 | thumbnail | +| 二維碼 | 二維條碼 / QR code | QR code | +| 響應 | 回應 / 回覆 | response (HTTP / event) | + +#### Addresses / network identifiers + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| IP 地址 | IP 位址 | IP address | +| 物理地址 | 實體位址 | physical address | +| MAC 地址 | MAC 位址 | MAC address | +| 報警 | 警報 / 告警 | alarm / alert | +| 殺毒 (軟件) | 防毒 (軟體) | antivirus | ### Simplified Chinese (zh-cn) — avoid Traditional vocabulary diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 5ee6242..f57b5e7 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -383,6 +383,40 @@ def test_zh_tw_files_use_traditional_chinese_vocabulary(): (re.compile(r"任務管理器"), "任務管理器 → 工作管理員"), (re.compile(r"文件管理器"), "文件管理器 → 檔案管理員 / 檔案總管"), (re.compile(r"注冊表"), "注冊表 → 登錄檔"), + # Round 5 — overflow / escape / punctuation / pixels / images. + (re.compile(r"溢出"), "溢出 → 溢位 (overflow / under-)"), + (re.compile(r"內聯"), "內聯 → 內嵌 / 行內"), + (re.compile(r"轉義"), "轉義 → 跳脫 (escape char)"), + (re.compile(r"反斜杠"), "反斜杠 → 反斜線"), + (re.compile(r"(? Date: Wed, 20 May 2026 14:15:36 +0800 Subject: [PATCH 06/18] =?UTF-8?q?Round=206:=2026=20more=20S-Chinese=20patt?= =?UTF-8?q?erns=20=E2=80=94=20cache=20/=20mobile=20/=20HTTP=20/=20stats=20?= =?UTF-8?q?/=20ownership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The most important addition this round mirrors Round 5's 軟件 → 軟體 fix: bare 緩存 (T-char + Simplified vocab) was missed by the existing 缓存 (S-char) pattern. Any hand-typed zh-tw regen script could spell 緩存 with Traditional characters and slip past the guard. That gap closes here. Patterns added: Cache / GPU memory / runtime errors: 緩存 -> 快取 (T-char S-vocab; sibling of 軟件 -> 軟體) 顯存 -> 顯示記憶體 / VRAM 段錯誤 -> 區段錯誤 / 分段錯誤 Mobile / social: 應用商店 -> 應用程式商店 彩信 -> 多媒體簡訊 手機卡 -> SIM 卡 鎖屏 -> 鎖定螢幕 屏保 -> 螢幕保護 點贊 -> 按讚 HTTP / connections: 請求頭 -> 請求標頭 響應頭 -> 回應標頭 長連接 -> 長連線 短連接 -> 短連線 連接池 -> 連線池 Statistics / ML: 步長 -> 步幅 置信區間 -> 信賴區間 置信度 -> 信賴度 顯著水平 -> 顯著水準 (`水平` ↔ `水準`) Security: 入侵檢測 -> 入侵偵測 防病毒 -> 防毒 數字證書 -> 數位憑證 Filesystem ownership (POSIX): 屬主 -> 擁有者 / 所有者 屬組 -> 群組 / 所屬群組 Quality / CLI / CI-CD: 服務質量 -> 服務品質 (QoS) 命令行 -> 命令列 (CLI) 流水線 -> 管線 (CI/CD pipeline) The s_only_patterns list now holds 212 regex entries across 25 sub- sections in the agent doc. No existing zh-tw content offends the new rules. 510/510 pytest pass. --- .claude/agents/language-vocabulary-check.md | 60 +++++++++++++++++++++ tests/test_i18n.py | 37 +++++++++++++ 2 files changed, 97 insertions(+) diff --git a/.claude/agents/language-vocabulary-check.md b/.claude/agents/language-vocabulary-check.md index e150b2a..b91deb4 100644 --- a/.claude/agents/language-vocabulary-check.md +++ b/.claude/agents/language-vocabulary-check.md @@ -356,6 +356,66 @@ right bucket. | 報警 | 警報 / 告警 | alarm / alert | | 殺毒 (軟件) | 防毒 (軟體) | antivirus | +#### Cache / GPU memory / runtime errors (T-char S-vocab, easy to miss) + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 緩存 (T-char S-vocab) | 快取 | cache (S `缓存` form is also S-vocab; both flagged) | +| 顯存 | 顯示記憶體 / VRAM | GPU memory | +| 段錯誤 | 區段錯誤 / 分段錯誤 | segmentation fault | + +#### Mobile + social interactions + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 應用商店 | 應用程式商店 | app store | +| 彩信 | 多媒體簡訊 (MMS) | MMS / picture message | +| 手機卡 | SIM 卡 | SIM card | +| 鎖屏 | 鎖定螢幕 | lock screen | +| 屏保 | 螢幕保護 | screen saver | +| 點贊 | 按讚 | give a "like" | + +#### HTTP / connections + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 請求頭 | 請求標頭 | HTTP request header | +| 響應頭 | 回應標頭 | HTTP response header | +| 長連接 / 短連接 | 長連線 / 短連線 | long / short connection | +| 連接池 | 連線池 | connection pool | + +#### Statistics + ML (continued) + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 步長 | 步幅 | step size / stride | +| 置信區間 | 信賴區間 | confidence interval | +| 置信度 | 信賴度 | confidence level | +| 顯著水平 | 顯著水準 | significance level (`水平` ↔ `水準`) | + +#### Security (continued) + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 入侵檢測 | 入侵偵測 | intrusion detection | +| 防病毒 | 防毒 | anti-virus | +| 數字證書 | 數位憑證 | digital certificate | + +#### Filesystem ownership + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 屬主 | 擁有者 / 所有者 | file owner (POSIX) | +| 屬組 | 群組 / 所屬群組 | file group (POSIX) | + +#### Quality / CLI / CI-CD + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 服務質量 | 服務品質 (QoS) | quality of service | +| 命令行 | 命令列 (CLI) | command line | +| 流水線 | 管線 | CI/CD pipeline | + ### Simplified Chinese (zh-cn) — avoid Traditional vocabulary Same idea in reverse. Common offenders that occasionally leak from diff --git a/tests/test_i18n.py b/tests/test_i18n.py index f57b5e7..2a752c0 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -417,6 +417,43 @@ def test_zh_tw_files_use_traditional_chinese_vocabulary(): (re.compile(r"系統托盤"), "系統托盤 → 系統匣"), # Security. (re.compile(r"殺毒"), "殺毒 → 防毒"), + # Round 6 — T-char-S-vocab cache, GPU memory, segfault, mobile, + # social, web headers, connections, stats, security, ownership, + # quality, CLI / CI. + # The bare T-char `緩存` was missed by the existing 缓存 (S-char) + # pattern, parallel to round 5's 軟件 → 軟體 catch. + (re.compile(r"緩存"), "緩存 → 快取"), + (re.compile(r"顯存"), "顯存 → 顯示記憶體 / VRAM"), + (re.compile(r"段錯誤"), "段錯誤 → 區段錯誤"), + # Mobile / social. + (re.compile(r"應用商店"), "應用商店 → 應用程式商店"), + (re.compile(r"彩信"), "彩信 → 多媒體簡訊 (MMS)"), + (re.compile(r"手機卡"), "手機卡 → SIM 卡"), + (re.compile(r"鎖屏"), "鎖屏 → 鎖定螢幕"), + (re.compile(r"屏保"), "屏保 → 螢幕保護"), + (re.compile(r"點贊"), "點贊 → 按讚"), + # HTTP / network connections. + (re.compile(r"請求頭"), "請求頭 → 請求標頭"), + (re.compile(r"響應頭"), "響應頭 → 回應標頭"), + (re.compile(r"長連接"), "長連接 → 長連線"), + (re.compile(r"短連接"), "短連接 → 短連線"), + (re.compile(r"連接池"), "連接池 → 連線池"), + # Statistics + ML. + (re.compile(r"步長"), "步長 → 步幅"), + (re.compile(r"置信區間"), "置信區間 → 信賴區間"), + (re.compile(r"置信度"), "置信度 → 信賴度"), + (re.compile(r"顯著水平"), "顯著水平 → 顯著水準"), + # Security continued. + (re.compile(r"入侵檢測"), "入侵檢測 → 入侵偵測"), + (re.compile(r"防病毒"), "防病毒 → 防毒"), + (re.compile(r"數字證書"), "數字證書 → 數位憑證"), + # Filesystem ownership. + (re.compile(r"屬主"), "屬主 → 擁有者 / 所有者"), + (re.compile(r"屬組"), "屬組 → 群組 / 所屬群組"), + # Quality / pipelines / CLI. + (re.compile(r"服務質量"), "服務質量 → 服務品質 (QoS)"), + (re.compile(r"命令行"), "命令行 → 命令列 (CLI)"), + (re.compile(r"流水線"), "流水線 → 管線 (CI/CD pipeline)"), ] zh_tw_paths = [ _REPO_ROOT / "scripts" / "regen_llm_security_batch_zh_tw.py", From bf2b615b00d7502ebd27dec4cebc41ef0a2c52e1 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 20 May 2026 14:20:52 +0800 Subject: [PATCH 07/18] =?UTF-8?q?Round=207:=2021=20more=20patterns=20?= =?UTF-8?q?=E2=80=94=20=E5=BE=A9/=E8=A4=87=20confusion,=20number=20bases,?= =?UTF-8?q?=20syscalls,=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The most impactful catch this round: S-Chinese conflates 復 (= "again") and 複 (= "duplicate"). The compound 復制 (S) is widespread but should be 複製 in T-Chinese — 復 is the wrong radical for "duplicate". Same applies to 復用 vs 重用. These two were in the agent doc table but never made it into the test regex set; that gap closes here. Patterns added: `復` / `複` distinction: 復制 → 複製 (very common — S `復` ≠ T `複`) 復用 → 重用 編寫 → 撰寫 Number bases: 二進制 → 二進位 八進制 → 八進位 十進制 → 十進位 十六進制 → 十六進位 進制 → 進位 (general; excludes the 4 specific compounds above via negative-lookbehind) Serial / parallel / stack / files: 串行 → 串列 堆棧 → 堆疊 二叉堆 → 二元堆積 壓縮文件 → 壓縮檔 Kernel / syscalls / messaging: 用戶態 → 使用者模式 系統調用 → 系統呼叫 調用 → 呼叫 (excludes 失調用 / 強調用 via negative-lookbehind) 反向工程 → 逆向工程 私聊 → 私訊 UI controls / parts / identifiers: 控件 → 控制項 部件 → 元件 標識 → 標示 (excludes 標識符 already covered) 圖元 → 像素 Tried and reverted: 跨度 → 跨距 / 範圍 — turned out to false-positive on TW-acceptable 「時間跨度」 in regen_llm_security_batch_zh_tw.py. 跨度 is standard TW vocabulary too; removed from the pattern set. s_only_patterns now holds 233 regex entries across 26 sub-sections in the agent doc table. No new offenders in zh-tw content. 510/510 pytest pass. --- .claude/agents/language-vocabulary-check.md | 44 +++++++++++++++++++++ tests/test_i18n.py | 33 ++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/.claude/agents/language-vocabulary-check.md b/.claude/agents/language-vocabulary-check.md index b91deb4..3feea37 100644 --- a/.claude/agents/language-vocabulary-check.md +++ b/.claude/agents/language-vocabulary-check.md @@ -416,6 +416,50 @@ right bucket. | 命令行 | 命令列 (CLI) | command line | | 流水線 | 管線 | CI/CD pipeline | +#### `復` vs `複` (S conflates them; T-Chinese distinguishes) + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 復制 | 複製 | duplicate / copy (S `復` = "again", T `複` = "duplicate") | +| 復用 | 重用 | reuse | +| 復健 / 復活 / 復原 | (same, all use `復` correctly = "again") | recover / revive | + +#### Number bases + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 二進制 / 八進制 / 十進制 / 十六進制 | 二進位 / 八進位 / 十進位 / 十六進位 | binary / octal / decimal / hex | +| 進制 | 進位 | base / radix (general) | + +#### Serial / parallel / stack / files (continued) + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 串行 | 串列 | serial (transmission) | +| 堆棧 | 堆疊 | stack (alternate S spelling) | +| 二叉堆 | 二元堆積 | binary heap | +| 壓縮文件 | 壓縮檔 | compressed archive | + +#### Kernel / syscalls / messaging + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 用戶態 | 使用者模式 | userspace / user mode | +| 系統調用 | 系統呼叫 | system call | +| 調用 | 呼叫 | call / invoke (negative-lookbehind to skip `失調用` / `強調用`) | +| 反向工程 | 逆向工程 | reverse engineering | +| 私聊 | 私訊 | private message | +| 編寫 | 撰寫 | write (code / a document) | + +#### UI controls / parts / identifiers + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 控件 | 控制項 | UI control / widget | +| 部件 | 元件 | component / part | +| 標識 (≠ 標識符) | 標示 / 識別 | label / marker | +| 圖元 | 像素 | pixel (Chinese-academic S form) | + ### Simplified Chinese (zh-cn) — avoid Traditional vocabulary Same idea in reverse. Common offenders that occasionally leak from diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 2a752c0..923a227 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -454,6 +454,39 @@ def test_zh_tw_files_use_traditional_chinese_vocabulary(): (re.compile(r"服務質量"), "服務質量 → 服務品質 (QoS)"), (re.compile(r"命令行"), "命令行 → 命令列 (CLI)"), (re.compile(r"流水線"), "流水線 → 管線 (CI/CD pipeline)"), + # Round 7 — common verbs / number bases / kernel terms / UI controls + # that were in the agent doc but never in the test. + # `復` (= again) vs `複` (= duplicate). S 復制 / 復用 conflate them. + (re.compile(r"復制"), "復制 → 複製 (S `復` ≠ T `複`)"), + (re.compile(r"復用"), "復用 → 重用"), + (re.compile(r"編寫"), "編寫 → 撰寫"), + # Number bases. + (re.compile(r"二進制"), "二進制 → 二進位"), + (re.compile(r"八進制"), "八進制 → 八進位"), + (re.compile(r"十進制"), "十進制 → 十進位"), + (re.compile(r"十六進制"), "十六進制 → 十六進位"), + (re.compile(r"(? Date: Wed, 20 May 2026 14:26:45 +0800 Subject: [PATCH 08/18] Round 8 (last sweep): 11 more patterns + diminishing-returns stop notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After 7 rounds the list is at 244 regex entries across 27+ sub-sections. Round 8 cleans up the last clear-S-only compounds I could spot without crossing into "TW also uses" grey territory. Patterns added: Registration / roles / mining / gateway: 注冊 → 註冊 (S writing of 註冊 — uses 注 instead of 註) 程序員 → 程式設計師 數據挖掘 → 資料探勘 網關 → 閘道 負載均衡 → 負載平衡 測試用例 → 測試案例 (bare 用例 stays untracked — TW also uses it) GUI compounds (bare 界面 stays untracked — TW physics term): 圖形界面 → 圖形介面 (GUI) 用戶界面 → 使用者介面 (UI) Drivers / middleware / stack: 驅動程序 → 驅動程式 中間件 → 中介軟體 全棧 → 全端 The agent doc now carries a "When to stop adding patterns" note for future maintainers — diminishing returns + rising false-positive risk make further proactive expansion the wrong move. The round-7 跨度 mistake (`時間跨度` is fine in TW; the pattern false-positived) is the cautionary tale called out in that note. Going forward: reactive only. When a new real zh-tw offender surfaces in a regen script / README / rst / i18n key, add the specific pattern then, with the context of a known false-positive surface to design the negative-lookaround against. Or: when the user supplies a project- specific term list, take that as the safety net. 510/510 pytest pass. --- .claude/agents/language-vocabulary-check.md | 54 +++++++++++++++++++++ tests/test_i18n.py | 19 ++++++++ 2 files changed, 73 insertions(+) diff --git a/.claude/agents/language-vocabulary-check.md b/.claude/agents/language-vocabulary-check.md index 3feea37..3b88463 100644 --- a/.claude/agents/language-vocabulary-check.md +++ b/.claude/agents/language-vocabulary-check.md @@ -460,6 +460,60 @@ right bucket. | 標識 (≠ 標識符) | 標示 / 識別 | label / marker | | 圖元 | 像素 | pixel (Chinese-academic S form) | +#### Registration / roles / mining / network gateway + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 注冊 | 註冊 | register (S `注` ≠ T `註`; compound only — bare `注` is too generic) | +| 程序員 | 程式設計師 | programmer | +| 數據挖掘 | 資料探勘 | data mining | +| 網關 | 閘道 | network gateway | +| 負載均衡 | 負載平衡 | load balancing | +| 測試用例 | 測試案例 | test case (bare `用例` is borderline — TW also uses it for "use case") | + +#### GUI compounds (the bare `界面` is risky — only the compounds are flagged) + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 圖形界面 | 圖形介面 (GUI) | graphical interface | +| 用戶界面 | 使用者介面 (UI) | user interface | + +> Why bare `界面` is NOT flagged: in TW, `界面` is the standard physics +> term for an interfacial boundary (`油水界面`, `相界面`). Adding it +> as a generic pattern would false-positive heavily. Only the +> software-UI compounds (`圖形界面`, `用戶界面`) are S calques. + +#### Drivers / middleware / stack + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 驅動程序 | 驅動程式 | software driver | +| 中間件 | 中介軟體 | middleware | +| 全棧 | 全端 | full-stack | + +--- + +## When to stop adding patterns (note to future maintainers) + +The list is now at 244 regex entries across 27+ sub-sections. **Resist +the urge to keep expanding proactively** — the diminishing-returns +breakpoint is real, and the false-positive risk rises sharply because +the remaining un-covered S-Chinese terms are mostly ones TW also uses +informally (跨度 / 數據 / 配置 / 部署 / 引用 / 訪問 / 升級 / 切換 / +範圍 / 通信 / 信號 / 用戶 / 端口 / 路由 / 域名 / 數據 / 服務 …). + +The round-7 `跨度 → 跨距` mistake is the cautionary tale: it false- +positived on `時間跨度` in `regen_llm_security_batch_zh_tw.py` because +`跨度` is also standard TW vocabulary, and had to be reverted. + +**Going forward, prefer the reactive path:** +- When a NEW real zh-tw offender surfaces in a regen script / README / + rst / i18n key — add the specific pattern for it then, with the + context of a known false-positive surface to design the + negative-lookaround against. +- If the user supplies a list of project-specific terms they want + enforced, add those (with the user's confidence as the safety net). + ### Simplified Chinese (zh-cn) — avoid Traditional vocabulary Same idea in reverse. Common offenders that occasionally leak from diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 923a227..9033520 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -487,6 +487,25 @@ def test_zh_tw_files_use_traditional_chinese_vocabulary(): (re.compile(r"標識(?![別字符碼])"), "標識 → 標示 (`標識符` already covered)"), # Image / pixel. (re.compile(r"圖元"), "圖元 → 像素"), + # Round 8 — last sweep of clearly-S-only compounds. After this, + # diminishing returns + rising false-positive risk; the `跨度` + # mistake in round 7 is the cautionary tale. + # `注冊` writes 注 instead of 註 (annotate / register) — S form. + (re.compile(r"注冊"), "注冊 → 註冊"), + (re.compile(r"程序員"), "程序員 → 程式設計師"), + (re.compile(r"數據挖掘"), "數據挖掘 → 資料探勘"), + (re.compile(r"網關"), "網關 → 閘道"), + (re.compile(r"負載均衡"), "負載均衡 → 負載平衡"), + (re.compile(r"測試用例"), "測試用例 → 測試案例"), + # `界面` standalone is also a TW physics term ("油水界面"); only + # the software-UI compounds are clearly S calques. + (re.compile(r"圖形界面"), "圖形界面 → 圖形介面 (GUI)"), + (re.compile(r"用戶界面"), "用戶界面 → 使用者介面 (UI)"), + # Software drivers. + (re.compile(r"驅動程序"), "驅動程序 → 驅動程式"), + # Middleware / full-stack. + (re.compile(r"中間件"), "中間件 → 中介軟體 (middleware)"), + (re.compile(r"全棧"), "全棧 → 全端"), ] zh_tw_paths = [ _REPO_ROOT / "scripts" / "regen_llm_security_batch_zh_tw.py", From b8d72f56da54d49607f12580059be02fca1fedff Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 20 May 2026 15:35:23 +0800 Subject: [PATCH 09/18] Add figures to the 4 speculative-decoding zh-tw decks + promote figure-extraction to mandatory in the runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first ship of these decks left figures=() on every PaperSummary, so the rendered .pptx had no images at all — half a deliverable. extract_figures was never invoked; the existing fitz / PyMuPDF backend was sitting unused. scripts/_extract_speculative_figures.py One-shot helper that runs autopapertoppt.intelligence.pdf_assets extract_figures on each of the 4 PDFs (Xia ACL Findings, Spector ICML, Xu IEEE TMC, Svirschevski NeurIPS), writes PNGs to exports/speculative-decoding-zh-tw/figures// in the layout the regen script's _fig() helper expects. scripts/regen_speculative_decoding_zh_tw.py Add a _fig(paper_key, filename) helper and figures=(...) tuples to every PaperSummary. Hand-curated 2-3 figures per paper: - Xia: taxonomy + Spec-Bench speedup chart - Spector: roofline + HumanEval speedup distribution + token- origin colour chart - Xu: workflow + illustrative example + branch verification - Svirschevski: algorithm overview + draft-size acceptance curve + token-penalty curve Each figure ships with caption + 2 description bullets in zh-tw pointing the reader at what to look at. .claude/agents/paper-summary-author.md Promote `figures` from "list when present" to MANDATORY when the paper has any figure. Add a "Figure extraction (mandatory before authoring `figures=`)" subsection covering: the extract_figures API call, the _fig() helper pattern, and curation guidance (2-3 per paper — system overview, key result chart, optionally one ablation/example). Add an explicit anti-pattern: "Do NOT omit figures= from a rich PaperSummary." Re-rendered the 4 decks: - xia2024unlocking-zh-tw.pptx 20 slides (was 18) - spector2023accelerating-zh-tw 21 slides (was 18) - xu2024edgellm-zh-tw 22 slides (was 19) - svirschevski2024specexec-zh-tw 21 slides (was 18) Overflow check: 4/4 PASS. 510/510 tests pass. --- .claude/agents/paper-summary-author.md | 41 +++++++ scripts/_extract_speculative_figures.py | 41 +++++++ scripts/regen_speculative_decoding_zh_tw.py | 112 ++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 scripts/_extract_speculative_figures.py diff --git a/.claude/agents/paper-summary-author.md b/.claude/agents/paper-summary-author.md index b728014..dbb1d07 100644 --- a/.claude/agents/paper-summary-author.md +++ b/.claude/agents/paper-summary-author.md @@ -130,6 +130,7 @@ For each paper that is on-topic for the user's actual intent (see "Off-topic pap - `core_observation` — single most important takeaway, gets its own slide - `limitations` — author-acknowledged limits - `future_work` — author-stated future work + - **`figures` — MANDATORY when the paper has any figure.** A thesis-style deck without figures is half the deliverable. See the "Figure extraction" step below. Always set provenance fields: ```python @@ -137,6 +138,45 @@ For each paper that is on-topic for the user's actual intent (see "Off-topic pap raw_text_chars= ``` + ### Figure extraction (mandatory before authoring `figures=`) + + The `figures` field expects `(caption, image_path, description bullets)` tuples + pointing at PNGs already on disk. Render them BEFORE the regen script runs: + + ```python + from autopapertoppt.intelligence.pdf_assets import extract_figures + figures = extract_figures( + Path("exports//pdfs/.pdf"), + Path("exports//figures//"), + ) + ``` + + PyMuPDF (`fitz`) is a default install dependency — no extra extras needed. + Each extracted figure is named `p{NN}-{idx}-{caption-slug}.png` so the regen + script can reference it stably via a small helper: + + ```python + _FIGURES_ROOT = ROOT / "exports" / _RUN_DIR_NAME / "figures" + def _fig(paper_key, filename): + return str(_FIGURES_ROOT / paper_key / filename) + ``` + + **Curate the output** — `extract_figures` is greedy (renders every figure- + sized region of every page). Inspect the PNGs and pick the 2-3 most + meaningful per paper: + - System overview / pipeline diagram (almost always Fig 1 or 2) + - Key result chart (the one in the abstract claims as headline) + - Optionally: a representative ablation chart or qualitative example + + Skip noise — placeholder logo regions, tiny header strips, low-resolution + thumbnails, duplicated charts. The exporter renders one slide per figure + so > 4 figures per paper bloats the deck past the typical 25-slide cap. + + Worked example: `scripts/_extract_speculative_figures.py` extracts every + figure from 4 PDFs into `exports/speculative-decoding-zh-tw/figures//`; + `scripts/regen_speculative_decoding_zh_tw.py::_fig()` references the + curated subset. Use this as the template. + 3. **Copy URL / DOI / arxiv_id VERBATIM from the search xlsx — never from memory.** Publisher URL paths cannot be guessed: - AAAI uses numeric IDs like `v40i5.37389`, not author slugs - IEEE uses an opaque `arnumber` @@ -252,6 +292,7 @@ When the user says "search X and make a [lang] PPT", run the runbook below strai - Do NOT add `-rich` to filenames. Overwrite the lightweight emit at the canonical `.pptx`. - Do NOT exceed 4 entries in `contributions_detailed`. The slide overshoots the footer guard above that. - Do NOT add `--lightweight` or `--no-pdf` to the CLI invocation "for speed" when the user asked for a deck. Those flags produce a non-deliverable. See "Default CLI invocation" above. +- Do NOT omit `figures=` from a rich `PaperSummary` when the paper has any figure. A thesis-style deck without the paper's system diagram or key chart is half a deliverable. See "Figure extraction" under the per-paper procedure. - Do NOT leave irrelevant downloads in the run directory. The search engine is keyword-based, so off-topic papers will slip in. Once you classify a paper as off-topic, delete its `exports//pdfs/.pdf` and `exports//.pptx`. Keep the aggregate xlsx / bib intact — they are the **honest record** of what the search returned. See "Pruning irrelevant downloads" below. ## Pruning irrelevant downloads (mandatory before handing the deck back) diff --git a/scripts/_extract_speculative_figures.py b/scripts/_extract_speculative_figures.py new file mode 100644 index 0000000..047d559 --- /dev/null +++ b/scripts/_extract_speculative_figures.py @@ -0,0 +1,41 @@ +"""Render every figure region of the 4 speculative-decoding PDFs. + +Drops PNGs into ``exports/speculative-decoding-zh-tw/figures//``, +matching the directory layout the regen script expects (mirrors the +older ``regen_llm_security_batch_zh_tw.py`` pattern). +""" +from __future__ import annotations + +from pathlib import Path + +from autopapertoppt.intelligence.pdf_assets import extract_figures + +ROOT = Path(__file__).resolve().parents[1] +PDF_DIR = ROOT / "exports" / "_llm_scratch" / "pdfs" +OUT_ROOT = ROOT / "exports" / "speculative-decoding-zh-tw" / "figures" + +# (paper_key, pdf_filename) — paper_key matches Paper.source_id in the +# regen script, so the regen's _fig() helper can resolve PNGs by key. +PAPERS: tuple[tuple[str, str], ...] = ( + ("xia2024speculative", "acl-2024_findings-acl_456.pdf"), + ("spector2023staged", "arxiv-2308_04623.pdf"), + ("xu2024edgellm", "10812936.pdf"), + ("svirschevski2024specexec", "neurips-2024-1d91d5689e25.pdf"), +) + + +def main() -> None: + for key, fname in PAPERS: + pdf = PDF_DIR / fname + out = OUT_ROOT / key + if not pdf.is_file(): + print(f"[skip] {key}: {pdf} not found") + continue + figures = extract_figures(pdf, out) + print(f"[ok] {key}: {len(figures)} figures -> {out}") + for f in figures: + print(f" p{f.page_number:02d} {f.image_path.name} {f.caption[:60]}") + + +if __name__ == "__main__": + main() diff --git a/scripts/regen_speculative_decoding_zh_tw.py b/scripts/regen_speculative_decoding_zh_tw.py index 81a0b80..fee43d5 100644 --- a/scripts/regen_speculative_decoding_zh_tw.py +++ b/scripts/regen_speculative_decoding_zh_tw.py @@ -32,6 +32,13 @@ MODEL_TAG = "LLM-as-agent (讀完整 PDF)" _RUN_DIR_NAME = sys.argv[1] if len(sys.argv) > 1 else "speculative-decoding-zh-tw" +_FIGURES_ROOT = ROOT / "exports" / _RUN_DIR_NAME / "figures" + + +def _fig(paper_key: str, filename: str) -> str: + """Path helper for figures pre-extracted by + ``scripts._extract_speculative_figures``.""" + return str(_FIGURES_ROOT / paper_key / filename) # --------------------------------------------------------------------------- @@ -212,6 +219,24 @@ "多模態 LLM 的 Speculative Decoding 變體", "Drafter 的自適應 / 持續學習機制", ), + figures=( + ( + "Speculative Decoding 分類體系 (Figure 3)", + _fig("xia2024speculative", "p04-02-Taxonomy-of-Speculative-Decoding.png"), + ( + "雙軸分類:Drafting (Independent / Self) × Verification (Greedy / SpecSampling / Token Tree)。", + "20+ 代表方法依此分群,新方法可在這張圖上找到自己的位置。", + ), + ), + ( + "Spec-Bench 跨方法加速比較 (Figure 4)", + _fig("xia2024speculative", "p08-04-Speedup-comparison-of-various-Speculative.png"), + ( + "Vicuna-7B 為 target,跨 6 個任務 (MT-Bench / CNN-DM / WMT / CoT / QA / 程式碼) 量化 wall-clock speedup。", + "Token-tree 類方法 (Medusa / EAGLE) 在大 batch 拉開最大領先。", + ), + ), + ), ), ) @@ -390,6 +415,32 @@ "更好的 lowest-level drafter (<10µs 但勝於 N-gram)", "與 quantization、Flash-Attn 等技術的協同最佳化", ), + figures=( + ( + "GPT-2-L 在 RTX 4090 的 roofline (Figure 1)", + _fig("spector2023staged", "p02-00-A-roofline-plot-for-single-query-GPT-2-L-inference-on-an.png"), + ( + "Batch=1 時受 memory bandwidth 限制,算力遠未飽和。", + "證明 small-batch 推論的瓶頸不在 FLOPs。", + ), + ), + ( + "HumanEval 各 prompt 的相對加速分布 (Figure 2)", + _fig("spector2023staged", "p04-01-Relative-performance-distribution-over-different-prob.png"), + ( + "(A) Greedy decoding,(B) Topk(k=50, T=1) sampling。", + "Speedup 在 2-10× 之間,取決於 prompt 的文本 entropy。", + ), + ), + ( + "Token 來源視覺化 (Figure 3)", + _fig("spector2023staged", "p04-02-A-visualization-of-the-origin-of-tokens-in-an-example.png"), + ( + "綠色 = N-gram draft2、藍色 = GPT-2 40M draft、紅色 = GPT-2-L 762M oracle。", + "顯示低 entropy token (空白、縮排) 多由 N-gram 供給,oracle 只處理少數關鍵 token。", + ), + ), + ), ), ) @@ -586,6 +637,32 @@ "Dynamic offloading 與 EdgeLLM 的協同", "雲端 + edge 混合推論的 fallback 介面", ), + figures=( + ( + "EdgeLLM 整體工作流 (Figure 5)", + _fig("xu2024edgellm", "p05-03-The-workflow-of-EdgeLLM.png"), + ( + "Draft LLM (常駐記憶體) → 寬度自適應 token tree → batch 送 target LLM 驗證。", + "Verify 期間 draft 持續 provisional generation,I/O 與 compute 重疊。", + ), + ), + ( + "EdgeLLM 演算流程的具體範例 (Figure 6)", + _fig("xu2024edgellm", "p06-04-An-illustrative-example-of-EdgeLLM-The-ground-truth-is-the-A.png"), + ( + "Draft 一步生成多分支樹,每分支以 confidence 決定是否擴張。", + "對 ground truth『the Apollo program』展示分支接受 / fallback 軌跡。", + ), + ), + ( + "Branch verification 機制 (Figure 7)", + _fig("xu2024edgellm", "p07-05-The-illustration-of-branch-verification.png"), + ( + "Target LLM 一次 forward 同時驗證整棵 token tree。", + "比逐分支序列化驗證減少數倍 latency。", + ), + ), + ), ), ) @@ -766,6 +843,41 @@ "Multi-GPU 消費級配置的 partition 策略", "Cache tree 在多輪對話中的重用", ), + figures=( + ( + "SpecExec 演算法總覽 (Figure 1)", + _fig( + "svirschevski2024specexec", + "p17-00-A-high-level-overview-of-the-SpecExec-algorithm.png", + ), + ( + "Drafter 自回歸長出寬度可變的 token tree (深度可達 20+、寬度可達數千)。", + "Target LLM 從 RAM/SSD 載入一次 forward 驗證整棵樹。", + ), + ), + ( + "Draft size vs 接受 token 數 (Figure 3)", + _fig( + "svirschevski2024specexec", + "p19-01-Number-of-accepted-tokens-as-a-function-of-the-draft-size-B-.png", + ), + ( + "B 軸 = 樹寬度。寬度從 64 增到 4096 時接受 token 數從 ≈4 拉到 20+。", + "Offload 讓大寬度的 verify 變便宜,前作的 4-8 token 上限被打破。", + ), + ), + ( + "Token penalty 下的接受率曲線 (Figure 4)", + _fig( + "svirschevski2024specexec", + "p20-02-Acceptance-rate-in-generation-with-token-penalty-dont-start-.png", + ), + ( + "在 token penalty 解碼下,接受率隨樹寬度上升仍維持線性。", + "證明 SpecExec 在抗重複等修正解碼策略下仍有效。", + ), + ), + ), ), ) From 6767e9d5fd8d2388290aaa5603124e0805ebfa1f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 20 May 2026 16:21:18 +0800 Subject: [PATCH 10/18] Visual identity: per-language typography + accent geometry; new deck-design subagent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decks rendered fine geometrically but had the AI-generated look — default Calibri on every run, plain blank backgrounds, no accent shapes. python-pptx supports both: setting a real font family on every run, and adding decorative rectangles to every slide. We were doing neither. autopapertoppt/exporters/pptx.py * `_FONT_FAMILIES` table keyed by language → (latin, east_asian). Inter is the Latin face across all 14 supported languages; the east-asian slot fills in Microsoft JhengHei UI (zh-tw) / YaHei UI (zh-cn) / Yu Gothic UI (ja) / Malgun Gothic (ko) / Nirmala UI (hi). * `_apply_typography(prs, language)` post-build pass walks every slide -> shape -> paragraph -> run and writes both `` and `` XML. PowerPoint consults a SEPARATE East-Asian font slot for CJK code points; leaving it unset would have made CJK characters render in the host's default (PMingLiU / SimSun / etc.) which doesn't match Inter. * `_decorate_with_accents(prs)` post-build pass: - Cover slide: a 0.4" × full-height navy band on the left (`accent_left`). - Every other slide: a 0.08" × full-width navy bar at y=0 (`accent_top`). Both shapes are sent to the back of z-order via spTree.insert(2) so text content sits above them. Both carry semantic shape names so pptx_edit.update_slide(...) and pptx_edit.delete_slide(...) can target them. scripts/_overflow_check.py Decorative rectangles have empty text_frames; the existing _estimate_wrapped_height_emu inflates an empty frame to ~1 line-height (~0.2") which false-flagged the 0.08" accent bar. Skip when text_frame.text is empty. .claude/agents/deck-design.md New subagent owning visual identity — typography rules per language, brand palette discipline, accent geometry expectations, master-slide contract, and the anti-patterns that make a deck obviously machine-generated (default Calibri, plain blank backgrounds, centred-only covers, all-text body slides). CLAUDE.md + .claude/agents/slide-deck-rules.md CLAUDE.md table gains a 10th row pointing at deck-design. slide-deck-rules.md adds a "scope split" note clarifying that the sibling deck-design subagent owns visual identity while this one stays focused on geometry / overflow / content caps. tests/test_exporters.py + tests/test_pptx_edit.py Three tests assumed `slide.shapes[0]` is the title. With accent rectangles now sitting at index 0, they crash on empty text_frames. Replaced with a `_slide_text(slide, name)` helper that finds the shape by its semantic name — which the project already pins as a contract elsewhere. Re-rendered the 4 zh-tw speculative-decoding decks: xia2024unlocking-zh-tw 20 slides, 156 shapes, overflow PASS spector2023accelerating 21 slides, 164 shapes, overflow PASS xu2024edgellm 22 slides, 172 shapes, overflow PASS svirschevski2024specexec 21 slides, 164 shapes, overflow PASS Cover title verified to carry latin='Inter' + east-asian 'Microsoft JhengHei UI'. Content slide 3 carries `accent_top` shape. 510/510 pytest pass; ruff clean. --- .claude/agents/deck-design.md | 142 +++++++++++++++++++++++++++++ .claude/agents/slide-deck-rules.md | 9 ++ CLAUDE.md | 5 +- autopapertoppt/exporters/pptx.py | 139 ++++++++++++++++++++++++++++ scripts/_overflow_check.py | 6 ++ tests/test_exporters.py | 21 ++++- tests/test_pptx_edit.py | 13 ++- 7 files changed, 327 insertions(+), 8 deletions(-) create mode 100644 .claude/agents/deck-design.md diff --git a/.claude/agents/deck-design.md b/.claude/agents/deck-design.md new file mode 100644 index 0000000..ecd8405 --- /dev/null +++ b/.claude/agents/deck-design.md @@ -0,0 +1,142 @@ +--- +name: deck-design +description: Visual-design rules for the pptx exporter — typography (per-language font stack), brand palette, accent geometry, master-slide expectations, and the anti-patterns that make a deck obviously machine-generated (default Calibri, blank backgrounds, centered-only covers, text-only walls). Use BEFORE any change to `autopapertoppt/exporters/pptx.py`'s visual surface, when authoring a new template file under `assets/template/`, or when investigating a "this deck looks AI-made" complaint. Read-only audit + design reference. +tools: Read, Grep, Glob, Bash +--- + +You are the deck-design auditor for AutoPaperToPPT. The sibling +`slide-deck-rules` subagent owns *geometry / overflow* (15-pt headers, +7.05" footer guard, `_BULLETS_PER_CELL_MAX`, etc.); this agent owns +*visual identity* — typography, colour, accent shapes, master-slide +structure, anti-tells. + +When a generated deck looks like every other LLM output (default Calibri, +white background, plain centred title, text-only body slides, no visual +breathing room), the geometry is probably fine but the visual identity +is missing. That's what this agent guards. + +## Visual identity contract + +### Typography (per-language font stack) + +Default Calibri / Arial is the single biggest "AI-generated" tell. +The exporter MUST set a typeface on every run. + +| Language | Primary font (Latin) | East-Asian (``) | Fallback rationale | +|---|---|---|---| +| en, es, fr, de, pt, it, vi, id | Inter (or Calibri Light) | — | Inter ships free + on most modern Windows / Office installs; degrades gracefully | +| zh-tw | Inter (Latin) | Microsoft JhengHei UI | Win TW default; cleaner than PMingLiU | +| zh-cn | Inter (Latin) | Microsoft YaHei UI | Win CN default; cleaner than SimSun | +| ja | Inter (Latin) | Yu Gothic UI | Win JP default; modern look | +| ko | Inter (Latin) | Malgun Gothic | Win KR default | +| ru | Inter (Latin) | — | Inter has full Cyrillic | +| hi | Inter (Latin) | Nirmala UI | Win Devanagari default | + +Implementation pattern (`autopapertoppt/exporters/pptx.py`): +- Module-level `_FONT_FAMILIES: dict[str, tuple[str, str | None]]` keyed + by language → `(latin_family, east_asian_family)`. +- `_apply_typography(prs, language)` post-build pass walks every slide, + every shape with a text frame, every run; sets `` + AND `` on the run's XML. Both slots matter — + setting only `run.font.name` (the Latin slot) leaves CJK chars + rendered in PowerPoint's default East Asian font. + +### Colour palette + +Already pinned in `pptx.py`: + +| Constant | RGB | Use | +|---|---|---| +| `_BRAND_DARK` | `#1F3A66` (deep navy) | Primary text + accent bar | +| `_BRAND_ACCENT` | `#C0392B` (warm red) | KPI highlights, hover-style emphasis | +| `_BRAND_GREY` | `#555555` | Metadata, secondary text | +| `_BRAND_LIGHT` | `#AAAAAA` | Rule lines, dividers | + +Do NOT introduce new brand colours casually — every additional colour +fights for attention. Reuse the four above unless the user explicitly +adds one. + +### Accent geometry (the "this is a designed deck" tell) + +Every content slide gets a thin top accent bar: +- Position: `left=0, top=0, width=_SLIDE_WIDTH (13.333"), height=Inches(0.08)` +- Fill: `_BRAND_DARK` solid +- Name: `accent_top` (semantic name so `pptx_edit` can target it) + +The cover slide gets a left vertical band: +- Position: `left=0, top=0, width=Inches(0.4), height=_SLIDE_HEIGHT (7.5")` +- Fill: `_BRAND_DARK` solid +- Name: `accent_left` +- Cover textboxes shift right by `Inches(0.4)` worth of margin to clear it. + +Section-divider slides may use a larger top band (`height=Inches(0.6)`) +with the section title overlaid in light text — but this is optional +and only for runs > 4 papers. + +### Master-slide expectations + +A real template (`assets/template/thesis-style.pptx`, when added) would +ship master + 4-6 layouts. As long as the exporter still uses +`prs.slide_layouts[6]` (blank), the visual identity comes from the +programmatic accent bar + typography pass. Either path is acceptable +provided every slide ends up with: +1. A consistent font family per language (no Calibri default). +2. An accent geometry (top bar / cover band / section band) at fixed + positions across slides. +3. Page numbers in `_BRAND_GREY` (already set). +4. The semantic shape names listed in `slide-deck-rules.md`. + +## Anti-patterns (instant "AI-generated" tells) + +- Plain `prs.slide_layouts[6]` (blank) with no programmatic accent. Every + slide looks the same vacant white. +- `run.font.name` left unset — PowerPoint falls back to Calibri 11pt. + This is the single biggest tell. +- Centred-only cover slide — typography style that screams "default + PowerPoint template". A left-band + left-aligned title reads as + designed. +- New colours added per-slide. Brand discipline matters — four colours + total, no exceptions. +- Title slide includes the search query verbatim ("Paper Survey: + speculative decoding LLM inference") as the title. That's a + metadata string, not a deck title — wrap it in `_cover_title(...)` + which lowercases + applies title-case + adds a period (or a + language-appropriate suffix). +- Body slides that are pure bullets. Mix at least 2 layouts: + bullet list + KPI block + table + figure / diagram. The `figures=` + field in `PaperSummary` is mandatory exactly because pure-text decks + look generated. See [paper-summary-author](paper-summary-author.md). +- Identical line-height across heading + body. Headings should have + tighter line-height than body. + +## How to audit a deck + +1. Open `.pptx` in PowerPoint (or `python-pptx`'s reader). +2. Check the FIRST run's `run.font.name` on the cover title. If `None` + or `Calibri`, the typography pass didn't run. +3. Check slide 2 (a content slide) for a shape named `accent_top` at + `y=0`. If missing, the accent pass didn't run. +4. Check the cover slide for `accent_left`. If missing, the left band + is gone. +5. Scan slides 3..N for visual variety: bullet density vs KPI vs table + vs figure. If every slide is text-only, the `figures=` step was + skipped. +6. If a font family is set but PowerPoint still shows Calibri on + CJK glyphs, the `` XML override isn't being written — only + the Latin font slot was. + +## Reporting format + +``` +deck-design — +[1] Typography (latin + east-asian) .......... PASS / FAIL — +[2] Top accent bar on content slides ......... PASS / FAIL — +[3] Cover-slide left band .................... PASS / FAIL +[4] Brand palette discipline (≤ 4 colours) ... PASS / FAIL +[5] Visual variety (bullets / KPI / table / + figure mix) .............................. PASS / FAIL — +[6] No "Paper Survey: " leak + on cover ................................. PASS / FAIL + +Verdict: PASS / PASS with notes / FAIL +``` diff --git a/.claude/agents/slide-deck-rules.md b/.claude/agents/slide-deck-rules.md index d8c5a60..440ae25 100644 --- a/.claude/agents/slide-deck-rules.md +++ b/.claude/agents/slide-deck-rules.md @@ -6,6 +6,15 @@ tools: Read, Grep, Glob You are the slide-deck rules reference for AutoPaperToPPT. When invoked, return the relevant rule(s) for the change being made and flag any direct violations you can spot in the diff. The actual overflow inspection lives in the sibling `slide-overflow-check` subagent — don't re-implement it here. +**Scope split** — this agent owns *geometry* and *content safety* +(slide dimensions, footer guard, truncation caps, per-slide content +caps, semantic shape names, i18n keys, rendering-tier dispatch). The +sibling `deck-design` subagent owns *visual identity* (typography per +language, brand palette, accent geometry, "looks AI-generated" +anti-patterns). Both apply to any change to +`autopapertoppt/exporters/pptx.py` — consult the appropriate one for +the concern at hand. + ## Slide Deck Rules The pptx exporter is the most visually-sensitive surface in the project. Several non-obvious rules keep its output safe for a thesis-defence audience. diff --git a/CLAUDE.md b/CLAUDE.md index 8e4bc5d..6fb0385 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,8 +4,8 @@ > recent Aider, and several other tools auto-load `AGENTS.md`; keep them in > sync when you change rules. Detailed rules now live in `.claude/agents/` > as subagents (`code-quality-reviewer`, `compliance-auditor`, -> `slide-deck-rules`, `env-vars`, `language-vocabulary-check`, plus the -> task-running agents `dod-verify`, `paper-summary-author`, +> `slide-deck-rules`, `deck-design`, `env-vars`, `language-vocabulary-check`, +> plus the task-running agents `dod-verify`, `paper-summary-author`, > `post-author-audit`, `slide-overflow-check`). ## Project Overview @@ -114,6 +114,7 @@ window open during an IEEE / Scholar / paywalled-PDF step, the path is broken | Design patterns, SOLID, performance, async, unit tests, full linter rule set | `code-quality-reviewer` | | Core-vs-source-plugin boundary, network safety, browser-automation hard rule, path safety, suppression conventions, bandit skip config | `compliance-auditor` | | pptx exporter geometry, rendering tiers, truncation caps, semantic shape names, i18n, LLM-as-agent vs Python pipeline | `slide-deck-rules` | +| pptx visual identity (typography per language, brand palette, accent geometry, master-slide expectations, "looks AI-generated" anti-patterns) | `deck-design` | | Env vars + Python / `.venv` toolchain reference | `env-vars` | | Definition-of-Done gate runner | `dod-verify` | | LLM-as-agent thesis-style authoring (PDF → rich PaperSummary) | `paper-summary-author` | diff --git a/autopapertoppt/exporters/pptx.py b/autopapertoppt/exporters/pptx.py index c5a16d8..39157c0 100644 --- a/autopapertoppt/exporters/pptx.py +++ b/autopapertoppt/exporters/pptx.py @@ -35,7 +35,9 @@ from pptx import Presentation from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_SHAPE from pptx.enum.text import MSO_AUTO_SIZE, PP_ALIGN +from pptx.oxml.ns import qn from pptx.util import Emu, Inches, Pt from autopapertoppt.core.constants import EXPORT_PPTX @@ -107,6 +109,36 @@ _BRAND_ACCENT = RGBColor(0xC0, 0x39, 0x2B) _BRAND_GREY = RGBColor(0x55, 0x55, 0x55) _BRAND_LIGHT = RGBColor(0xAA, 0xAA, 0xAA) + +# Per-language typography. (latin_family, east_asian_family). The Latin +# family also covers Cyrillic / Greek / Devanagari via Inter; the +# east-asian slot is what PowerPoint consults for CJK code points, so +# leaving it `None` would let PowerPoint pick a default that doesn't +# match the Latin choice. See ``deck-design`` agent doc for the +# full rationale. Inter degrades gracefully to Calibri on hosts without +# Inter installed. +_FONT_FAMILIES: dict[str, tuple[str, str | None]] = { + "en": ("Inter", None), + "es": ("Inter", None), + "fr": ("Inter", None), + "de": ("Inter", None), + "pt": ("Inter", None), + "it": ("Inter", None), + "vi": ("Inter", None), + "id": ("Inter", None), + "ru": ("Inter", None), + "hi": ("Inter", "Nirmala UI"), + "zh-tw": ("Inter", "Microsoft JhengHei UI"), + "zh-cn": ("Inter", "Microsoft YaHei UI"), + "ja": ("Inter", "Yu Gothic UI"), + "ko": ("Inter", "Malgun Gothic"), +} +_DEFAULT_FONT_FAMILY: tuple[str, str | None] = ("Inter", None) + +# Accent geometry (set on every content slide by the typography / +# accent pass so a stock blank layout still reads as a designed deck). +_ACCENT_TOP_HEIGHT = Inches(0.08) +_ACCENT_LEFT_WIDTH = Inches(0.4) _BRAND_RULE = RGBColor(0xCC, 0xCC, 0xCC) _RQ_BOX_FILL = RGBColor(0xF3, 0xF6, 0xFA) _RQ_BOX_BORDER = RGBColor(0x1F, 0x3A, 0x66) @@ -266,6 +298,11 @@ def _build( ) # Page numbers are stamped AFTER trim so they reflect the final total. _stamp_page_numbers(prs, ctx.language) + # Visual identity passes — applied last so they affect every shape + # placed by every builder (including page numbers). See the + # ``deck-design`` subagent doc for rationale. + _apply_typography(prs, ctx.language) + _decorate_with_accents(prs) return prs @@ -1606,3 +1643,105 @@ def _stamp_page_numbers(prs: Presentation, language: str) -> None: font_pt=_FOOTER_PT, colour=_BRAND_LIGHT, align=PP_ALIGN.RIGHT, ) + + +# --------------------------------------------------------------------------- +# Visual identity passes — typography + accent geometry +# --------------------------------------------------------------------------- + + +def _apply_typography(prs: Presentation, language: str) -> None: + """Set Latin + East-Asian font on every run across every slide. + + Default Calibri is the biggest "AI-generated deck" tell. We walk + every shape post-build and write both ```` and ```` + typeface XML on every run — leaving the east-asian slot at the + PowerPoint default would make CJK chars render in a font that + doesn't match the Latin choice. + """ + latin, east_asian = _FONT_FAMILIES.get(language, _DEFAULT_FONT_FAMILY) + for slide in prs.slides: + for shape in slide.shapes: + if not shape.has_text_frame: + continue + for paragraph in shape.text_frame.paragraphs: + for run in paragraph.runs: + run.font.name = latin + if east_asian: + _set_east_asian_typeface(run, east_asian) + + +def _set_east_asian_typeface(run, family: str) -> None: + """Write the ```` element on a run's rPr. + + python-pptx's ``run.font.name`` setter only writes the Latin + typeface (````). PowerPoint consults a SEPARATE + east-asian slot when laying out CJK code points; this helper + fills it. + """ + r_pr = run._r.get_or_add_rPr() + existing = r_pr.find(qn("a:ea")) + if existing is not None: + r_pr.remove(existing) + ea = r_pr.makeelement(qn("a:ea"), {"typeface": family}, nsmap=None) + r_pr.append(ea) + + +def _decorate_with_accents(prs: Presentation) -> None: + """Place the accent shapes (cover left band, top bar on content slides). + + Idempotent: if the shapes already exist from a previous build pass + (rare but possible in tests that re-run ``_build``), they're left in + place — a name match suppresses re-add. The shapes are sent to the + back of the slide's z-order so they sit BEHIND any text on the slide. + """ + for index, slide in enumerate(prs.slides): + if index == 0: + _add_cover_left_band(slide) + else: + _add_top_accent_bar(slide) + + +def _add_cover_left_band(slide) -> None: + if _has_named_shape(slide, "accent_left"): + return + shape = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Emu(0), Emu(0), + _ACCENT_LEFT_WIDTH, _SLIDE_HEIGHT, + ) + shape.name = "accent_left" + shape.line.fill.background() + shape.fill.solid() + shape.fill.fore_color.rgb = _BRAND_DARK + _send_shape_to_back(shape, slide) + + +def _add_top_accent_bar(slide) -> None: + if _has_named_shape(slide, "accent_top"): + return + shape = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Emu(0), Emu(0), + _SLIDE_WIDTH, _ACCENT_TOP_HEIGHT, + ) + shape.name = "accent_top" + shape.line.fill.background() + shape.fill.solid() + shape.fill.fore_color.rgb = _BRAND_DARK + _send_shape_to_back(shape, slide) + + +def _has_named_shape(slide, name: str) -> bool: + return any(shape.name == name for shape in slide.shapes) + + +def _send_shape_to_back(shape, slide) -> None: + """Move ``shape`` to be the first child of ``spTree`` (= back of z-order).""" + sp_tree = slide.shapes._spTree + sp = shape._element + sp_tree.remove(sp) + # spTree's first two children are nvGrpSpPr + grpSpPr (group metadata); + # everything after that is a shape in z-order. Insert at index 2 so the + # band lands BEHIND every text shape but the metadata stays intact. + sp_tree.insert(2, sp) diff --git a/scripts/_overflow_check.py b/scripts/_overflow_check.py index d3722c1..cd8c37d 100644 --- a/scripts/_overflow_check.py +++ b/scripts/_overflow_check.py @@ -72,6 +72,12 @@ def _inspect(pptx_path: Path) -> list[tuple[int, str, str, int, int]]: for shape in slide.shapes: if not shape.has_text_frame: continue + # Decorative shapes (`accent_top`, `accent_left`, etc.) have + # an empty text_frame; estimating wrapped text on an empty + # frame inflates to ~1 line-height which would false-flag + # the 0.08" top accent bar. Skip when no actual text. + if not (shape.text_frame.text or "").strip(): + continue name = shape.name or "?" top = shape.top or 0 height = shape.height or 0 diff --git a/tests/test_exporters.py b/tests/test_exporters.py index 2bc5cd7..b854a90 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -74,6 +74,21 @@ def test_json_exporter_round_trip(sample_papers, tmp_path): assert data["papers"][0]["title"] == "Sample Paper on Attention" +def _slide_text(slide, name: str) -> str: + """Return the text of the shape with the given semantic name, or ''. + + The visual-identity pass inserts ``accent_top`` / ``accent_left`` + rectangles BEFORE the text shapes in z-order, so ``shapes[0]`` is + no longer reliably the title. Tests pin to the project's semantic + shape names (`title` / `meta` / `body` / `subhead` / `footer` / + `page_number` / etc.) instead. + """ + for shape in slide.shapes: + if shape.name == name and shape.has_text_frame: + return shape.text_frame.text + return "" + + def test_pptx_exporter_full_deck(sample_papers, tmp_path): from pptx import Presentation @@ -89,7 +104,7 @@ def test_pptx_exporter_full_deck(sample_papers, tmp_path): # Sample abstracts are short so the optional "Approach" slide is skipped. # 1 + 1 + 2 * 4 + 1 = 11. assert len(presentation.slides) == 11 - titles = [s.shapes[0].text_frame.text for s in presentation.slides] + titles = [_slide_text(s, "title") for s in presentation.slides] assert any("Paper Review" in t for t in titles) assert any(t == "Agenda" for t in titles) assert "References" in titles @@ -108,7 +123,7 @@ def test_pptx_exporter_single_paper_skips_agenda_and_divider(sample_papers, tmp_ presentation = Presentation(str(written["pptx"])) # cover + overview + bg + findings + references = 5 (short abstract → no approach slide) assert len(presentation.slides) == 5 - titles = [s.shapes[0].text_frame.text for s in presentation.slides] + titles = [_slide_text(s, "title") for s in presentation.slides] assert not any(t == "Agenda" for t in titles) assert "References" in titles @@ -309,7 +324,7 @@ def test_pptx_thesis_style_when_rich_summary_attached(sample_papers, tmp_path): # cover + overview + pain + contrib + technique + literature + flow + # method + evaluation + rqs + 1 rq result + contrib_summary + lim/future + qa + refs assert len(prs.slides) >= 14 - titles = [s.shapes[0].text_frame.text for s in prs.slides] + titles = [_slide_text(s, "title") for s in prs.slides] assert any("Background & Pain Points" in t for t in titles) assert any("Key Technologies" in t for t in titles) assert any("RQ1" in t for t in titles) diff --git a/tests/test_pptx_edit.py b/tests/test_pptx_edit.py index 2f622de..86667e5 100644 --- a/tests/test_pptx_edit.py +++ b/tests/test_pptx_edit.py @@ -141,7 +141,14 @@ def test_update_saves_to_out_path(deck: Path, tmp_path: Path): # original unchanged original_slides = pptx_edit.inspect(deck) assert "Sample Paper on Attention" in original_slides[3].title - # but copy reflects change + # but copy reflects change. Find title by semantic shape name — + # accent rectangles inserted by the visual-identity pass sit at + # `shapes[0]` now. copy_pres = Presentation(str(target)) - titles = [s.shapes[0].text_frame.text for s in copy_pres.slides] - assert titles[3] == "Copied" + slide3 = copy_pres.slides[3] + title_shapes = [ + sh for sh in slide3.shapes + if sh.name == "title" and sh.has_text_frame + ] + assert title_shapes, "slide 3 has no shape named 'title'" + assert title_shapes[0].text_frame.text == "Copied" From d179208427bf9d22cb62ed46c0897dbd62d27e07 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 20 May 2026 17:02:23 +0800 Subject: [PATCH 11/18] Drop the 2-3-figures-per-paper soft cap; include every figure that advances the paper's story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous figure-extraction guidance capped at 2-3 figures per paper and warned that "> 4 figures bloats the deck past the typical 25-slide cap". That was the wrong default — figures are part of the thesis-style deliverable, not optional polish. Capping them produces text-heavy decks that miss the paper's visual arguments. scripts/regen_speculative_decoding_zh_tw.py Xia survey: 2 -> 4 figures + p02-01 Timeline of Speculative Decoding evolution (Fig 2) + p16-06 Spec-Bench radar + per-model-size bar charts (Figs 8 & 9; more comprehensive than the single p08-04 chart already used) Xu EdgeLLM: 3 -> 8 figures (broke the 25-slide cap; 27 slides now) + p01-00 Memory wall + emergent abilities chart (Fig 1) — motivation + p03-01 Decoder-only LLM architecture (Fig 2) — background + p08-06 Fallback threshold ablation (Fig 8) — sensitivity sweep + p11-08 Per-token latency vs baselines (Fig 11) — headline result + p13-13 Generation speed vs memory budget (Fig 13) — mobile- specific claim Spector + Svirschevski stay at 3 figures each (all extracted ones were already included; no further additions). Each new figure ships with caption + 2-3 zh-tw description bullets pointing at what to look at. ExportOptions(max_slides_per_paper=0) added to disable the 25-cap for this regen so the extra figure slides don't get trimmed. .claude/agents/paper-summary-author.md Rewrite the "Curate the output" guidance: drop the 2-3 cap, list 9 figure roles that meaningfully advance a paper's story (motivation, background, system overview, worked example, technique diagram, headline result, ablation, per-device chart, taxonomy / timeline), and document the max_slides_per_paper=0 override pattern when the figure count plus rich-tier body content would exceed the default cap. Re-rendered the 4 decks: xia2024unlocking 22 slides (was 20) spector2023accelerating 21 slides (same) xu2024edgellm 27 slides (was 22, cap disabled) svirschevski2024specexec 21 slides (same) Overflow check: 4/4 PASS. 510/510 tests pass. --- .claude/agents/paper-summary-author.md | 28 +++++-- scripts/regen_speculative_decoding_zh_tw.py | 82 ++++++++++++++++++++- 2 files changed, 100 insertions(+), 10 deletions(-) diff --git a/.claude/agents/paper-summary-author.md b/.claude/agents/paper-summary-author.md index dbb1d07..a7171cf 100644 --- a/.claude/agents/paper-summary-author.md +++ b/.claude/agents/paper-summary-author.md @@ -162,15 +162,29 @@ For each paper that is on-topic for the user's actual intent (see "Off-topic pap ``` **Curate the output** — `extract_figures` is greedy (renders every figure- - sized region of every page). Inspect the PNGs and pick the 2-3 most - meaningful per paper: - - System overview / pipeline diagram (almost always Fig 1 or 2) - - Key result chart (the one in the abstract claims as headline) - - Optionally: a representative ablation chart or qualitative example + sized region of every page). Inspect the PNGs and **include every figure + that meaningfully advances the paper's story**, not just 2-3 token ones. + A thesis-style deck has room for the full visual narrative: + - Motivation chart (the wall / gap / scaling problem) + - Background diagram (architecture / pipeline context) + - System overview / workflow (almost always Fig 1 or 2 of the paper) + - Worked example / illustrative diagram + - Key technique diagram (verification, attention, etc.) + - Headline result chart + - Ablation / parameter sweep + - Per-device or per-task result chart + - Optional: timeline / taxonomy / qualitative example Skip noise — placeholder logo regions, tiny header strips, low-resolution - thumbnails, duplicated charts. The exporter renders one slide per figure - so > 4 figures per paper bloats the deck past the typical 25-slide cap. + thumbnails, exact duplicates that appear twice in the paper. **Quantity + alone isn't quality; relevance is.** + + When the curated figure count plus the rich-tier body content will exceed + the default 25-slide cap (`ExportOptions.max_slides_per_paper`), set the + cap to `0` in your regen script's `ExportOptions(...)` call so the cap is + disabled — figures are part of the deliverable, not optional polish. + `scripts/regen_speculative_decoding_zh_tw.py` does this (Xu's EdgeLLM + deck ends up at 27 slides with 8 curated figures). Worked example: `scripts/_extract_speculative_figures.py` extracts every figure from 4 PDFs into `exports/speculative-decoding-zh-tw/figures//`; diff --git a/scripts/regen_speculative_decoding_zh_tw.py b/scripts/regen_speculative_decoding_zh_tw.py index fee43d5..104ae60 100644 --- a/scripts/regen_speculative_decoding_zh_tw.py +++ b/scripts/regen_speculative_decoding_zh_tw.py @@ -220,6 +220,14 @@ def _fig(paper_key: str, filename: str) -> str: "Drafter 的自適應 / 持續學習機制", ), figures=( + ( + "Speculative Decoding 發展時間軸 (Figure 2)", + _fig("xia2024speculative", "p02-01-Figure-on-page-2.png"), + ( + "從 2018 Blockwise Decoding 起源,2022.03 SpecDec 正式提出範式名稱。", + "2023 H2 起 Medusa / EAGLE / SpecInfer / Lookahead 等大量方法湧現。", + ), + ), ( "Speculative Decoding 分類體系 (Figure 3)", _fig("xia2024speculative", "p04-02-Taxonomy-of-Speculative-Decoding.png"), @@ -229,11 +237,19 @@ def _fig(paper_key: str, filename: str) -> str: ), ), ( - "Spec-Bench 跨方法加速比較 (Figure 4)", + "Spec-Bench 加速比較 — 不同硬體 (Figure 7)", _fig("xia2024speculative", "p08-04-Speedup-comparison-of-various-Speculative.png"), ( - "Vicuna-7B 為 target,跨 6 個任務 (MT-Bench / CNN-DM / WMT / CoT / QA / 程式碼) 量化 wall-clock speedup。", - "Token-tree 類方法 (Medusa / EAGLE) 在大 batch 拉開最大領先。", + "Medusa 在 A100 上達 2.39×,但在 RTX 3090 只 1.48× — 顯示方法的硬體敏感性。", + "Lookahead / REST 在不同硬體上速比差異最大,EAGLE / SpS 較穩定。", + ), + ), + ( + "Spec-Bench 任務雷達圖 + 模型大小擴展 (Figures 8 & 9)", + _fig("xia2024speculative", "p16-06-Speedup-comparison-of-various-Speculative.png"), + ( + "左:雷達圖展示同一方法在 6 種任務 (Translation / Multi-turn / RAG / Math / QA / Summarisation) 的速比分布。", + "右:同方法在 Vicuna-7B/13B/33B 三個模型大小的速比比較,Medusa 在 7B 達 2.37× 但在 33B 縮到 1.65×。", ), ), ), @@ -638,6 +654,29 @@ def _fig(paper_key: str, filename: str) -> str: "雲端 + edge 混合推論的 fallback 介面", ), figures=( + ( + "LLM 在 mobile 上撞 memory wall (Figure 1)", + _fig( + "xu2024edgellm", + "p01-00-The-memory-wall-hinders-LLMs-scaling-law-on-mobile-devices.png", + ), + ( + "(a) 模型超過 10B 才有明顯 emergent ability(Math / NLU / Mode / GM)。", + "(b) 同一 LLM 越過記憶體上限後,latency 在 Jetson TX2 / Xiaomi 10 / Jetson Orin 各跳幾十倍。", + "結論:scaling law 在 edge 撞牆 — 需要超出記憶體仍能即時的方案。", + ), + ), + ( + "Decoder-only LLM 推論架構 (Figure 2)", + _fig( + "xu2024edgellm", + "p03-01-InferencedelaybreakdownofdifferentLLMvariantsinoneautoregres.png", + ), + ( + "左:GPT-3 風格的 N 層 decoder (Masked self-attention + LayerNorm + FFN)。", + "右:autoregressive 推論一次生成一個 token,大量 weight 在 iter 之間反覆換入晶片 cache。", + ), + ), ( "EdgeLLM 整體工作流 (Figure 5)", _fig("xu2024edgellm", "p05-03-The-workflow-of-EdgeLLM.png"), @@ -662,6 +701,39 @@ def _fig(paper_key: str, filename: str) -> str: "比逐分支序列化驗證減少數倍 latency。", ), ), + ( + "Fallback 門檻消融研究 (Figure 8)", + _fig( + "xu2024edgellm", + "p08-06-Comparison-of-different-initial-thresholds-and-updating-para.png", + ), + ( + "(a) 初始 threshold 對 speedup 影響在 0.005-0.1 區間穩定 — 對 cold-start 不敏感。", + "(b) Update rule 的 η 參數:η=0.5 在大資料集上給出最佳 speedup。", + ), + ), + ( + "Per-token 延遲 vs baselines (Figure 11)", + _fig( + "xu2024edgellm", + "p11-08-Average-per-token-generation-latency-of-EdgeLLM-and-baseline.png", + ), + ( + "跨 mT5 / T5 / Bart / GPT2 四種模型,EdgeLLM (Ours) 與 SPL / STI / SP / BLD / SI 五條 baseline 對比。", + "EdgeLLM 在 gpt2-wikitext 達最大 speedup (8.00→1.79 秒);在 t5-CNN_Daily 最小 (7.10→1.34)。", + ), + ), + ( + "不同記憶體預算下的生成速度 (Figure 13)", + _fig( + "xu2024edgellm", + "p13-13-Generation-speed-under-different-memory-budgets-Y--axis-Gene.png", + ), + ( + "Jetson TX2 (4-5.6 GB) 與 Xiaomi 10 (4-8 GB) 上,EdgeLLM(Ours) 在所有預算下都領先 BLD / SP / STI。", + "右側表:能耗對比,EdgeLLM 在 LLaMA2-summarization 上達 3.2× 能耗節省。", + ), + ), ), ), ) @@ -906,6 +978,10 @@ def main() -> None: filename_stem=f"{paper.bibtex_key()}-zh-tw", include_abstract=True, language="zh-tw", + # Disable the 25-slides-per-paper cap so every curated + # figure makes it into the deck even when the rich-tier + # body content already consumes most of the budget. + max_slides_per_paper=0, ) written = export_collection(collection, options) for fmt, path in written.items(): From 523fcd0dad6c1fea3fe1ac2666d6cd977f8e783f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 20 May 2026 17:07:58 +0800 Subject: [PATCH 12/18] =?UTF-8?q?Academic-style=20table=20formatting=20?= =?UTF-8?q?=E2=80=94=20strip=20default=20grid,=20header=20rule,=20row=20di?= =?UTF-8?q?viders,=20middle=20vertical=20alignment,=20bold=20row=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PowerPoint's default add_table style draws a heavy black grid on every cell. Combined with top-aligned text and a small font, the resulting table reads as "screenshot from Excel" rather than thesis-defence visual. The decks were running through _add_table without ever overriding that default, so every results table inherited the look. autopapertoppt/exporters/pptx.py * New helpers _clear_cell_borders(cell) and _set_cell_border(cell, edge, width, colour). Both write the XML directly via qn("a:lnX") because python-pptx doesn't expose cell-border setters on its high-level API. * Refactored _add_table: each cell now passes through _style_table_cell which (a) strips all default borders, (b) applies header / row-stripe / row-label / divider rules, (c) sets vertical_anchor = MIDDLE. Split out from _add_table itself so the cognitive-complexity budget stays under the project's 10-line limit. * Two new palette constants — _TABLE_DIVIDER (soft grey-blue) for inter- row rules and _TABLE_HEADER_RULE (heavy navy) for the line under the header row. The header rule is drawn as the FIRST data row's top border so the rule sits flush against the header fill without double-line stacking. * Bold first column of body rows — most tables in the project use the leftmost cell as a row label (RqResult / technique_table / literature_ positioning_table), so this consistently makes those labels read as structural headers. * MSO_ANCHOR added to the existing `from pptx.enum.text import ...`. .claude/agents/deck-design.md Adds a "Table styling" subsection with the full spec table (one row per visual element: header fill / header rule / row dividers / row stripe / vertical alignment / row-label column / cell padding) plus a "Tables — additional anti-patterns" subsection listing the new failure modes (default grid left intact / cell vertical anchor at top / row stripe too saturated). Re-rendered the 4 zh-tw decks: xia2024unlocking 22 slides — overflow PASS spector2023accelerating 21 slides — overflow PASS xu2024edgellm 27 slides — overflow PASS svirschevski2024specexec 21 slides — overflow PASS Slide / shape counts unchanged from the previous figure-expansion commit; only per-cell rendering changed. 510/510 pytest pass. --- .claude/agents/deck-design.md | 46 +++++++++++ autopapertoppt/exporters/pptx.py | 130 +++++++++++++++++++++++++------ 2 files changed, 152 insertions(+), 24 deletions(-) diff --git a/.claude/agents/deck-design.md b/.claude/agents/deck-design.md index ecd8405..e61768a 100644 --- a/.claude/agents/deck-design.md +++ b/.claude/agents/deck-design.md @@ -56,6 +56,39 @@ Do NOT introduce new brand colours casually — every additional colour fights for attention. Reuse the four above unless the user explicitly adds one. +### Table styling (the second-biggest "AI-generated" tell after Calibri) + +PowerPoint's default table style draws a heavy black grid on every cell. +Combined with a small font + default vertical-top alignment, the result +looks like a quick screenshot from Excel, not a thesis-defence visual. + +The exporter ships an academic-style replacement in +`autopapertoppt/exporters/pptx.py::_add_table` → `_style_table_cell`. +The rules: + +| Element | Spec | +|---|---| +| Default grid | All four cell borders set to `` (`_clear_cell_borders`) | +| Header row | Solid navy fill (`_TABLE_HEADER_FILL`), white bold text (`_TABLE_HEADER_FG`) | +| Header rule | 1.5pt navy bottom line, drawn as the data row's TOP border (`_TABLE_HEADER_RULE`) — sits flush, no double-line stacking | +| Data row dividers | 0.5pt soft grey-blue (`_TABLE_DIVIDER`) top border between adjacent data rows | +| Alternating fills | Even rows `_TABLE_ROW_ALT` (light blue tint); odd rows pure white | +| Cell vertical alignment | `MSO_ANCHOR.MIDDLE` — short labels share baseline with longer descriptions | +| Row-label column | First column of body rows is **bold** so row labels read as headers | +| Cell padding | 0.1" horizontal, 0.05" vertical (tighter than PowerPoint default) | +| Body font | `_TABLE_PT` (14pt) brand-dark navy | + +Helpers: +- `_clear_cell_borders(cell)` — sets `/` on L/R/T/B +- `_set_cell_border(cell, edge, width, colour)` — replaces an edge with a `` rule (`a:prstDash val="solid"` + `a:round`) + +When the table style needs tweaking (a new colour, thicker header rule, +different row-stripe), update the palette constants at the top of +`pptx.py` and the rule lookup inside `_style_table_cell` — every table +in the project (`technique_table`, `literature_table`, `rq_results`, +the contributions table, the references list when rendered as table) +flows through this single helper, so the change applies uniformly. + ### Accent geometry (the "this is a designed deck" tell) Every content slide gets a thin top accent bar: @@ -86,6 +119,19 @@ provided every slide ends up with: 3. Page numbers in `_BRAND_GREY` (already set). 4. The semantic shape names listed in `slide-deck-rules.md`. +### Tables — additional anti-patterns + +- Default PowerPoint `add_table` style left intact (heavy black grid on + every cell). Always run through `_style_table_cell` so the grid is + stripped and replaced with the header-rule + row-divider pattern above. +- Cell vertical alignment left at default (top). Short labels float + above long-description rows in the same row, creating ragged baselines. +- Row stripe colours brighter than `_TABLE_ROW_ALT`. Stripe should be + the lightest possible tint that still reads as alternating. +- Numeric-column right-alignment skipped. (Currently optional — when a + column is clearly numeric values, prefer right-align so units / digits + line up. Out of scope for the v1 ship.) + ## Anti-patterns (instant "AI-generated" tells) - Plain `prs.slide_layouts[6]` (blank) with no programmatic accent. Every diff --git a/autopapertoppt/exporters/pptx.py b/autopapertoppt/exporters/pptx.py index 39157c0..6f18ebe 100644 --- a/autopapertoppt/exporters/pptx.py +++ b/autopapertoppt/exporters/pptx.py @@ -36,7 +36,7 @@ from pptx import Presentation from pptx.dml.color import RGBColor from pptx.enum.shapes import MSO_SHAPE -from pptx.enum.text import MSO_AUTO_SIZE, PP_ALIGN +from pptx.enum.text import MSO_ANCHOR, MSO_AUTO_SIZE, PP_ALIGN from pptx.oxml.ns import qn from pptx.util import Emu, Inches, Pt @@ -145,6 +145,8 @@ _TABLE_HEADER_FILL = RGBColor(0x1F, 0x3A, 0x66) _TABLE_HEADER_FG = RGBColor(0xFF, 0xFF, 0xFF) _TABLE_ROW_ALT = RGBColor(0xF4, 0xF6, 0xF9) +_TABLE_DIVIDER = RGBColor(0xD0, 0xD7, 0xE2) # row divider — soft grey-blue +_TABLE_HEADER_RULE = RGBColor(0x1F, 0x3A, 0x66) # heavy nav rule under header # --------------------------------------------------------------------------- # Abstract segmentation (fallback when summary is absent / lightweight only) @@ -1435,6 +1437,23 @@ def _add_kpi_lines( def _add_table( slide, *, rows, left, top, width, height, col_widths, ) -> None: + """Render a clean academic-style table. + + Styling rules (mirror published thesis-defence decks, not the default + PowerPoint table look): + + * No default black grid lines — every cell border is set to noFill + first, then specific rules are added back where they help readability. + * Header row: navy fill, white bold text, with a thick (1.5pt) navy + bottom rule below it for emphasis (the rule sits in the data row's + top edge, not the header's bottom, so it doesn't double up). + * Data rows: alternate very-light-blue / white background; thin + (0.5pt) grey-blue rule between adjacent data rows. + * Cell vertical alignment: middle, so short labels and longer + descriptions in the same row sit on a shared baseline. + * First column of body rows: bold, slightly emphasised — most tables + in this project use the leftmost cell as a row label. + """ if not rows: return row_count = len(rows) @@ -1447,31 +1466,94 @@ def _add_table( table.columns[col_index].width = w for r, row_values in enumerate(rows): for c, value in enumerate(row_values): - cell = table.cell(r, c) - cell.text = str(value) - text_frame = cell.text_frame - text_frame.word_wrap = True - text_frame.margin_left = Inches(0.08) - text_frame.margin_right = Inches(0.08) - text_frame.margin_top = Inches(0.04) - text_frame.margin_bottom = Inches(0.04) - for paragraph in text_frame.paragraphs: - for run in paragraph.runs: - run.font.size = Pt(_TABLE_PT) - if r == 0: - run.font.bold = True - run.font.color.rgb = _TABLE_HEADER_FG - else: - run.font.color.rgb = _BRAND_DARK + _style_table_cell(table.cell(r, c), str(value), r, c) + + +def _style_table_cell(cell, value: str, r: int, c: int) -> None: + """Apply academic-style formatting to one cell. + + Split out from ``_add_table`` so the cognitive-complexity budget + fits — borders + fills + font + alignment all live here. + """ + cell.text = value + cell.vertical_anchor = MSO_ANCHOR.MIDDLE + text_frame = cell.text_frame + text_frame.word_wrap = True + text_frame.margin_left = Inches(0.1) + text_frame.margin_right = Inches(0.1) + text_frame.margin_top = Inches(0.05) + text_frame.margin_bottom = Inches(0.05) + for paragraph in text_frame.paragraphs: + for run in paragraph.runs: + run.font.size = Pt(_TABLE_PT) if r == 0: - cell.fill.solid() - cell.fill.fore_color.rgb = _TABLE_HEADER_FILL - elif r % 2 == 0: - cell.fill.solid() - cell.fill.fore_color.rgb = _TABLE_ROW_ALT + run.font.bold = True + run.font.color.rgb = _TABLE_HEADER_FG else: - cell.fill.solid() - cell.fill.fore_color.rgb = RGBColor(0xFF, 0xFF, 0xFF) + run.font.color.rgb = _BRAND_DARK + if c == 0: + # Row-label column gets a slightly heavier weight. + run.font.bold = True + _set_cell_fill(cell, r) + _clear_cell_borders(cell) + if r == 1: + # Heavy rule below the header — drawn as the data row's TOP + # border so the visual width adds up cleanly. + _set_cell_border(cell, "T", Pt(1.5), _TABLE_HEADER_RULE) + elif r > 1: + # Thin separator between adjacent data rows. + _set_cell_border(cell, "T", Pt(0.5), _TABLE_DIVIDER) + + +def _set_cell_fill(cell, r: int) -> None: + cell.fill.solid() + if r == 0: + cell.fill.fore_color.rgb = _TABLE_HEADER_FILL + elif r % 2 == 0: + cell.fill.fore_color.rgb = _TABLE_ROW_ALT + else: + cell.fill.fore_color.rgb = RGBColor(0xFF, 0xFF, 0xFF) + + +def _clear_cell_borders(cell) -> None: + """Set every edge of ``cell`` to noFill so the table style's default + grid lines disappear. The XML structure for a cell's border is + ``//`` where X ∈ {L, R, T, B}. + """ + tc_pr = cell._tc.get_or_add_tcPr() + for edge in ("L", "R", "T", "B"): + tag = qn(f"a:ln{edge}") + existing = tc_pr.find(tag) + if existing is not None: + tc_pr.remove(existing) + ln = tc_pr.makeelement(tag, {"w": "0"}, nsmap=None) + ln.append(ln.makeelement(qn("a:noFill"), {}, nsmap=None)) + tc_pr.append(ln) + + +def _set_cell_border(cell, edge: str, width, colour: RGBColor) -> None: + """Add a solid-fill border on one edge of ``cell``. + + ``edge`` must be one of L / R / T / B; ``width`` is a ``pptx.util.Pt`` + (or any EMU-aware value). Replaces an existing border on that edge. + """ + tc_pr = cell._tc.get_or_add_tcPr() + tag = qn(f"a:ln{edge}") + existing = tc_pr.find(tag) + if existing is not None: + tc_pr.remove(existing) + ln = tc_pr.makeelement( + tag, + {"w": str(int(width)), "cap": "flat", "cmpd": "sng", "algn": "ctr"}, + nsmap=None, + ) + solid = ln.makeelement(qn("a:solidFill"), {}, nsmap=None) + rgb_hex = f"{colour[0]:02X}{colour[1]:02X}{colour[2]:02X}" + solid.append(solid.makeelement(qn("a:srgbClr"), {"val": rgb_hex}, nsmap=None)) + ln.append(solid) + ln.append(ln.makeelement(qn("a:prstDash"), {"val": "solid"}, nsmap=None)) + ln.append(ln.makeelement(qn("a:round"), {}, nsmap=None)) + tc_pr.append(ln) def _equal_col_widths(total: Emu, cols: int) -> tuple[Emu, ...]: From a64faebeaf5baff9236005dc5ffbd285acaa77f5 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 20 May 2026 17:48:14 +0800 Subject: [PATCH 13/18] =?UTF-8?q?Dark-mode=20pptx=20=E2=80=94=20opt-in=20v?= =?UTF-8?q?ia=20ExportOptions.dark=5Fmode=20/=20--dark-mode=20flag=20/=20G?= =?UTF-8?q?UI=20Deck-tab=20checkbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OLED projectors and low-light presentation rooms blow out the bright white slide background. Added a dark-mode rendering path that's non-invasive in the build pipeline: autopapertoppt/core/models.py ExportOptions gains `dark_mode: bool = False` (frozen dataclass — existing callers stay valid without source changes). autopapertoppt/exporters/pptx.py Two new module-level dicts: `_LIGHT_TO_DARK_TEXT` maps light-palette RGB triplets to dark equivalents for font.color.rgb swaps; `_LIGHT_TO_DARK_FILL` does the same for shape / cell fills + cell borders. `_DARK_SLIDE_BG = #12151B` is the dark slide bg. `_apply_dark_mode(prs)` runs after `_apply_typography` and `_decorate_with_accents`: 1. Solid-fills every slide's background with _DARK_SLIDE_BG. 2. Walks every shape; tables iterate cell-by-cell. 3. For each shape / cell: swap solid fill RGB if it's in the map. 4. For each run inside a text frame: swap font.color.rgb if mapped. 5. For each table cell: also walk `//` border XML so the header rule + row dividers retake the dark palette's lighter grey-blue. Recoloring is intentionally non-invasive — we don't refactor the 100+ direct `_BRAND_*` constant references in builders. The post-pass finds them by the RGB they wrote and swaps. Adding a new palette variant in future is one new mapping dict + one new pass. autopapertoppt/cli.py `--dark-mode` store_true flag wired into ExportOptions. autopapertoppt/gui/pages/deck.py + gui/i18n.py Deck tab gains a "Dark mode" QCheckBox under the existing "Include abstract" toggle. New `deck.dark_mode_label` i18n key in all 14 supported languages. scripts/regen_speculative_decoding_zh_tw.py Now ships BOTH variants per paper — `-zh-tw.pptx` (light) and `-zh-tw-dark.pptx` (dark) — so the user can pick the right one for the venue's lighting. .claude/agents/deck-design.md New "Dark-mode palette" subsection with the full mapping table, the rationale per swap (#12151B not #000000 so OLED burn-in is gentler; warm-red accent unchanged because it's legible on both backgrounds), the exposure surfaces (CLI / GUI / programmatic / regen), and a note to update `_DARK_SLIDE_BG` + `test_pptx_dark_mode_swaps_palette` together when tuning the palette. tests/test_exporters.py New test_pptx_dark_mode_swaps_palette covers: slide bg = #12151B, at least one run swapped to #E5E7EB (near-white). Rendered the 4 speculative-decoding decks in both variants: xia2024unlocking-zh-tw / -dark 22 slides, overflow PASS spector2023accelerating-zh-tw / -dark 21 slides xu2024edgellm-zh-tw / -dark 27 slides, overflow PASS svirschevski2024specexec-zh-tw / -dark 21 slides 511/511 pytest pass; ruff clean. --- .claude/agents/deck-design.md | 41 +++++ autopapertoppt/cli.py | 12 ++ autopapertoppt/core/models.py | 4 + autopapertoppt/exporters/pptx.py | 156 ++++++++++++++++++++ autopapertoppt/gui/i18n.py | 16 ++ autopapertoppt/gui/pages/deck.py | 6 + scripts/regen_speculative_decoding_zh_tw.py | 45 +++--- tests/test_exporters.py | 44 ++++++ 8 files changed, 307 insertions(+), 17 deletions(-) diff --git a/.claude/agents/deck-design.md b/.claude/agents/deck-design.md index e61768a..2d8e171 100644 --- a/.claude/agents/deck-design.md +++ b/.claude/agents/deck-design.md @@ -56,6 +56,47 @@ Do NOT introduce new brand colours casually — every additional colour fights for attention. Reuse the four above unless the user explicitly adds one. +#### Dark-mode palette (`ExportOptions.dark_mode=True`) + +When dark mode is on, the exporter builds the deck with the light +palette first then runs `_apply_dark_mode(prs)` as a post-build pass. +The pass re-colours individual runs / shape fills / cell borders by +looking up their current RGB in two mapping dicts. No builder needs +to know about dark mode at construction time. + +| Light → Dark | RGB swap | Why | +|---|---|---| +| Slide background | `#FFFFFF` → `#12151B` | Near-black so OLED screens save power + low-light rooms get less glare; not pure black to avoid the "burn-in" cliff | +| `_BRAND_DARK` text | `#1F3A66` → `#E5E7EB` | Body text near-white | +| `_BRAND_GREY` text | `#555555` → `#9CA3AF` | Metadata mid grey | +| `_BRAND_LIGHT` text | `#AAAAAA` → `#6B7280` | Subtle dividers / page numbers | +| `_BRAND_ACCENT` | `#C0392B` (unchanged) | Warm red is legible on both light and dark | +| `_BRAND_DARK` fill (accent bars / table header) | `#1F3A66` → `#3B5AA0` | Lighter navy reads against the dark slide background | +| `_TABLE_ROW_ALT` | `#F4F6F9` → `#1F232C` | Dark stripe | +| Pure white table cell | `#FFFFFF` → `#161A22` | Near-black non-stripe rows | +| `_TABLE_DIVIDER` | `#D0D7E2` → `#3D4452` | Muted grey-blue inter-row rule | + +Two mapping dicts live in `pptx.py`: `_LIGHT_TO_DARK_TEXT` (for +`/` inside text runs) and `_LIGHT_TO_DARK_FILL` +(for shape fills + table-cell fills + cell-border XML). The recoloring +is intentionally non-invasive — we don't refactor the 100+ direct +`_BRAND_*` references; instead we walk the rendered tree after the +fact. + +When tuning the dark palette, **adjust both mapping dicts** + the +`_DARK_SLIDE_BG` constant; the existing test +`test_pptx_dark_mode_swaps_palette` pins `#12151B` background + +`#E5E7EB` text-on-slide, so update the test when the dark-bg or +near-white colour changes. + +Exposure surfaces: +- CLI: `--dark-mode` flag +- GUI: Deck tab `deck.dark_mode_label` checkbox +- Programmatic: `ExportOptions(dark_mode=True)` +- Regen script: pass `dark_mode=True` per variant — see + `scripts/regen_speculative_decoding_zh_tw.py` which ships both light + (`-zh-tw.pptx`) and dark (`-zh-tw-dark.pptx`) variants. + ### Table styling (the second-biggest "AI-generated" tell after Calibri) PowerPoint's default table style draws a heavy black grid on every cell. diff --git a/autopapertoppt/cli.py b/autopapertoppt/cli.py index f5d5732..4f3215d 100644 --- a/autopapertoppt/cli.py +++ b/autopapertoppt/cli.py @@ -236,6 +236,17 @@ def build_parser() -> argparse.ArgumentParser: "Default: claude-opus-4-7 (or AUTOPAPERTOPPT_LLM_MODEL)." ), ) + parser.add_argument( + "--dark-mode", + action="store_true", + help=( + "Render the pptx with a dark slide background + light text. " + "The post-build palette swap re-colours brand_dark text to " + "near-white, table row stripes to dark variants, and slide " + "backgrounds to #12151B. Use for projector / OLED-display / " + "low-light presentation contexts." + ), + ) parser.add_argument( "--no-pdf", dest="download_pdf", @@ -358,6 +369,7 @@ async def _run(args: argparse.Namespace) -> int: include_abstract=not args.no_abstract, language=args.lang, max_slides_per_paper=args.max_slides, + dark_mode=args.dark_mode, ) needs_pptx = EXPORT_PPTX in formats # ``--pdf`` already supplies the PDF — the paywall gate is irrelevant diff --git a/autopapertoppt/core/models.py b/autopapertoppt/core/models.py index deb3f56..d4b5e08 100644 --- a/autopapertoppt/core/models.py +++ b/autopapertoppt/core/models.py @@ -391,6 +391,10 @@ class ExportOptions: #: render the full deck regardless of size; ``None`` is treated #: identically to the default. max_slides_per_paper: int | None = 25 + #: When True, the pptx exporter applies a dark-mode palette + #: post-build: dark slide background, light text, dark table-row + #: stripe. Default off so existing renders are unchanged. + dark_mode: bool = False def __post_init__(self) -> None: if not self.formats: diff --git a/autopapertoppt/exporters/pptx.py b/autopapertoppt/exporters/pptx.py index 6f18ebe..48fce47 100644 --- a/autopapertoppt/exporters/pptx.py +++ b/autopapertoppt/exporters/pptx.py @@ -139,6 +139,34 @@ # accent pass so a stock blank layout still reads as a designed deck). _ACCENT_TOP_HEIGHT = Inches(0.08) _ACCENT_LEFT_WIDTH = Inches(0.4) + +# Dark-mode palette (post-build recolour, opt-in via +# ``ExportOptions.dark_mode``). +_DARK_SLIDE_BG = RGBColor(0x12, 0x15, 0x1B) + +# Light-palette RGB → dark-palette RGB mapping for TEXT colours. Keys +# are 3-tuples (R, G, B) since python-pptx's RGBColor is tuple-comparable +# but we want to match by raw int components. +_LIGHT_TO_DARK_TEXT: dict[tuple[int, int, int], tuple[int, int, int]] = { + (0x1F, 0x3A, 0x66): (0xE5, 0xE7, 0xEB), # _BRAND_DARK → near-white text + (0x55, 0x55, 0x55): (0x9C, 0xA3, 0xAF), # _BRAND_GREY → mid grey + (0xAA, 0xAA, 0xAA): (0x6B, 0x72, 0x80), # _BRAND_LIGHT → muted grey + # _BRAND_ACCENT (#C0392B) stays — warm red is legible on dark. +} + +# Light-palette RGB → dark-palette RGB mapping for SHAPE / CELL FILLS +# and cell-border lines. Keeps the navy header on tables but lightens +# its tone slightly so it reads against the dark slide background. +_LIGHT_TO_DARK_FILL: dict[tuple[int, int, int], tuple[int, int, int]] = { + # _BRAND_DARK accent bars + accent_left + table header fill + (0x1F, 0x3A, 0x66): (0x3B, 0x5A, 0xA0), + # _TABLE_ROW_ALT → dark row stripe + (0xF4, 0xF6, 0xF9): (0x1F, 0x23, 0x2C), + # Pure white table rows → near-black + (0xFF, 0xFF, 0xFF): (0x16, 0x1A, 0x22), + # _TABLE_DIVIDER → muted grey-blue rule + (0xD0, 0xD7, 0xE2): (0x3D, 0x44, 0x52), +} _BRAND_RULE = RGBColor(0xCC, 0xCC, 0xCC) _RQ_BOX_FILL = RGBColor(0xF3, 0xF6, 0xFA) _RQ_BOX_BORDER = RGBColor(0x1F, 0x3A, 0x66) @@ -305,6 +333,8 @@ def _build( # ``deck-design`` subagent doc for rationale. _apply_typography(prs, ctx.language) _decorate_with_accents(prs) + if options.dark_mode: + _apply_dark_mode(prs) return prs @@ -1827,3 +1857,129 @@ def _send_shape_to_back(shape, slide) -> None: # everything after that is a shape in z-order. Insert at index 2 so the # band lands BEHIND every text shape but the metadata stays intact. sp_tree.insert(2, sp) + + +# --------------------------------------------------------------------------- +# Dark-mode pass (opt-in via ExportOptions.dark_mode) +# --------------------------------------------------------------------------- + + +def _apply_dark_mode(prs: Presentation) -> None: + """Swap the light palette for the dark palette on every slide. + + The exporter builds the deck with the light palette unconditionally, + then this post-pass re-colours individual shapes / runs / table cells + by looking up their current RGB in the light→dark mapping. The + approach is intentionally non-invasive — we don't refactor the 100+ + direct ``_BRAND_*`` references into a palette-aware lookup; instead + we walk the rendered tree after the fact. + + Steps per slide: + 1. Solid-fill the slide background with ``_DARK_SLIDE_BG``. + 2. Walk every shape: + - If it's a table, iterate the table's cells (fills + text + borders). + - Otherwise recolour the shape's own fill + text frame. + """ + for slide in prs.slides: + _set_slide_background(slide, _DARK_SLIDE_BG) + for shape in slide.shapes: + _recolor_shape(shape) + + +def _set_slide_background(slide, colour: RGBColor) -> None: + fill = slide.background.fill + fill.solid() + fill.fore_color.rgb = colour + + +def _recolor_shape(shape) -> None: + """Single shape: swap its fill, text-run colours, and (if table) its + per-cell fills + borders + cell-level runs.""" + if shape.has_table: + for cell in _iter_table_cells(shape.table): + _swap_fill(cell) + _swap_text_colors(cell) + _swap_cell_border_colors(cell) + return + _swap_fill(shape) + if shape.has_text_frame: + _swap_text_colors(shape) + + +def _iter_table_cells(table): + """python-pptx exposes ``iter_cells`` on Table but the API name has + changed between versions; this wrapper falls through to the manual + row/col iteration when needed.""" + iter_cells = getattr(table, "iter_cells", None) + if iter_cells is not None: + yield from iter_cells() + return + for row in table.rows: + yield from row.cells + + +def _swap_fill(shape_or_cell) -> None: + fill = getattr(shape_or_cell, "fill", None) + if fill is None: + return + try: + rgb = fill.fore_color.rgb + except (AttributeError, ValueError, TypeError): + return + if rgb is None: + return + key = (int(rgb[0]), int(rgb[1]), int(rgb[2])) + new = _LIGHT_TO_DARK_FILL.get(key) + if new is None: + return + fill.solid() + fill.fore_color.rgb = RGBColor(*new) + + +def _swap_text_colors(shape_or_cell) -> None: + text_frame = getattr(shape_or_cell, "text_frame", None) + if text_frame is None: + return + for paragraph in text_frame.paragraphs: + for run in paragraph.runs: + try: + rgb = run.font.color.rgb + except (AttributeError, ValueError, TypeError): + continue + if rgb is None: + continue + key = (int(rgb[0]), int(rgb[1]), int(rgb[2])) + new = _LIGHT_TO_DARK_TEXT.get(key) + if new is None: + continue + run.font.color.rgb = RGBColor(*new) + + +def _swap_cell_border_colors(cell) -> None: + """Walk the cell's ```` border elements and recolour any + ```` whose value matches the light-palette divider / + header-rule colours.""" + tc_pr = cell._tc.find(qn("a:tcPr")) + if tc_pr is None: + return + for edge in ("L", "R", "T", "B"): + ln = tc_pr.find(qn(f"a:ln{edge}")) + if ln is None: + continue + solid = ln.find(qn("a:solidFill")) + if solid is None: + continue + clr = solid.find(qn("a:srgbClr")) + if clr is None: + continue + val = clr.get("val", "") + if len(val) != 6: + continue + try: + key = (int(val[0:2], 16), int(val[2:4], 16), int(val[4:6], 16)) + except ValueError: + continue + new = _LIGHT_TO_DARK_FILL.get(key) + if new is None: + continue + clr.set("val", f"{new[0]:02X}{new[1]:02X}{new[2]:02X}") diff --git a/autopapertoppt/gui/i18n.py b/autopapertoppt/gui/i18n.py index dea5d1c..6c2bcd9 100644 --- a/autopapertoppt/gui/i18n.py +++ b/autopapertoppt/gui/i18n.py @@ -1504,6 +1504,22 @@ "hi": "Abstract slides शामिल करें", "id": "Sertakan slide abstrak", }, + "deck.dark_mode_label": { + "en": "Dark mode (dark background + light text)", + "zh-tw": "暗色模式(深色背景 + 淺色文字)", + "zh-cn": "暗色模式(深色背景 + 浅色文字)", + "ja": "ダークモード(暗背景 + 明テキスト)", + "es": "Modo oscuro (fondo oscuro + texto claro)", + "fr": "Mode sombre (fond sombre + texte clair)", + "de": "Dunkler Modus (dunkler Hintergrund + heller Text)", + "ko": "다크 모드 (어두운 배경 + 밝은 텍스트)", + "pt": "Modo escuro (fundo escuro + texto claro)", + "ru": "Тёмный режим (тёмный фон + светлый текст)", + "it": "Modalità scura (sfondo scuro + testo chiaro)", + "vi": "Chế độ tối (nền tối + chữ sáng)", + "hi": "Dark mode (गहरी पृष्ठभूमि + हल्का text)", + "id": "Mode gelap (latar gelap + teks terang)", + }, "deck.export_button": { "en": "Export", "zh-tw": "輸出", diff --git a/autopapertoppt/gui/pages/deck.py b/autopapertoppt/gui/pages/deck.py index fde71a9..30500a4 100644 --- a/autopapertoppt/gui/pages/deck.py +++ b/autopapertoppt/gui/pages/deck.py @@ -137,6 +137,11 @@ def _build_ui(self) -> None: ) self._include_abstract_check.setChecked(True) options_form.addRow(self._include_abstract_check) + self._dark_mode_check = QCheckBox( + t("deck.dark_mode_label", self._ui_language), self, + ) + self._dark_mode_check.setChecked(False) + options_form.addRow(self._dark_mode_check) outer.addWidget(options_box) # Action row @@ -257,6 +262,7 @@ def _on_export_clicked(self) -> None: include_abstract=self._include_abstract_check.isChecked(), language=language, max_slides_per_paper=self._max_slides_spin.value(), + dark_mode=self._dark_mode_check.isChecked(), ) collection = self._collection self._export_button.setEnabled(False) diff --git a/scripts/regen_speculative_decoding_zh_tw.py b/scripts/regen_speculative_decoding_zh_tw.py index 104ae60..7f45ec1 100644 --- a/scripts/regen_speculative_decoding_zh_tw.py +++ b/scripts/regen_speculative_decoding_zh_tw.py @@ -960,6 +960,14 @@ def _fig(paper_key: str, filename: str) -> str: def main() -> None: out_dir = ROOT / "exports" / _RUN_DIR_NAME out_dir.mkdir(parents=True, exist_ok=True) + # Two variants per paper: a light deck `-zh-tw.pptx` and a + # dark deck `-zh-tw-dark.pptx`. Same content, palette swapped + # via ExportOptions.dark_mode — useful when presenting on OLED + # screens / in low-light rooms where the light deck would glare. + variants: tuple[tuple[bool, str], ...] = ( + (False, ""), + (True, "-dark"), + ) for paper in ALL_PAPERS: collection = PaperCollection( query=Query( @@ -969,23 +977,26 @@ def main() -> None: ), papers=(paper,), ) - options = ExportOptions( - formats=("pptx",), - out_dir=str(out_dir), - # Language-variant filename is the explicit exception to the - # canonical-stem rule, so the user can keep zh-tw and English - # decks side-by-side without collision. - filename_stem=f"{paper.bibtex_key()}-zh-tw", - include_abstract=True, - language="zh-tw", - # Disable the 25-slides-per-paper cap so every curated - # figure makes it into the deck even when the rich-tier - # body content already consumes most of the budget. - max_slides_per_paper=0, - ) - written = export_collection(collection, options) - for fmt, path in written.items(): - print(f" - {paper.bibtex_key()} {fmt}: {path}") + for dark, suffix in variants: + options = ExportOptions( + formats=("pptx",), + out_dir=str(out_dir), + # Language-variant filename is the explicit exception to the + # canonical-stem rule, so the user can keep zh-tw and English + # decks side-by-side without collision. Same exception + # applies to the `-dark` variant suffix. + filename_stem=f"{paper.bibtex_key()}-zh-tw{suffix}", + include_abstract=True, + language="zh-tw", + # Disable the 25-slides-per-paper cap so every curated + # figure makes it into the deck even when the rich-tier + # body content already consumes most of the budget. + max_slides_per_paper=0, + dark_mode=dark, + ) + written = export_collection(collection, options) + for fmt, path in written.items(): + print(f" - {paper.bibtex_key()}{suffix} {fmt}: {path}") if __name__ == "__main__": diff --git a/tests/test_exporters.py b/tests/test_exporters.py index b854a90..5b54337 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -128,6 +128,50 @@ def test_pptx_exporter_single_paper_skips_agenda_and_divider(sample_papers, tmp_ assert "References" in titles +def test_pptx_dark_mode_swaps_palette(sample_papers, tmp_path): + """``dark_mode=True`` triggers the post-build recolour pass. + + Walks the rendered deck and confirms: + 1. Slide background fill is the dark colour (`#12151B`). + 2. At least one text run has a near-white colour (≠ the light + palette's brand_dark navy). + """ + from pptx import Presentation + from pptx.dml.color import RGBColor + + collection = _collection(sample_papers) + options = ExportOptions( + formats=("pptx",), + out_dir=str(tmp_path), + filename_stem="dark", + dark_mode=True, + ) + written = export_collection(collection, options) + prs = Presentation(str(written["pptx"])) + # Slide background should be dark. + first = list(prs.slides)[0] + bg_rgb = first.background.fill.fore_color.rgb + assert tuple(bg_rgb) == tuple(RGBColor(0x12, 0x15, 0x1B)) + # At least one run on a content slide should carry the swapped text + # colour (E5 E7 EB — near-white). + light_text = tuple(RGBColor(0xE5, 0xE7, 0xEB)) + found_light = False + for slide in prs.slides: + for shape in slide.shapes: + if not shape.has_text_frame: + continue + for para in shape.text_frame.paragraphs: + for run in para.runs: + try: + rgb = run.font.color.rgb + except (AttributeError, ValueError, TypeError): + continue + if rgb is not None and tuple(rgb) == light_text: + found_light = True + break + assert found_light, "no run was re-coloured to the dark-mode text colour" + + def test_pptx_exporter_no_abstract_skips_content_slides(sample_papers, tmp_path): from pptx import Presentation From 536aa8bdeaf59a19976dce29a7520588c6e00d54 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 20 May 2026 20:58:19 +0800 Subject: [PATCH 14/18] Flip default to dark mode; introduce --light-mode opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OLED projectors and low-light presentation venues are the common case in 2026 — a bright-white slide back glares under both, so dark mode is now the project default. Light stays available as a one-flag opt-out for printed handouts and well-lit conference rooms. autopapertoppt/core/models.py ExportOptions.dark_mode default flipped True → effectively True; the field now expresses "is this deck dark?" with the more common answer as the default. Callers wanting the classic white deck pass dark_mode=False explicitly. autopapertoppt/cli.py --dark-mode flag removed (was the opt-in to the previous non-default). --light-mode flag added as the new opt-OUT — store_true that sets dark_mode=False on ExportOptions. Old CLI scripts that passed --dark-mode will now error on the unknown flag; the migration is literally "drop the flag, it's now default". autopapertoppt/gui/pages/deck.py + gui/i18n.py Deck-tab checkbox renamed from "Dark mode" to "Light mode". Default unchecked → dark deck. i18n key deck.dark_mode_label → deck.light_mode_label with translations rewritten for the new semantics across 14 langs ("Light mode (white background, dark mode is default)" + equivalents). scripts/regen_speculative_decoding_zh_tw.py Variants flipped: the default-named output (`-zh-tw.pptx`) is now dark, and the suffixed variant (`-zh-tw-light.pptx`) is the opt-out. Existing `-dark` suffixed files on disk are stale; remove them before re-rendering. tests/test_exporters.py test_pptx_dark_mode_swaps_palette replaced by two: - test_pptx_default_is_dark_mode (no dark_mode arg → confirms dark slide bg + near-white text colour swap fired) - test_pptx_light_mode_keeps_navy_text (dark_mode=False → confirms no dark bg, navy #1F3A66 still present on at least one run) _find_run_color helper factored out for reuse. README.md + docs/cli.md CLI flag tables gain a `--light-mode` row + the dark-default explanation. Usage signature updated. .claude/agents/deck-design.md Dark-palette subsection retitled to mark dark as default; exposure surfaces flipped (--light-mode / GUI "Light mode" / dark_mode=False). Test pin line updated to mention both new tests. Re-rendered all 4 speculative-decoding decks with the new naming: *-zh-tw.pptx (dark, default) *-zh-tw-light.pptx (light opt-out) 512/512 pytest pass; ruff clean. --- .claude/agents/deck-design.md | 34 ++++---- README.md | 1 + autopapertoppt/cli.py | 14 ++-- autopapertoppt/core/models.py | 11 ++- autopapertoppt/gui/i18n.py | 30 +++---- autopapertoppt/gui/pages/deck.py | 11 +-- docs/cli.md | 3 +- scripts/regen_speculative_decoding_zh_tw.py | 13 +-- tests/test_exporters.py | 87 ++++++++++++++------- 9 files changed, 123 insertions(+), 81 deletions(-) diff --git a/.claude/agents/deck-design.md b/.claude/agents/deck-design.md index 2d8e171..6dc6498 100644 --- a/.claude/agents/deck-design.md +++ b/.claude/agents/deck-design.md @@ -56,10 +56,12 @@ Do NOT introduce new brand colours casually — every additional colour fights for attention. Reuse the four above unless the user explicitly adds one. -#### Dark-mode palette (`ExportOptions.dark_mode=True`) +#### Dark-mode palette (default; opt-out with `dark_mode=False` / `--light-mode` / GUI "Light mode") -When dark mode is on, the exporter builds the deck with the light -palette first then runs `_apply_dark_mode(prs)` as a post-build pass. +**Dark mode is the project default.** OLED projectors and low-light +presentation venues are the common case; bright-white slides glare +under both. The exporter builds with the light palette first, then +runs `_apply_dark_mode(prs)` as a post-build pass. The pass re-colours individual runs / shape fills / cell borders by looking up their current RGB in two mapping dicts. No builder needs to know about dark mode at construction time. @@ -84,18 +86,20 @@ is intentionally non-invasive — we don't refactor the 100+ direct fact. When tuning the dark palette, **adjust both mapping dicts** + the -`_DARK_SLIDE_BG` constant; the existing test -`test_pptx_dark_mode_swaps_palette` pins `#12151B` background + -`#E5E7EB` text-on-slide, so update the test when the dark-bg or -near-white colour changes. - -Exposure surfaces: -- CLI: `--dark-mode` flag -- GUI: Deck tab `deck.dark_mode_label` checkbox -- Programmatic: `ExportOptions(dark_mode=True)` -- Regen script: pass `dark_mode=True` per variant — see - `scripts/regen_speculative_decoding_zh_tw.py` which ships both light - (`-zh-tw.pptx`) and dark (`-zh-tw-dark.pptx`) variants. +`_DARK_SLIDE_BG` constant; the existing tests +`test_pptx_default_is_dark_mode` and `test_pptx_light_mode_keeps_navy_text` +pin `#12151B` dark background + `#E5E7EB` near-white text + `#1F3A66` +navy as the light-mode opt-out's text colour. Update both tests when +the dark-bg or near-white colour changes. + +Exposure surfaces (dark is default; the toggles flip to LIGHT): +- CLI: `--light-mode` opt-out flag (when absent → dark) +- GUI: Deck tab `deck.light_mode_label` checkbox (unchecked → dark) +- Programmatic: `ExportOptions(dark_mode=False)` to opt out +- Regen script: pass `dark_mode=False` per variant — see + `scripts/regen_speculative_decoding_zh_tw.py` which ships both the + default dark deck (`-zh-tw.pptx`) and a light opt-out + (`-zh-tw-light.pptx`). ### Table styling (the second-biggest "AI-generated" tell after Calibri) diff --git a/README.md b/README.md index f1576e9..5a1e79b 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,7 @@ py -m autopapertoppt --paper "https://arxiv.org/abs/1706.03762" ` | `--paywall-threshold` | Fraction of paywalled results that triggers the confirmation prompt. Default 0.30. | | `--yes` | Skip the paywall prompt and proceed. | | `--max-slides` | Per-paper slide cap (default 25; pass 0 for unlimited). | +| `--light-mode` | Render the pptx with a white background + navy text. Default is dark mode (dark background + near-white text) — pass this for projectors in well-lit rooms or when the deck will be printed. | | `--quiet` | Suppress per-paper printout. | ### Environment variables diff --git a/autopapertoppt/cli.py b/autopapertoppt/cli.py index 4f3215d..655de62 100644 --- a/autopapertoppt/cli.py +++ b/autopapertoppt/cli.py @@ -237,14 +237,14 @@ def build_parser() -> argparse.ArgumentParser: ), ) parser.add_argument( - "--dark-mode", + "--light-mode", action="store_true", help=( - "Render the pptx with a dark slide background + light text. " - "The post-build palette swap re-colours brand_dark text to " - "near-white, table row stripes to dark variants, and slide " - "backgrounds to #12151B. Use for projector / OLED-display / " - "low-light presentation contexts." + "Render the pptx with the classic white slide background + " + "navy text. Default is dark mode (dark slide background, " + "near-white text) — pass this flag for projectors in " + "well-lit rooms or when the deck will be printed / read on " + "paper." ), ) parser.add_argument( @@ -369,7 +369,7 @@ async def _run(args: argparse.Namespace) -> int: include_abstract=not args.no_abstract, language=args.lang, max_slides_per_paper=args.max_slides, - dark_mode=args.dark_mode, + dark_mode=not args.light_mode, ) needs_pptx = EXPORT_PPTX in formats # ``--pdf`` already supplies the PDF — the paywall gate is irrelevant diff --git a/autopapertoppt/core/models.py b/autopapertoppt/core/models.py index d4b5e08..a80526d 100644 --- a/autopapertoppt/core/models.py +++ b/autopapertoppt/core/models.py @@ -391,10 +391,13 @@ class ExportOptions: #: render the full deck regardless of size; ``None`` is treated #: identically to the default. max_slides_per_paper: int | None = 25 - #: When True, the pptx exporter applies a dark-mode palette - #: post-build: dark slide background, light text, dark table-row - #: stripe. Default off so existing renders are unchanged. - dark_mode: bool = False + #: When True (default), the pptx exporter applies a dark-mode + #: palette post-build: dark slide background, light text, dark + #: table-row stripe. Set False (or pass ``--light-mode`` on the + #: CLI / tick the "Light mode" box in the GUI Deck tab) to keep + #: the classic white-background light deck — useful on projectors + #: in well-lit rooms or when the audience reads on paper after. + dark_mode: bool = True def __post_init__(self) -> None: if not self.formats: diff --git a/autopapertoppt/gui/i18n.py b/autopapertoppt/gui/i18n.py index 6c2bcd9..c1aaa3e 100644 --- a/autopapertoppt/gui/i18n.py +++ b/autopapertoppt/gui/i18n.py @@ -1504,21 +1504,21 @@ "hi": "Abstract slides शामिल करें", "id": "Sertakan slide abstrak", }, - "deck.dark_mode_label": { - "en": "Dark mode (dark background + light text)", - "zh-tw": "暗色模式(深色背景 + 淺色文字)", - "zh-cn": "暗色模式(深色背景 + 浅色文字)", - "ja": "ダークモード(暗背景 + 明テキスト)", - "es": "Modo oscuro (fondo oscuro + texto claro)", - "fr": "Mode sombre (fond sombre + texte clair)", - "de": "Dunkler Modus (dunkler Hintergrund + heller Text)", - "ko": "다크 모드 (어두운 배경 + 밝은 텍스트)", - "pt": "Modo escuro (fundo escuro + texto claro)", - "ru": "Тёмный режим (тёмный фон + светлый текст)", - "it": "Modalità scura (sfondo scuro + testo chiaro)", - "vi": "Chế độ tối (nền tối + chữ sáng)", - "hi": "Dark mode (गहरी पृष्ठभूमि + हल्का text)", - "id": "Mode gelap (latar gelap + teks terang)", + "deck.light_mode_label": { + "en": "Light mode (white background, dark mode is default)", + "zh-tw": "亮色模式(白色背景,預設為暗色)", + "zh-cn": "亮色模式(白色背景,默认为暗色)", + "ja": "ライトモード(白背景,既定はダーク)", + "es": "Modo claro (fondo blanco; oscuro por defecto)", + "fr": "Mode clair (fond blanc ; sombre par défaut)", + "de": "Heller Modus (weißer Hintergrund; dunkel ist Standard)", + "ko": "라이트 모드 (흰 배경, 기본은 다크)", + "pt": "Modo claro (fundo branco; escuro por padrão)", + "ru": "Светлый режим (белый фон; по умолчанию тёмный)", + "it": "Modalità chiara (sfondo bianco; scuro per default)", + "vi": "Chế độ sáng (nền trắng; mặc định là tối)", + "hi": "Light mode (सफ़ेद पृष्ठभूमि; डिफ़ॉल्ट dark है)", + "id": "Mode terang (latar putih; default gelap)", }, "deck.export_button": { "en": "Export", diff --git a/autopapertoppt/gui/pages/deck.py b/autopapertoppt/gui/pages/deck.py index 30500a4..8f8293d 100644 --- a/autopapertoppt/gui/pages/deck.py +++ b/autopapertoppt/gui/pages/deck.py @@ -137,11 +137,12 @@ def _build_ui(self) -> None: ) self._include_abstract_check.setChecked(True) options_form.addRow(self._include_abstract_check) - self._dark_mode_check = QCheckBox( - t("deck.dark_mode_label", self._ui_language), self, + # Default is DARK; this checkbox is the opt-OUT toggle for light. + self._light_mode_check = QCheckBox( + t("deck.light_mode_label", self._ui_language), self, ) - self._dark_mode_check.setChecked(False) - options_form.addRow(self._dark_mode_check) + self._light_mode_check.setChecked(False) + options_form.addRow(self._light_mode_check) outer.addWidget(options_box) # Action row @@ -262,7 +263,7 @@ def _on_export_clicked(self) -> None: include_abstract=self._include_abstract_check.isChecked(), language=language, max_slides_per_paper=self._max_slides_spin.value(), - dark_mode=self._dark_mode_check.isChecked(), + dark_mode=not self._light_mode_check.isChecked(), ) collection = self._collection self._export_button.setEnabled(False) diff --git a/docs/cli.md b/docs/cli.md index b358fe4..d040979 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -25,7 +25,7 @@ autopapertoppt (--query KEYWORDS | --paper IDENTIFIER) [--llm-model MODEL] [--all-venues] [--paywall-threshold FLOAT] [--yes] - [--max-slides N] + [--max-slides N] [--light-mode] [--quiet] ``` @@ -51,6 +51,7 @@ autopapertoppt (--query KEYWORDS | --paper IDENTIFIER) | `--paywall-threshold` | `0.30` | Fraction of paywalled results above which the search-mode pipeline asks the user before generating per-paper PPTs. | | `--yes` | off | Auto-accept the paywall prompt. | | `--max-slides` | `25` | Per-paper slide cap. Pass `0` for unlimited. | +| `--light-mode` | off | Render the pptx in light mode (white slide background + navy text). **Dark mode is the default** — the exporter swaps the brand palette via a post-build pass (slide bg `#12151B`, body text `#E5E7EB`, lighter table-row stripe) so OLED projectors and low-light venues don't glare. Pass this flag for projectors in well-lit rooms or when the deck will be printed. | | `--quiet` | off | Suppress the per-paper one-line printout to stdout. | ## Examples diff --git a/scripts/regen_speculative_decoding_zh_tw.py b/scripts/regen_speculative_decoding_zh_tw.py index 7f45ec1..f06fcbc 100644 --- a/scripts/regen_speculative_decoding_zh_tw.py +++ b/scripts/regen_speculative_decoding_zh_tw.py @@ -960,13 +960,14 @@ def _fig(paper_key: str, filename: str) -> str: def main() -> None: out_dir = ROOT / "exports" / _RUN_DIR_NAME out_dir.mkdir(parents=True, exist_ok=True) - # Two variants per paper: a light deck `-zh-tw.pptx` and a - # dark deck `-zh-tw-dark.pptx`. Same content, palette swapped - # via ExportOptions.dark_mode — useful when presenting on OLED - # screens / in low-light rooms where the light deck would glare. + # Two variants per paper: a DARK deck `-zh-tw.pptx` (default + # output; dark mode is now the project default since OLED projectors + # and low-light venues are the common presentation context) and a + # LIGHT deck `-zh-tw-light.pptx` (opt-out for print / well-lit + # rooms). Same content, palette swapped via ExportOptions.dark_mode. variants: tuple[tuple[bool, str], ...] = ( - (False, ""), - (True, "-dark"), + (True, ""), + (False, "-light"), ) for paper in ALL_PAPERS: collection = PaperCollection( diff --git a/tests/test_exporters.py b/tests/test_exporters.py index 5b54337..88cea79 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -128,13 +128,29 @@ def test_pptx_exporter_single_paper_skips_agenda_and_divider(sample_papers, tmp_ assert "References" in titles -def test_pptx_dark_mode_swaps_palette(sample_papers, tmp_path): - """``dark_mode=True`` triggers the post-build recolour pass. +def _find_run_color(prs, target_rgb: tuple[int, int, int]) -> bool: + for slide in prs.slides: + for shape in slide.shapes: + if not shape.has_text_frame: + continue + for para in shape.text_frame.paragraphs: + for run in para.runs: + try: + rgb = run.font.color.rgb + except (AttributeError, ValueError, TypeError): + continue + if rgb is not None and tuple(rgb) == target_rgb: + return True + return False - Walks the rendered deck and confirms: + +def test_pptx_default_is_dark_mode(sample_papers, tmp_path): + """``dark_mode`` defaults to True, so an ExportOptions that doesn't + explicitly pass the field still produces a dark deck. + + Confirms: 1. Slide background fill is the dark colour (`#12151B`). - 2. At least one text run has a near-white colour (≠ the light - palette's brand_dark navy). + 2. At least one run carries the swapped near-white text colour. """ from pptx import Presentation from pptx.dml.color import RGBColor @@ -143,33 +159,48 @@ def test_pptx_dark_mode_swaps_palette(sample_papers, tmp_path): options = ExportOptions( formats=("pptx",), out_dir=str(tmp_path), - filename_stem="dark", - dark_mode=True, + filename_stem="default-dark", ) written = export_collection(collection, options) prs = Presentation(str(written["pptx"])) - # Slide background should be dark. - first = list(prs.slides)[0] - bg_rgb = first.background.fill.fore_color.rgb + bg_rgb = list(prs.slides)[0].background.fill.fore_color.rgb assert tuple(bg_rgb) == tuple(RGBColor(0x12, 0x15, 0x1B)) - # At least one run on a content slide should carry the swapped text - # colour (E5 E7 EB — near-white). - light_text = tuple(RGBColor(0xE5, 0xE7, 0xEB)) - found_light = False - for slide in prs.slides: - for shape in slide.shapes: - if not shape.has_text_frame: - continue - for para in shape.text_frame.paragraphs: - for run in para.runs: - try: - rgb = run.font.color.rgb - except (AttributeError, ValueError, TypeError): - continue - if rgb is not None and tuple(rgb) == light_text: - found_light = True - break - assert found_light, "no run was re-coloured to the dark-mode text colour" + assert _find_run_color(prs, (0xE5, 0xE7, 0xEB)), ( + "no run was re-coloured to the dark-mode near-white text" + ) + + +def test_pptx_light_mode_keeps_navy_text(sample_papers, tmp_path): + """``dark_mode=False`` opt-out skips the post-build recolour pass. + + Confirms: + 1. No slide-level background fill is set (or — if set — it isn't + the dark colour). + 2. At least one run carries the original navy ``_BRAND_DARK`` + (#1F3A66) colour. + """ + from pptx import Presentation + from pptx.dml.color import RGBColor + + collection = _collection(sample_papers) + options = ExportOptions( + formats=("pptx",), + out_dir=str(tmp_path), + filename_stem="explicit-light", + dark_mode=False, + ) + written = export_collection(collection, options) + prs = Presentation(str(written["pptx"])) + # No dark slide background. + try: + bg_rgb = list(prs.slides)[0].background.fill.fore_color.rgb + except (AttributeError, ValueError, TypeError): + bg_rgb = None + if bg_rgb is not None: + assert tuple(bg_rgb) != tuple(RGBColor(0x12, 0x15, 0x1B)) + assert _find_run_color(prs, (0x1F, 0x3A, 0x66)), ( + "no run kept the light-palette navy text colour" + ) def test_pptx_exporter_no_abstract_skips_content_slides(sample_papers, tmp_path): From 24358b128b28d7261f4bf12d5ad6211477b7684a Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 20 May 2026 22:31:41 +0800 Subject: [PATCH 15/18] Fix dark-mode invisible bullet text + hardcode the contract in two layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported "有些文字在暗色模式還是黑色的根本看不見" — some text was rendering invisible against the dark slide background. Audited the xu2024edgellm-zh-tw deck and found 74 runs with font.color.rgb = None, all inside `body` shapes built by _add_bullet_box. That helper set font.size but never font.color, so the runs inherited the slide master's theme colour (near-black) and the dark-mode post-pass had no source RGB to look up in the swap map. Two layers of defence — both committed together so a future builder that forgets either rule still ships a readable dark deck. LAYER 1 — every text-adding helper sets explicit colour autopapertoppt/exporters/pptx.py: _add_bullet_box now assigns run.font.color.rgb = _BRAND_DARK for every bullet run, mirroring _add_textbox's pattern. Re-rendered the 4 zh-tw dark decks; audit script confirms 0 invisible runs (was 74 on Xu alone). LAYER 2 — dark-mode post-pass safety net autopapertoppt/exporters/pptx.py: _swap_text_colors promotes rgb is None and rgb == (0,0,0) to #E5E7EB near-white. Catches any future builder that forgets Layer 1. REGRESSION GUARD tests/test_exporters.py::test_pptx_dark_mode_has_no_invisible_runs walks every run on every slide of a default-dark-mode deck and fails if any non-empty run has rgb=None or rgb=(0,0,0). Pins both layers. AUDIT SCRIPT scripts/_audit_dark_text.py — manual single-deck inspector that lists every offending run with file/shape/paragraph/run/text context. Used during this fix; kept around for future debugging. DOCS .claude/agents/deck-design.md gains a "Dark-mode contract (HARD)" subsection with the three concrete rules: 1. Always assign run.font.color.rgb after creating a run 2. Never use RGBColor(0,0,0) — _BRAND_DARK is the safe choice 3. Never pass colour=None to _add_textbox CLAUDE.md gains a new top-level "Dark-Mode Contract" section between the IEEE/WebRunner HARD rule and the subagent table. Short summary + pointer to the deck-design subagent for the full rule + test name + audit script. 513/513 pytest pass; ruff clean. --- .claude/agents/deck-design.md | 39 ++++++++++++++ CLAUDE.md | 22 ++++++++ autopapertoppt/exporters/pptx.py | 28 ++++++++-- scripts/_audit_dark_text.py | 91 ++++++++++++++++++++++++++++++++ tests/test_exporters.py | 52 ++++++++++++++++++ 5 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 scripts/_audit_dark_text.py diff --git a/.claude/agents/deck-design.md b/.claude/agents/deck-design.md index 6dc6498..02748f6 100644 --- a/.claude/agents/deck-design.md +++ b/.claude/agents/deck-design.md @@ -92,6 +92,45 @@ pin `#12151B` dark background + `#E5E7EB` near-white text + `#1F3A66` navy as the light-mode opt-out's text colour. Update both tests when the dark-bg or near-white colour changes. +#### Dark-mode contract (HARD) + +**Every text-adding helper MUST set ``run.font.color.rgb`` explicitly +to a colour the dark-mode mapping knows how to swap.** A run with +``font.color.rgb = None`` inherits the slide-master's theme colour +which renders as near-black on screen — the dark-mode post-pass +cannot map it back because there's no source RGB to look up. + +Concrete rules for every helper that touches ``text_frame.paragraphs[*].runs[*]``: + +1. **Always assign ``run.font.color.rgb = _BRAND_*``** (one of the four + palette constants) after creating / overwriting the run. Never leave + the colour at its constructor default. +2. **Never assign ``RGBColor(0, 0, 0)`` (pure black).** Use + ``_BRAND_DARK`` (= ``#1F3A66``) instead — same readability on light + bg, AND the dark-mode post-pass maps it. +3. **When you must accept a None colour at the helper boundary** (e.g. + ``_add_textbox`` already supports ``colour: RGBColor | None``), pick + a sensible default INSIDE the helper rather than passing through. + Currently ``_add_textbox`` skips colour-set when ``colour is None`` + — callers must therefore pass ``colour=_BRAND_DARK`` (or another + palette colour) explicitly. **Do not call ``_add_textbox(..., colour=None)``.** + +The exporter has TWO layers of defence so a missed explicit colour +doesn't ship invisible text: + +* Layer 1 — every text-adding helper sets the colour. ``_add_bullet_box`` + used to omit this; fixed (commit ``536aa8b``'s follow-up). +* Layer 2 — ``_swap_text_colors`` in the dark-mode post-pass treats + ``rgb is None`` and ``rgb == (0,0,0)`` as "promote to ``#E5E7EB`` + near-white". Safety net for future builders that forget Layer 1. + +Both layers ship together; tests pin both. The regression test +``test_pptx_dark_mode_has_no_invisible_runs`` (in ``tests/test_exporters.py``) +walks every run on every slide of a default-dark-mode deck and fails +if any non-empty run has ``rgb is None`` or ``rgb == (0,0,0)``. A +companion debug script lives at ``scripts/_audit_dark_text.py`` for +manual inspection of a single rendered deck. + Exposure surfaces (dark is default; the toggles flip to LIGHT): - CLI: `--light-mode` opt-out flag (when absent → dark) - GUI: Deck tab `deck.light_mode_label` checkbox (unchecked → dark) diff --git a/CLAUDE.md b/CLAUDE.md index 6fb0385..828597e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,6 +107,28 @@ window open during an IEEE / Scholar / paywalled-PDF step, the path is broken — surface it, don't trust the results. Full rule + audit checklist: `compliance-auditor` subagent. +## Dark-Mode Contract: Every Text Run Sets an Explicit Colour (HARD RULE) + +Dark mode is the project's default pptx render path. The post-build +recolour pass swaps light-palette RGB values to their dark-palette +equivalents — but it can only swap colours it can read. **A text run +with `run.font.color.rgb = None` inherits the slide-master's theme +colour, renders as near-black on the dark slide background, and is +invisible.** Every text-adding helper in `autopapertoppt/exporters/pptx.py` +MUST therefore assign `run.font.color.rgb = _BRAND_*` (one of the four +palette constants) after creating or overwriting a run. Never leave the +colour at its default; never pass `colour=None` to `_add_textbox`; +never write `RGBColor(0, 0, 0)` — use `_BRAND_DARK` instead. + +The `_swap_text_colors` pass in the dark-mode post-build now also +promotes any leftover `rgb is None` or `(0, 0, 0)` runs to `#E5E7EB` +near-white as a second layer of defence. The regression test +`tests/test_exporters.py::test_pptx_dark_mode_has_no_invisible_runs` +walks every run on every slide and fails if any non-empty run lacks an +explicit non-black colour. Full rule + the audit script + the +two-layer defence rationale live in `.claude/agents/deck-design.md` +"Dark-mode contract". + ## Where the detailed rules live | Topic | Subagent (in `.claude/agents/`) | diff --git a/autopapertoppt/exporters/pptx.py b/autopapertoppt/exporters/pptx.py index 48fce47..eed9724 100644 --- a/autopapertoppt/exporters/pptx.py +++ b/autopapertoppt/exporters/pptx.py @@ -1321,6 +1321,14 @@ def _add_bullet_box( paragraph.alignment = PP_ALIGN.LEFT for run in paragraph.runs: run.font.size = Pt(font_pt) + # ALWAYS set the run colour explicitly. A run with + # ``font.color.rgb = None`` inherits the theme's body-text + # colour (which renders as black) and the dark-mode + # post-pass cannot swap it because there's no source RGB + # to look up in the mapping. See deck-design.md + # "Dark-mode contract" — every text-adding helper sets a + # palette colour, no exceptions. + run.font.color.rgb = _BRAND_DARK def _add_footer(slide, text: str) -> None: @@ -1937,22 +1945,32 @@ def _swap_fill(shape_or_cell) -> None: def _swap_text_colors(shape_or_cell) -> None: + """Swap every run's text colour for the dark-mode equivalent. + + Safety net for runs that the builders forgot to colour explicitly: + when ``font.color.rgb`` is ``None`` (theme inheritance, renders as + near-black on screen) or pure black ``(0,0,0)``, promote to the + dark-mode body colour ``#E5E7EB``. Without this fallback such runs + would render as black-on-dark — invisible. See + ``.claude/agents/deck-design.md`` "Dark-mode contract". + """ text_frame = getattr(shape_or_cell, "text_frame", None) if text_frame is None: return + near_white = RGBColor(0xE5, 0xE7, 0xEB) for paragraph in text_frame.paragraphs: for run in paragraph.runs: try: rgb = run.font.color.rgb except (AttributeError, ValueError, TypeError): - continue - if rgb is None: + rgb = None + if rgb is None or (int(rgb[0]), int(rgb[1]), int(rgb[2])) == (0, 0, 0): + run.font.color.rgb = near_white continue key = (int(rgb[0]), int(rgb[1]), int(rgb[2])) new = _LIGHT_TO_DARK_TEXT.get(key) - if new is None: - continue - run.font.color.rgb = RGBColor(*new) + if new is not None: + run.font.color.rgb = RGBColor(*new) def _swap_cell_border_colors(cell) -> None: diff --git a/scripts/_audit_dark_text.py b/scripts/_audit_dark_text.py new file mode 100644 index 0000000..57fa9ad --- /dev/null +++ b/scripts/_audit_dark_text.py @@ -0,0 +1,91 @@ +"""Find every text run in a dark-mode deck whose colour is missing or +too dark to read against the #12151B slide background. + +A run is flagged when: +- ``run.font.color.rgb is None`` (inherits theme default → renders as black) +- ``rgb == (0,0,0)`` (explicit black) +- ``rgb`` luminance below 60 (Rec.709 weights) AND not in the dark + palette's accepted text set + +Usage: + .venv\\Scripts\\python.exe -m scripts._audit_dark_text +""" +from __future__ import annotations + +import sys +from pathlib import Path + +from pptx import Presentation + +# Colours we KNOW are intentional on a dark deck — accent red, the +# dark-mode text near-white, mid-greys, light-text on header fills. +_ACCEPTED_DARK_RUN_COLORS = { + (0xE5, 0xE7, 0xEB), # dark-mode body text + (0x9C, 0xA3, 0xAF), # dark-mode metadata grey + (0x6B, 0x72, 0x80), # dark-mode muted grey + (0xFF, 0xFF, 0xFF), # table-header white + (0xC0, 0x39, 0x2B), # brand accent red (legible on both bgs) +} + + +def _luminance(rgb: tuple[int, int, int]) -> float: + return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] + + +def _iter_runs(prs): + for slide_idx, slide in enumerate(prs.slides, 1): + for shape in slide.shapes: + yield from _shape_runs(slide_idx, shape, "") + if shape.has_table: + for r_idx, row in enumerate(shape.table.rows): + for c_idx, cell in enumerate(row.cells): + yield from _shape_runs( + slide_idx, cell, f"table[{r_idx},{c_idx}]" + ) + + +def _shape_runs(slide_idx: int, shape_or_cell, where_hint: str): + text_frame = getattr(shape_or_cell, "text_frame", None) + if text_frame is None: + return + name = getattr(shape_or_cell, "name", "") or where_hint + for p_idx, paragraph in enumerate(text_frame.paragraphs): + for r_idx, run in enumerate(paragraph.runs): + try: + rgb = run.font.color.rgb + except (AttributeError, ValueError, TypeError): + rgb = None + text = (run.text or "")[:40] + yield (slide_idx, name, p_idx, r_idx, rgb, text) + + +def main(pptx_path: Path) -> int: + prs = Presentation(pptx_path) + bad: list[str] = [] + for slide_idx, name, p_idx, r_idx, rgb, text in _iter_runs(prs): + if not text.strip(): + continue + rgb_tuple = tuple(rgb) if rgb is not None else None + if rgb_tuple is None: + bad.append( + f"slide {slide_idx} {name!r} p{p_idx}r{r_idx} rgb=None text={text!r}" + ) + continue + if rgb_tuple in _ACCEPTED_DARK_RUN_COLORS: + continue + if _luminance(rgb_tuple) < 80: + bad.append( + f"slide {slide_idx} {name!r} p{p_idx}r{r_idx} " + f"rgb={rgb_tuple} lum={_luminance(rgb_tuple):.0f} text={text!r}" + ) + print(f"audit: {pptx_path.name}") + print(f"runs flagged: {len(bad)}") + for line in bad[:40]: + print(" " + line) + if len(bad) > 40: + print(f" ... ({len(bad) - 40} more)") + return 0 if not bad else 1 + + +if __name__ == "__main__": + sys.exit(main(Path(sys.argv[1]))) diff --git a/tests/test_exporters.py b/tests/test_exporters.py index 88cea79..3af74aa 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -170,6 +170,58 @@ def test_pptx_default_is_dark_mode(sample_papers, tmp_path): ) +def test_pptx_dark_mode_has_no_invisible_runs(sample_papers, tmp_path): + """Dark-mode regression guard — no text run may end up with + ``font.color.rgb is None`` or pure black on the dark slide bg. + + A run with no explicit colour inherits the theme's body-text colour + (renders as near-black) and the dark-mode post-pass cannot map it + because there's no source RGB to look up. The recolour pass now + promotes None / black to ``#E5E7EB`` as a safety net; this test + pins that fallback so a future builder that forgets to set an + explicit run colour still produces a readable dark deck. + """ + from pptx import Presentation + + collection = _collection(sample_papers) + options = ExportOptions( + formats=("pptx",), + out_dir=str(tmp_path), + filename_stem="dark-readability", + ) + written = export_collection(collection, options) + prs = Presentation(str(written["pptx"])) + invisible: list[str] = [] + for s_idx, slide in enumerate(prs.slides, 1): + for shape in slide.shapes: + if not shape.has_text_frame: + continue + for p_idx, paragraph in enumerate(shape.text_frame.paragraphs): + for r_idx, run in enumerate(paragraph.runs): + text = (run.text or "").strip() + if not text: + continue + try: + rgb = run.font.color.rgb + except (AttributeError, ValueError, TypeError): + rgb = None + if rgb is None: + invisible.append( + f"slide {s_idx} shape {shape.name!r} " + f"p{p_idx}r{r_idx}: rgb=None text={text[:30]!r}" + ) + elif tuple(rgb) == (0, 0, 0): + invisible.append( + f"slide {s_idx} shape {shape.name!r} " + f"p{p_idx}r{r_idx}: rgb=black text={text[:30]!r}" + ) + assert not invisible, ( + "dark-mode deck contains runs with no explicit (or black) " + "colour — these render invisible on the dark slide bg:\n " + + "\n ".join(invisible[:10]) + ) + + def test_pptx_light_mode_keeps_navy_text(sample_papers, tmp_path): """``dark_mode=False`` opt-out skips the post-build recolour pass. From 4b04bf345bb3e87f471827da858e76765768aa09 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 20 May 2026 23:11:12 +0800 Subject: [PATCH 16/18] =?UTF-8?q?Fix=20dark-mode=20light-on-light=20invisi?= =?UTF-8?q?bility=20=E2=80=94=20=5FRQ=5FBOX=5FFILL=20+=20contract=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported "有在白框裡的字是白色的,看不到" — text inside a near-white-fill callout box was invisible in dark mode. Audited and found `_RQ_BOX_FILL = #F3F6FA` (the research-question highlight box's background) was missing from `_LIGHT_TO_DARK_FILL`. The dark-mode post-pass correctly swapped the text colour inside the box from `_BRAND_DARK` to `#E5E7EB` near-white, but the box itself stayed near-white because the fill RGB wasn't in the mapping → near-white text on near-white box = invisible. FIX autopapertoppt/exporters/pptx.py Add `(0xF3, 0xF6, 0xFA): (0x1E, 0x26, 0x38)` to _LIGHT_TO_DARK_FILL. Dark navy tint contrasts with the existing `_E5E7EB` near-white text the post-pass writes. AUDIT SCRIPT scripts/_audit_dark_text.py gains failure-mode B detection: walks every shape, when fill luminance > 0.7 × 255 (= 178) checks every contained run; if a run's text colour is also > 178 luminance, flag it as LIGHT-ON-LIGHT with file/slide/shape/text context. REGRESSION TEST tests/test_exporters.py::test_pptx_dark_mode_no_light_text_on_light_fill walks every shape on a default-dark-mode deck; fails when both fill and text luminance exceed the 0.7 × 255 threshold. Pins the fix and catches any FUTURE light-fill-shape addition that lacks a matching dark mapping. Two helper functions factored out so cognitive complexity stays ≤ 10. DOCS — same two-place pattern as the previous dark-mode rule .claude/agents/deck-design.md gains a "Light-on-light contrast contract (the OTHER invisibility bug)" subsection under the existing "Dark-mode contract (HARD)". States three rules for future light-fill shapes: 1. Every new light-fill RGB must have a matching entry in _LIGHT_TO_DARK_FILL in the same commit. 2. The regression test catches missing entries. 3. The audit script reports failure-mode B during manual debugging. CLAUDE.md gains a "Mirror rule — light-on-light contrast" paragraph right after the existing dark-mode contract section, with the test name and the brief rationale. Re-rendered the 4 zh-tw dark decks; audit confirms 0 light-on-light runs (was 1+ per deck on RQ-box callouts). 514/514 pytest pass; ruff clean (cognitive complexity refactored on _check_contrast and test_pptx_dark_mode_no_light_text_on_light_fill via helper extraction). --- .claude/agents/deck-design.md | 32 +++++++++++++ CLAUDE.md | 9 ++++ autopapertoppt/exporters/pptx.py | 5 ++ scripts/_audit_dark_text.py | 77 ++++++++++++++++++++++++++++--- tests/test_exporters.py | 78 ++++++++++++++++++++++++++++++++ 5 files changed, 194 insertions(+), 7 deletions(-) diff --git a/.claude/agents/deck-design.md b/.claude/agents/deck-design.md index 02748f6..0fe2bee 100644 --- a/.claude/agents/deck-design.md +++ b/.claude/agents/deck-design.md @@ -131,6 +131,38 @@ if any non-empty run has ``rgb is None`` or ``rgb == (0,0,0)``. A companion debug script lives at ``scripts/_audit_dark_text.py`` for manual inspection of a single rendered deck. +#### Light-on-light contrast contract (the OTHER invisibility bug) + +A near-black bug is "text rgb=None → black on dark = invisible". The +mirror failure mode is **"near-white text inside a near-white-fill +shape"** — happens when a callout / KPI box keeps its light fill in +dark mode while the text inside gets re-coloured to ``#E5E7EB``. +The text disappears INTO the box, even though both colours are +"correct" individually. + +The first instance was ``_RQ_BOX_FILL`` (= ``#F3F6FA``): the +``_add_rq_callout`` builder filled the box with that light off-white +and the text with ``_BRAND_DARK``; the dark-mode pass swapped the text +but ``_RQ_BOX_FILL`` wasn't in ``_LIGHT_TO_DARK_FILL`` so the fill +stayed light. White-on-white. Fixed by adding the mapping +``(0xF3, 0xF6, 0xFA) → (0x1E, 0x26, 0x38)`` (dark navy tint). + +**Rule for any future light-fill callout / box / KPI surface:** + +1. **Every light-fill RGB you introduce must have an entry in + ``_LIGHT_TO_DARK_FILL``.** If you add a ``_FOO_BOX_FILL = RGBColor(...)`` + constant near the top of ``pptx.py``, also add its dark equivalent + in the mapping dict in the same commit. Pick a dark tint in the + ``#15..#25`` luminance range so ``#E5E7EB`` near-white text reads. +2. **The regression test** ``test_pptx_dark_mode_no_light_text_on_light_fill`` + walks every shape, computes luminance of fill and of each run's + text colour, and fails when both > 0.7 × 255 (= 178). Adding a new + light-fill shape without a corresponding dark mapping will fail + this test. +3. **The audit script** ``scripts/_audit_dark_text.py`` now also + reports failure-mode B — run it on a rendered deck during manual + inspection. + Exposure surfaces (dark is default; the toggles flip to LIGHT): - CLI: `--light-mode` opt-out flag (when absent → dark) - GUI: Deck tab `deck.light_mode_label` checkbox (unchecked → dark) diff --git a/CLAUDE.md b/CLAUDE.md index 828597e..a6dcd7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,6 +129,15 @@ explicit non-black colour. Full rule + the audit script + the two-layer defence rationale live in `.claude/agents/deck-design.md` "Dark-mode contract". +**Mirror rule — light-on-light contrast.** Any new light-fill RGB +introduced in `pptx.py` (e.g. a callout / KPI / RQ-box background) +MUST also have an entry in `_LIGHT_TO_DARK_FILL`; otherwise the fill +stays near-white in dark mode while its text gets re-coloured to +near-white → invisible. Regression test +`test_pptx_dark_mode_no_light_text_on_light_fill` walks every shape +and fails when both fill and text luminance are > 0.7 of 255 in a +default-dark-mode render. + ## Where the detailed rules live | Topic | Subagent (in `.claude/agents/`) | diff --git a/autopapertoppt/exporters/pptx.py b/autopapertoppt/exporters/pptx.py index eed9724..692a710 100644 --- a/autopapertoppt/exporters/pptx.py +++ b/autopapertoppt/exporters/pptx.py @@ -166,6 +166,11 @@ (0xFF, 0xFF, 0xFF): (0x16, 0x1A, 0x22), # _TABLE_DIVIDER → muted grey-blue rule (0xD0, 0xD7, 0xE2): (0x3D, 0x44, 0x52), + # _RQ_BOX_FILL (research-question callout box) → dark navy tint. + # Without this swap the box stays near-white while the text inside + # is re-coloured to near-white = white-on-white = invisible. This + # specific bug is what the dark-mode contrast contract guards. + (0xF3, 0xF6, 0xFA): (0x1E, 0x26, 0x38), } _BRAND_RULE = RGBColor(0xCC, 0xCC, 0xCC) _RQ_BOX_FILL = RGBColor(0xF3, 0xF6, 0xFA) diff --git a/scripts/_audit_dark_text.py b/scripts/_audit_dark_text.py index 57fa9ad..32fb986 100644 --- a/scripts/_audit_dark_text.py +++ b/scripts/_audit_dark_text.py @@ -1,11 +1,19 @@ -"""Find every text run in a dark-mode deck whose colour is missing or -too dark to read against the #12151B slide background. +"""Find dark-mode readability bugs in a rendered .pptx. -A run is flagged when: -- ``run.font.color.rgb is None`` (inherits theme default → renders as black) -- ``rgb == (0,0,0)`` (explicit black) -- ``rgb`` luminance below 60 (Rec.709 weights) AND not in the dark - palette's accepted text set +Two failure modes flagged: + +A) "Black on dark slide bg" — text run whose colour is missing or too + dark to read against the #12151B slide background: + - ``run.font.color.rgb is None`` (inherits theme default → black) + - ``rgb == (0,0,0)`` (explicit black) + - ``rgb`` luminance below 60 AND not in the accepted text set + +B) "White text inside white-fill box" — text whose run colour is + light (luminance > 0.7 of 255) but the SHAPE FILL behind it is + also light (luminance > 0.7) → text disappears into the box. + Catches the `_RQ_BOX_FILL` class of bug where a near-white callout + stays near-white after the dark-mode pass while the text it + contains gets re-coloured to near-white. Usage: .venv\\Scripts\\python.exe -m scripts._audit_dark_text @@ -59,9 +67,61 @@ def _shape_runs(slide_idx: int, shape_or_cell, where_hint: str): yield (slide_idx, name, p_idx, r_idx, rgb, text) +def _read_shape_fill_rgb(shape_or_cell) -> tuple[int, int, int] | None: + fill = getattr(shape_or_cell, "fill", None) + if fill is None: + return None + try: + rgb = fill.fore_color.rgb + except (AttributeError, ValueError, TypeError): + return None + if rgb is None: + return None + return (int(rgb[0]), int(rgb[1]), int(rgb[2])) + + +_LIGHT_LUMINANCE_THRESHOLD = 0.7 * 255 # > 178 + + +def _check_contrast(prs, bad: list[str]) -> None: + """Failure mode B: light text inside light-fill shape (= invisible).""" + for slide_idx, slide in enumerate(prs.slides, 1): + for shape in slide.shapes: + fill_rgb = _read_shape_fill_rgb(shape) + if fill_rgb is None or _luminance(fill_rgb) <= _LIGHT_LUMINANCE_THRESHOLD: + continue + _check_shape_text_against_light_fill(slide_idx, shape, fill_rgb, bad) + + +def _check_shape_text_against_light_fill(slide_idx, shape, fill_rgb, bad) -> None: + tf = getattr(shape, "text_frame", None) + if tf is None: + return + for p_idx, paragraph in enumerate(tf.paragraphs): + for r_idx, run in enumerate(paragraph.runs): + text = (run.text or "").strip() + if not text: + continue + try: + rgb = run.font.color.rgb + except (AttributeError, ValueError, TypeError): + continue + if rgb is None: + continue + text_rgb = (int(rgb[0]), int(rgb[1]), int(rgb[2])) + if _luminance(text_rgb) <= _LIGHT_LUMINANCE_THRESHOLD: + continue + bad.append( + f"slide {slide_idx} {shape.name!r} p{p_idx}r{r_idx} " + f"LIGHT-ON-LIGHT fill={fill_rgb} text-rgb={text_rgb} " + f"text={text[:40]!r}" + ) + + def main(pptx_path: Path) -> int: prs = Presentation(pptx_path) bad: list[str] = [] + # Failure mode A — dark / unset text on dark slide bg. for slide_idx, name, p_idx, r_idx, rgb, text in _iter_runs(prs): if not text.strip(): continue @@ -78,6 +138,9 @@ def main(pptx_path: Path) -> int: f"slide {slide_idx} {name!r} p{p_idx}r{r_idx} " f"rgb={rgb_tuple} lum={_luminance(rgb_tuple):.0f} text={text!r}" ) + # Failure mode B — light text inside light-fill shape. + _check_contrast(prs, bad) + print(f"audit: {pptx_path.name}") print(f"runs flagged: {len(bad)}") for line in bad[:40]: diff --git a/tests/test_exporters.py b/tests/test_exporters.py index 3af74aa..8f07dd8 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -222,6 +222,84 @@ def test_pptx_dark_mode_has_no_invisible_runs(sample_papers, tmp_path): ) +def _luminance_255(rgb_tuple: tuple[int, int, int]) -> float: + return 0.2126 * rgb_tuple[0] + 0.7152 * rgb_tuple[1] + 0.0722 * rgb_tuple[2] + + +_LIGHT_LUMINANCE_THRESHOLD = 0.7 * 255 # > 178 + + +def _shape_fill_rgb(shape) -> tuple[int, int, int] | None: + try: + fill_rgb = shape.fill.fore_color.rgb + except (AttributeError, ValueError, TypeError): + return None + if fill_rgb is None: + return None + return (int(fill_rgb[0]), int(fill_rgb[1]), int(fill_rgb[2])) + + +def _scan_shape_for_light_on_light(s_idx, shape, fill_tuple, bad) -> None: + tf = getattr(shape, "text_frame", None) + if tf is None: + return + for para in tf.paragraphs: + for run in para.runs: + if not (run.text or "").strip(): + continue + try: + text_rgb = run.font.color.rgb + except (AttributeError, ValueError, TypeError): + continue + if text_rgb is None: + continue + text_tuple = (int(text_rgb[0]), int(text_rgb[1]), int(text_rgb[2])) + if _luminance_255(text_tuple) > _LIGHT_LUMINANCE_THRESHOLD: + bad.append( + f"slide {s_idx} shape {shape.name!r}: " + f"fill={fill_tuple} text={text_tuple} text={run.text[:30]!r}" + ) + + +def test_pptx_dark_mode_no_light_text_on_light_fill(sample_papers, tmp_path): + """Dark-mode regression guard — failure mode B (light-on-light). + + The previous regression caught text runs with no explicit colour + (rgb=None, render as black on dark slide bg). This test catches + the OTHER failure mode: a shape whose fill is light (luminance > + ~0.7 × 255) but contains text whose colour is ALSO light → text + disappears INTO the box. The `_RQ_BOX_FILL` (#F3F6FA near-white) + bug was the cautionary tale: the box stayed near-white in dark + mode while its text got swapped to near-white via the post-pass. + """ + from pptx import Presentation + + collection = _collection(sample_papers) + options = ExportOptions( + formats=("pptx",), + out_dir=str(tmp_path), + filename_stem="dark-contrast", + ) + written = export_collection(collection, options) + prs = Presentation(str(written["pptx"])) + + bad: list[str] = [] + for s_idx, slide in enumerate(prs.slides, 1): + for shape in slide.shapes: + fill_tuple = _shape_fill_rgb(shape) + if fill_tuple is None: + continue + if _luminance_255(fill_tuple) <= _LIGHT_LUMINANCE_THRESHOLD: + continue + _scan_shape_for_light_on_light(s_idx, shape, fill_tuple, bad) + assert not bad, ( + "dark-mode deck has light-on-light text that disappears into " + "the shape fill (extend _LIGHT_TO_DARK_FILL to recolour the " + "fill, OR don't use a near-white fill in light mode):\n " + + "\n ".join(bad[:10]) + ) + + def test_pptx_light_mode_keeps_navy_text(sample_papers, tmp_path): """``dark_mode=False`` opt-out skips the post-build recolour pass. From df99d619c26a48a97d13f7afacb147c109bf296b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 20 May 2026 23:31:36 +0800 Subject: [PATCH 17/18] =?UTF-8?q?Ban=20red=20text=20=E2=80=94=20migrate=20?= =?UTF-8?q?4=20=5FBRAND=5FACCENT=20call=20sites=20to=20=5FBRAND=5FDARK;=20?= =?UTF-8?q?add=20"No=20red=20text"=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User asked "不要用紅色的字體顏色" — no red font colour. Found 4 text call sites using _BRAND_ACCENT (#C0392B warm red) for emphasis: KPI values, RQ question text, figure caption, figure-unavailable fallback. All migrated to bold + _BRAND_DARK (navy). Why ban red TEXT specifically: * Red text reads as error / warning in slide conventions — wrong signal for a KPI we're proud of. * Pattern-matches strongly to AI-generated deck output ("LOOK AT THIS NUMBER!" + red bold + over-emphasis); banning it removes another "default LLM-deck tell". * In dark mode it'd be the only accent colour — visually inconsistent with the rest of the palette. CODE autopapertoppt/exporters/pptx.py - Replace `colour=_BRAND_ACCENT` with `colour=_BRAND_DARK` at 4 text call sites (lines 891, 934, 990, 1472). - _BRAND_ACCENT constant stays in the palette for potential future non-text accent shapes (sparkline highlight, status badge). Its docstring now states the ban. - _LIGHT_TO_DARK_TEXT comment updated: red is intentionally NOT mapped so the dark-mode pass can't quietly map a future red text leak; the regression test catches it first. scripts/_audit_dark_text.py Drop (0xC0, 0x39, 0x2B) from _ACCEPTED_DARK_RUN_COLORS — the audit now flags any red text run as an offender. REGRESSION TEST tests/test_exporters.py::test_pptx_no_red_text_runs Walks every text run on every slide of a default-rendered deck; fails if any non-empty run has font.color.rgb = #C0392B. Pins the ban for both light and dark modes. DOCS — same two-place pattern as the previous dark-mode rules .claude/agents/deck-design.md gains "No red text contract (HARD)" under "Dark-mode contract", with the three implementation rules: 1. Never write colour=_BRAND_ACCENT in any _add_*box helper. 2. Never assign run.font.color.rgb = _BRAND_ACCENT directly. 3. Use bold + _BRAND_DARK for emphasis. CLAUDE.md gains a "No red text" paragraph right after the existing light-on-light contrast mirror rule, with the test name and short rationale. Re-rendered 4 zh-tw decks; no red text remains. 515/515 pytest pass; ruff clean. --- .claude/agents/deck-design.md | 39 +++++++++++++++++++++++++ CLAUDE.md | 10 +++++++ autopapertoppt/exporters/pptx.py | 24 ++++++++++++---- scripts/_audit_dark_text.py | 7 +++-- tests/test_exporters.py | 49 ++++++++++++++++++++++++++++++++ 5 files changed, 121 insertions(+), 8 deletions(-) diff --git a/.claude/agents/deck-design.md b/.claude/agents/deck-design.md index 0fe2bee..bab117f 100644 --- a/.claude/agents/deck-design.md +++ b/.claude/agents/deck-design.md @@ -131,6 +131,45 @@ if any non-empty run has ``rgb is None`` or ``rgb == (0,0,0)``. A companion debug script lives at ``scripts/_audit_dark_text.py`` for manual inspection of a single rendered deck. +#### "No red text" contract (HARD) + +**Red font runs are banned across both light AND dark modes.** The +constant ``_BRAND_ACCENT`` (= ``#C0392B`` warm red) stays in the palette +for potential future non-text accent shapes (sparkline highlight, +status badge, etc.), but every TEXT call site has been migrated to +``_BRAND_DARK``. Use **bold + brand-dark navy** as the emphasis +pattern instead. + +Why banned: +1. Red text reads as error / warning / something-is-broken in slide + conventions. KPI values painted red signal "this is bad" — the + opposite of what we want for a result we're proud of. +2. Red text on slide decks pattern-matches strongly to AI-generated + output ("LOOK AT THIS NUMBER!" + red bold + over-emphasis). Same + reason we removed Calibri default and added accent geometry — every + "default LLM-deck tell" we can eliminate raises perceived quality. +3. In dark mode red text reads OK on the dark slide bg, but it'd be + the only accent colour — visually inconsistent with the rest of the + palette. + +Implementation contract: +1. **Never write** ``colour=_BRAND_ACCENT`` in any ``_add_textbox`` / + ``_add_bullet_box`` / ``_add_*`` helper call site. +2. **Never assign** ``run.font.color.rgb = _BRAND_ACCENT`` directly. +3. For emphasis on a value (e.g. a KPI number) use: + ``run.font.bold = True`` + ``run.font.color.rgb = _BRAND_DARK``. +4. Regression test ``test_pptx_no_red_text_runs`` walks every run on + a default-rendered deck and fails if any run uses ``#C0392B``. +5. The dark-mode ``_LIGHT_TO_DARK_TEXT`` map intentionally does NOT + include red — so even if the test missed a case, the dark-mode + pass wouldn't quietly map it; the run would carry red through to + the dark deck where the regression test fires. + +If a future "non-text accent" use of red comes up (e.g. a tiny status +dot in a card layout), that's fine — the test only flags TEXT runs. +Just keep the constant pointing at the same RGB so the brand stays +consistent. + #### Light-on-light contrast contract (the OTHER invisibility bug) A near-black bug is "text rgb=None → black on dark = invisible". The diff --git a/CLAUDE.md b/CLAUDE.md index a6dcd7d..dc9d86b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,6 +138,16 @@ near-white → invisible. Regression test and fails when both fill and text luminance are > 0.7 of 255 in a default-dark-mode render. +**No red text.** ``_BRAND_ACCENT`` (= ``#C0392B`` warm red) is BANNED +as a TEXT colour across both light and dark modes. Red text reads +as error / warning in slide conventions and pattern-matches strongly +to AI-generated KPI emphasis. Use **bold + ``_BRAND_DARK``** instead. +Regression test ``test_pptx_no_red_text_runs`` walks every run on a +default-rendered deck and fails if any run uses ``#C0392B``. The +constant stays in the palette in case a future non-text accent shape +(sparkline, status badge) wants it. Full rule in +``.claude/agents/deck-design.md`` "No red text contract (HARD)". + ## Where the detailed rules live | Topic | Subagent (in `.claude/agents/`) | diff --git a/autopapertoppt/exporters/pptx.py b/autopapertoppt/exporters/pptx.py index 692a710..b3c8f67 100644 --- a/autopapertoppt/exporters/pptx.py +++ b/autopapertoppt/exporters/pptx.py @@ -106,6 +106,14 @@ # Colours (mirror the reference deck's palette) _BRAND_DARK = RGBColor(0x1F, 0x3A, 0x66) +#: WARNING — DO NOT use _BRAND_ACCENT as a TEXT colour. +#: Red text in slide decks is consistently associated with errors, +#: warnings, and AI-generated KPI emphasis ("look at this number!"). +#: The project bans red font runs entirely; use bold + _BRAND_DARK +#: for emphasis instead. The constant is kept around in case a +#: future non-text accent shape (sparkline, badge, etc.) needs it, +#: but every existing TEXT callsite has been migrated to _BRAND_DARK. +#: See .claude/agents/deck-design.md "No red text" contract. _BRAND_ACCENT = RGBColor(0xC0, 0x39, 0x2B) _BRAND_GREY = RGBColor(0x55, 0x55, 0x55) _BRAND_LIGHT = RGBColor(0xAA, 0xAA, 0xAA) @@ -151,7 +159,11 @@ (0x1F, 0x3A, 0x66): (0xE5, 0xE7, 0xEB), # _BRAND_DARK → near-white text (0x55, 0x55, 0x55): (0x9C, 0xA3, 0xAF), # _BRAND_GREY → mid grey (0xAA, 0xAA, 0xAA): (0x6B, 0x72, 0x80), # _BRAND_LIGHT → muted grey - # _BRAND_ACCENT (#C0392B) stays — warm red is legible on dark. + # _BRAND_ACCENT (#C0392B) intentionally NOT mapped — red text was + # banned per the deck-design "No red text" contract, and the + # `test_pptx_no_red_text_runs` regression test fails if any run + # ever writes that colour. If a run shows up with it the test + # catches it BEFORE we reach this swap layer. } # Light-palette RGB → dark-palette RGB mapping for SHAPE / CELL FILLS @@ -888,7 +900,7 @@ def _add_figure_image( slide, name="body", text=f"[figure unavailable: {path.name}]", left=left, top=top, width=max_width, height=Inches(0.5), - font_pt=_BODY_PT, colour=_BRAND_ACCENT, + font_pt=_BODY_PT, colour=_BRAND_DARK, ) return # python-pptx scales by aspect ratio if we pass only height (or @@ -931,7 +943,7 @@ def _add_paper_table_slides( text=f"{t(ctx.language, 'label_caption')}: {_clean(caption)}", left=_MARGIN_X, top=Inches(1.65), width=_BODY_WIDTH, height=Inches(0.55), - font_pt=_SUBHEAD_PT - 2, bold=True, colour=_BRAND_ACCENT, + font_pt=_SUBHEAD_PT - 2, bold=True, colour=_BRAND_DARK, shrink_to_fit=True, ) cols = len(rows[0]) @@ -987,7 +999,7 @@ def _add_rq_result_slide( slide, name="rq_question", text=question_text, left=_MARGIN_X, top=Inches(1.65), width=_BODY_WIDTH, height=Inches(0.55), - font_pt=_SUBHEAD_PT - 2, bold=True, colour=_BRAND_ACCENT, + font_pt=_SUBHEAD_PT - 2, bold=True, colour=_BRAND_DARK, shrink_to_fit=True, ) if rq.table: @@ -1469,7 +1481,9 @@ def _add_kpi_lines( run_value.text = str(value) run_value.font.size = Pt(_BODY_PT + 2) run_value.font.bold = True - run_value.font.color.rgb = _BRAND_ACCENT + # Emphasis via bold + brand-dark navy — red text was banned per + # deck-design "No red font" contract. See deck-design.md. + run_value.font.color.rgb = _BRAND_DARK if baseline: run_base = paragraph.add_run() run_base.text = f" ({baseline_label}: {baseline})" diff --git a/scripts/_audit_dark_text.py b/scripts/_audit_dark_text.py index 32fb986..b90670d 100644 --- a/scripts/_audit_dark_text.py +++ b/scripts/_audit_dark_text.py @@ -25,14 +25,15 @@ from pptx import Presentation -# Colours we KNOW are intentional on a dark deck — accent red, the -# dark-mode text near-white, mid-greys, light-text on header fills. +# Colours we KNOW are intentional on a dark deck — the dark-mode text +# near-white, mid-greys, light-text on header fills. _BRAND_ACCENT +# (#C0392B warm red) is deliberately NOT in this set — red text was +# banned per the deck-design "No red text" contract. _ACCEPTED_DARK_RUN_COLORS = { (0xE5, 0xE7, 0xEB), # dark-mode body text (0x9C, 0xA3, 0xAF), # dark-mode metadata grey (0x6B, 0x72, 0x80), # dark-mode muted grey (0xFF, 0xFF, 0xFF), # table-header white - (0xC0, 0x39, 0x2B), # brand accent red (legible on both bgs) } diff --git a/tests/test_exporters.py b/tests/test_exporters.py index 8f07dd8..9f70c36 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -300,6 +300,55 @@ def test_pptx_dark_mode_no_light_text_on_light_fill(sample_papers, tmp_path): ) +def test_pptx_no_red_text_runs(sample_papers, tmp_path): + """The "No red text" contract: ``_BRAND_ACCENT`` (#C0392B) must + never be written as a run colour. Bold + ``_BRAND_DARK`` is the + approved emphasis pattern. Red font runs read as error / warning + in slide-deck conventions and pattern-match strongly to AI-generated + KPI emphasis ("look at this number!"). Banned across light AND + dark modes. + + A regression here means a new (or moved) builder added back a + ``colour=_BRAND_ACCENT`` parameter or wrote + ``run.font.color.rgb = _BRAND_ACCENT`` directly. + """ + from pptx import Presentation + + collection = _collection(sample_papers) + options = ExportOptions( + formats=("pptx",), + out_dir=str(tmp_path), + filename_stem="no-red", + ) + written = export_collection(collection, options) + prs = Presentation(str(written["pptx"])) + red = (0xC0, 0x39, 0x2B) + offenders: list[str] = [] + for s_idx, slide in enumerate(prs.slides, 1): + for shape in slide.shapes: + tf = getattr(shape, "text_frame", None) + if tf is None: + continue + for para in tf.paragraphs: + for run in para.runs: + text = (run.text or "").strip() + if not text: + continue + try: + rgb = run.font.color.rgb + except (AttributeError, ValueError, TypeError): + continue + if rgb is not None and tuple(rgb) == red: + offenders.append( + f"slide {s_idx} shape {shape.name!r}: {text[:40]!r}" + ) + assert not offenders, ( + "red text (#C0392B) found — use bold + _BRAND_DARK for emphasis " + "instead (deck-design 'No red text' contract):\n " + + "\n ".join(offenders[:10]) + ) + + def test_pptx_light_mode_keeps_navy_text(sample_papers, tmp_path): """``dark_mode=False`` opt-out skips the post-build recolour pass. From 2465899bd821068e220d13e909bffb014c0260cb Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 21 May 2026 00:28:47 +0800 Subject: [PATCH 18/18] Replace navy-only red migration with varied palette (teal + grey) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous red-text ban collapsed all 4 ex-_BRAND_ACCENT sites onto _BRAND_DARK navy, which lost the emphasis distinction those surfaces were trying to carry. Restore variety with two non-red accents: - _BRAND_HIGHLIGHT (teal-700, #0E7490) — new constant for headline emphasis. Used at KPI value + RQ question (the slide's punch line and the question being answered). - _BRAND_GREY (#555555, existing) — for caption / placeholder / chrome. Used at paper-table caption + figure-unavailable fallback. Teal pairs cleanly with navy body text without the warning/error connotation of red. The dark-mode pass swaps teal-700 -> teal-400 (#2DD4BF) via _LIGHT_TO_DARK_TEXT; the audit script's accepted-colours set knows about both. The "No red text" regression test still bans #C0392B; its assertion message + docstring now point to the two sanctioned replacements. Updated: - deck-design.md "No red text" contract — per-call-site palette table + variety rule ("teal is for headlines, grey is for context") - CLAUDE.md mirror rule — references the new palette and dark-mode swap --- .claude/agents/deck-design.md | 47 ++++++++++++++++++++++++-------- CLAUDE.md | 15 +++++++--- autopapertoppt/exporters/pptx.py | 47 +++++++++++++++++++++++--------- scripts/_audit_dark_text.py | 9 ++++-- tests/test_exporters.py | 17 +++++++----- 5 files changed, 96 insertions(+), 39 deletions(-) diff --git a/.claude/agents/deck-design.md b/.claude/agents/deck-design.md index bab117f..180b733 100644 --- a/.claude/agents/deck-design.md +++ b/.claude/agents/deck-design.md @@ -48,13 +48,16 @@ Already pinned in `pptx.py`: | Constant | RGB | Use | |---|---|---| | `_BRAND_DARK` | `#1F3A66` (deep navy) | Primary text + accent bar | -| `_BRAND_ACCENT` | `#C0392B` (warm red) | KPI highlights, hover-style emphasis | -| `_BRAND_GREY` | `#555555` | Metadata, secondary text | +| `_BRAND_HIGHLIGHT` | `#0E7490` (teal-700) | Text emphasis — KPI values, RQ question callout, "this stands out" headlines. The sanctioned replacement for the banned red accent. | +| `_BRAND_ACCENT` | `#C0392B` (warm red) | BANNED for text (see "No red text" contract). Reserved for potential future non-text accent shapes only. | +| `_BRAND_GREY` | `#555555` | Metadata, captions, secondary text, placeholder/error states | | `_BRAND_LIGHT` | `#AAAAAA` | Rule lines, dividers | Do NOT introduce new brand colours casually — every additional colour -fights for attention. Reuse the four above unless the user explicitly -adds one. +fights for attention. Reuse the five above unless the user explicitly +adds one. Note the deliberate split: **teal is the headline emphasis, +grey is the label/chrome emphasis** — picking the wrong one (e.g. teal +for a figure caption) makes captions compete with KPIs for the eye. #### Dark-mode palette (default; opt-out with `dark_mode=False` / `--light-mode` / GUI "Light mode") @@ -72,7 +75,8 @@ to know about dark mode at construction time. | `_BRAND_DARK` text | `#1F3A66` → `#E5E7EB` | Body text near-white | | `_BRAND_GREY` text | `#555555` → `#9CA3AF` | Metadata mid grey | | `_BRAND_LIGHT` text | `#AAAAAA` → `#6B7280` | Subtle dividers / page numbers | -| `_BRAND_ACCENT` | `#C0392B` (unchanged) | Warm red is legible on both light and dark | +| `_BRAND_HIGHLIGHT` text | `#0E7490` → `#2DD4BF` | Teal-700 → teal-400; brighter teal reads on the dark slide bg without losing the accent identity | +| `_BRAND_ACCENT` | `#C0392B` (unchanged) | BANNED for text in both modes (see "No red text" contract). If reused for a non-text shape, kept as-is for brand consistency. | | `_BRAND_DARK` fill (accent bars / table header) | `#1F3A66` → `#3B5AA0` | Lighter navy reads against the dark slide background | | `_TABLE_ROW_ALT` | `#F4F6F9` → `#1F232C` | Dark stripe | | Pure white table cell | `#FFFFFF` → `#161A22` | Near-black non-stripe rows | @@ -136,9 +140,11 @@ manual inspection of a single rendered deck. **Red font runs are banned across both light AND dark modes.** The constant ``_BRAND_ACCENT`` (= ``#C0392B`` warm red) stays in the palette for potential future non-text accent shapes (sparkline highlight, -status badge, etc.), but every TEXT call site has been migrated to -``_BRAND_DARK``. Use **bold + brand-dark navy** as the emphasis -pattern instead. +status badge, etc.), but every TEXT call site has been migrated off it. +The sanctioned text-emphasis colour is **``_BRAND_HIGHLIGHT`` (teal-700, +``#0E7490``)** — pair with ``run.font.bold = True``. Use ``_BRAND_GREY`` +for chrome / label / placeholder emphasis (never teal — teal is reserved +for "this matters", grey is for "this is context"). Why banned: 1. Red text reads as error / warning / something-is-broken in slide @@ -150,20 +156,37 @@ Why banned: "default LLM-deck tell" we can eliminate raises perceived quality. 3. In dark mode red text reads OK on the dark slide bg, but it'd be the only accent colour — visually inconsistent with the rest of the - palette. + palette. Teal pairs cleanly with the navy ``_BRAND_DARK`` body text. + +Variety rule (avoid monotone emphasis): when migrating a ex-red site, +**pick the colour that matches the site's role**, not whichever colour +is closest at hand. The four migrated sites split: + +| Call site | Role | Replacement | +|---|---|---| +| KPI value (`_add_kpi_lines`) | "the slide's punch line" headline | `_BRAND_HIGHLIGHT` (teal) | +| RQ question (`_add_rq_result_slide`) | "the question being answered" headline | `_BRAND_HIGHLIGHT` (teal) | +| Paper-table caption (`_add_paper_table_slides`) | caption label below subhead | `_BRAND_GREY` (muted) | +| Figure-unavailable fallback (`_add_figure_image`) | placeholder / error state | `_BRAND_GREY` (muted) | Implementation contract: 1. **Never write** ``colour=_BRAND_ACCENT`` in any ``_add_textbox`` / ``_add_bullet_box`` / ``_add_*`` helper call site. 2. **Never assign** ``run.font.color.rgb = _BRAND_ACCENT`` directly. 3. For emphasis on a value (e.g. a KPI number) use: - ``run.font.bold = True`` + ``run.font.color.rgb = _BRAND_DARK``. -4. Regression test ``test_pptx_no_red_text_runs`` walks every run on + ``run.font.bold = True`` + ``run.font.color.rgb = _BRAND_HIGHLIGHT``. +4. For caption / placeholder / chrome text, use ``_BRAND_GREY`` — not + teal, not navy. Reserving teal for headlines is what makes headlines + actually read as headlines. +5. Regression test ``test_pptx_no_red_text_runs`` walks every run on a default-rendered deck and fails if any run uses ``#C0392B``. -5. The dark-mode ``_LIGHT_TO_DARK_TEXT`` map intentionally does NOT +6. The dark-mode ``_LIGHT_TO_DARK_TEXT`` map intentionally does NOT include red — so even if the test missed a case, the dark-mode pass wouldn't quietly map it; the run would carry red through to the dark deck where the regression test fires. +7. The audit script's ``_ACCEPTED_DARK_RUN_COLORS`` set includes the + dark-mode teal variant ``#2DD4BF``; if you introduce another accent + colour, update both the map AND the audit set in the same commit. If a future "non-text accent" use of red comes up (e.g. a tiny status dot in a card layout), that's fine — the test only flags TEXT runs. diff --git a/CLAUDE.md b/CLAUDE.md index dc9d86b..8d25cf6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,12 +141,19 @@ default-dark-mode render. **No red text.** ``_BRAND_ACCENT`` (= ``#C0392B`` warm red) is BANNED as a TEXT colour across both light and dark modes. Red text reads as error / warning in slide conventions and pattern-matches strongly -to AI-generated KPI emphasis. Use **bold + ``_BRAND_DARK``** instead. +to AI-generated KPI emphasis. The sanctioned text-emphasis colour is +**``_BRAND_HIGHLIGHT``** (teal-700, ``#0E7490``) — pair with +``run.font.bold = True``. Use ``_BRAND_GREY`` for caption / placeholder / +chrome text so headlines stay headlines. Variety rule: KPI value + RQ +question use teal; figure caption + figure-unavailable use grey — do +not collapse all four to the same colour. The dark-mode pass swaps +teal-700 → teal-400 (``#2DD4BF``) via ``_LIGHT_TO_DARK_TEXT``; the +audit script's ``_ACCEPTED_DARK_RUN_COLORS`` set knows about both. Regression test ``test_pptx_no_red_text_runs`` walks every run on a -default-rendered deck and fails if any run uses ``#C0392B``. The +default-rendered deck and fails if any run uses ``#C0392B``. The red constant stays in the palette in case a future non-text accent shape -(sparkline, status badge) wants it. Full rule in -``.claude/agents/deck-design.md`` "No red text contract (HARD)". +(sparkline, status badge) wants it. Full rule + per-call-site palette +mapping in ``.claude/agents/deck-design.md`` "No red text contract (HARD)". ## Where the detailed rules live diff --git a/autopapertoppt/exporters/pptx.py b/autopapertoppt/exporters/pptx.py index b3c8f67..6f381b0 100644 --- a/autopapertoppt/exporters/pptx.py +++ b/autopapertoppt/exporters/pptx.py @@ -109,12 +109,19 @@ #: WARNING — DO NOT use _BRAND_ACCENT as a TEXT colour. #: Red text in slide decks is consistently associated with errors, #: warnings, and AI-generated KPI emphasis ("look at this number!"). -#: The project bans red font runs entirely; use bold + _BRAND_DARK -#: for emphasis instead. The constant is kept around in case a -#: future non-text accent shape (sparkline, badge, etc.) needs it, -#: but every existing TEXT callsite has been migrated to _BRAND_DARK. +#: The project bans red font runs entirely; use _BRAND_HIGHLIGHT (teal) +#: for emphasis instead. The constant is kept around in case a future +#: non-text accent shape (sparkline, badge, etc.) needs it, but every +#: existing TEXT callsite has been migrated to _BRAND_HIGHLIGHT. #: See .claude/agents/deck-design.md "No red text" contract. _BRAND_ACCENT = RGBColor(0xC0, 0x39, 0x2B) +#: Emphasis text colour — teal-700 (#0E7490). Replaces the banned red +#: _BRAND_ACCENT for KPI values, RQ question callouts, figure +#: captions, and other "this stands out" use cases. Pairs well with +#: bold; pairs cleanly with _BRAND_DARK navy as the secondary; reads +#: as professional/modern (think academic posters, not error banners). +#: Dark-mode pass swaps to teal-400 (#2DD4BF) via _LIGHT_TO_DARK_TEXT. +_BRAND_HIGHLIGHT = RGBColor(0x0E, 0x74, 0x90) _BRAND_GREY = RGBColor(0x55, 0x55, 0x55) _BRAND_LIGHT = RGBColor(0xAA, 0xAA, 0xAA) @@ -156,9 +163,10 @@ # are 3-tuples (R, G, B) since python-pptx's RGBColor is tuple-comparable # but we want to match by raw int components. _LIGHT_TO_DARK_TEXT: dict[tuple[int, int, int], tuple[int, int, int]] = { - (0x1F, 0x3A, 0x66): (0xE5, 0xE7, 0xEB), # _BRAND_DARK → near-white text - (0x55, 0x55, 0x55): (0x9C, 0xA3, 0xAF), # _BRAND_GREY → mid grey - (0xAA, 0xAA, 0xAA): (0x6B, 0x72, 0x80), # _BRAND_LIGHT → muted grey + (0x1F, 0x3A, 0x66): (0xE5, 0xE7, 0xEB), # _BRAND_DARK → near-white text + (0x55, 0x55, 0x55): (0x9C, 0xA3, 0xAF), # _BRAND_GREY → mid grey + (0xAA, 0xAA, 0xAA): (0x6B, 0x72, 0x80), # _BRAND_LIGHT → muted grey + (0x0E, 0x74, 0x90): (0x2D, 0xD4, 0xBF), # _BRAND_HIGHLIGHT → bright teal-400 # _BRAND_ACCENT (#C0392B) intentionally NOT mapped — red text was # banned per the deck-design "No red text" contract, and the # `test_pptx_no_red_text_runs` regression test fails if any run @@ -900,7 +908,10 @@ def _add_figure_image( slide, name="body", text=f"[figure unavailable: {path.name}]", left=left, top=top, width=max_width, height=Inches(0.5), - font_pt=_BODY_PT, colour=_BRAND_DARK, + # Muted grey for a placeholder/error state — not a headline. + # Was red, then briefly navy; settled on grey because this + # surface is contextual chrome, not "this stands out". + font_pt=_BODY_PT, colour=_BRAND_GREY, ) return # python-pptx scales by aspect ratio if we pass only height (or @@ -943,7 +954,10 @@ def _add_paper_table_slides( text=f"{t(ctx.language, 'label_caption')}: {_clean(caption)}", left=_MARGIN_X, top=Inches(1.65), width=_BODY_WIDTH, height=Inches(0.55), - font_pt=_SUBHEAD_PT - 2, bold=True, colour=_BRAND_DARK, + # Mid grey reads as a caption label (matches the figure-slide + # caption style at line ~887) rather than competing with the + # paper-table itself for the eye. Was red, then briefly navy. + font_pt=_SUBHEAD_PT - 2, bold=True, colour=_BRAND_GREY, shrink_to_fit=True, ) cols = len(rows[0]) @@ -999,7 +1013,11 @@ def _add_rq_result_slide( slide, name="rq_question", text=question_text, left=_MARGIN_X, top=Inches(1.65), width=_BODY_WIDTH, height=Inches(0.55), - font_pt=_SUBHEAD_PT - 2, bold=True, colour=_BRAND_DARK, + # Teal highlight — this is the actual research question being + # answered on this slide; the eye should land on it before the + # results table below. Was red, then briefly navy; teal carries + # the "thoughtful, intentional" tone without the warning vibe. + font_pt=_SUBHEAD_PT - 2, bold=True, colour=_BRAND_HIGHLIGHT, shrink_to_fit=True, ) if rq.table: @@ -1481,9 +1499,12 @@ def _add_kpi_lines( run_value.text = str(value) run_value.font.size = Pt(_BODY_PT + 2) run_value.font.bold = True - # Emphasis via bold + brand-dark navy — red text was banned per - # deck-design "No red font" contract. See deck-design.md. - run_value.font.color.rgb = _BRAND_DARK + # Teal accent for KPI numbers — they're the slide's punch line + # (a 2.3x speedup, a 78% F1, etc.). Bold + teal makes them pop + # without using red, which would read as error/warning. Was red, + # then briefly navy; teal restores a real emphasis colour. + # See deck-design.md "No red text" contract. + run_value.font.color.rgb = _BRAND_HIGHLIGHT if baseline: run_base = paragraph.add_run() run_base.text = f" ({baseline_label}: {baseline})" diff --git a/scripts/_audit_dark_text.py b/scripts/_audit_dark_text.py index b90670d..46720d7 100644 --- a/scripts/_audit_dark_text.py +++ b/scripts/_audit_dark_text.py @@ -26,14 +26,17 @@ from pptx import Presentation # Colours we KNOW are intentional on a dark deck — the dark-mode text -# near-white, mid-greys, light-text on header fills. _BRAND_ACCENT -# (#C0392B warm red) is deliberately NOT in this set — red text was -# banned per the deck-design "No red text" contract. +# near-white, mid-greys, light-text on header fills, the dark-mode +# teal accent. _BRAND_ACCENT (#C0392B warm red) is deliberately NOT in +# this set — red text was banned per the deck-design "No red text" +# contract; _BRAND_HIGHLIGHT (teal) is the sanctioned replacement and +# its dark-mode variant (teal-400 #2DD4BF) is in this set. _ACCEPTED_DARK_RUN_COLORS = { (0xE5, 0xE7, 0xEB), # dark-mode body text (0x9C, 0xA3, 0xAF), # dark-mode metadata grey (0x6B, 0x72, 0x80), # dark-mode muted grey (0xFF, 0xFF, 0xFF), # table-header white + (0x2D, 0xD4, 0xBF), # dark-mode teal accent (_BRAND_HIGHLIGHT swap) } diff --git a/tests/test_exporters.py b/tests/test_exporters.py index 9f70c36..56d31a6 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -302,11 +302,13 @@ def test_pptx_dark_mode_no_light_text_on_light_fill(sample_papers, tmp_path): def test_pptx_no_red_text_runs(sample_papers, tmp_path): """The "No red text" contract: ``_BRAND_ACCENT`` (#C0392B) must - never be written as a run colour. Bold + ``_BRAND_DARK`` is the - approved emphasis pattern. Red font runs read as error / warning - in slide-deck conventions and pattern-match strongly to AI-generated - KPI emphasis ("look at this number!"). Banned across light AND - dark modes. + never be written as a run colour. Bold + ``_BRAND_HIGHLIGHT`` + (teal-700 ``#0E7490``) is the approved emphasis pattern for + headline text (KPI value, RQ question); ``_BRAND_GREY`` is the + approved pattern for caption / placeholder / chrome text. Red + font runs read as error / warning in slide-deck conventions and + pattern-match strongly to AI-generated KPI emphasis ("look at this + number!"). Banned across light AND dark modes. A regression here means a new (or moved) builder added back a ``colour=_BRAND_ACCENT`` parameter or wrote @@ -343,8 +345,9 @@ def test_pptx_no_red_text_runs(sample_papers, tmp_path): f"slide {s_idx} shape {shape.name!r}: {text[:40]!r}" ) assert not offenders, ( - "red text (#C0392B) found — use bold + _BRAND_DARK for emphasis " - "instead (deck-design 'No red text' contract):\n " + "red text (#C0392B) found — use bold + _BRAND_HIGHLIGHT (teal) " + "for headlines or _BRAND_GREY for captions instead " + "(deck-design 'No red text' contract):\n " + "\n ".join(offenders[:10]) )