diff --git a/.claude/agents/deck-design.md b/.claude/agents/deck-design.md new file mode 100644 index 0000000..180b733 --- /dev/null +++ b/.claude/agents/deck-design.md @@ -0,0 +1,366 @@ +--- +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_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 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") + +**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. + +| 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_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 | +| `_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 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. + +#### 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. + +#### "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 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 + 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. 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_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``. +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. +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 +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) +- 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) + +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: +- 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`. + +### 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 + 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/language-vocabulary-check.md b/.claude/agents/language-vocabulary-check.md new file mode 100644 index 0000000..3b88463 --- /dev/null +++ b/.claude/agents/language-vocabulary-check.md @@ -0,0 +1,621 @@ +--- +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. +Grouped by domain so future maintainers can drop new entries into the +right bucket. + +#### Memory / hardware + +| S-Chinese (avoid in zh-tw) | T-Chinese | Meaning | +|---|---|---| +| 內存 | 記憶體 | RAM / memory | +| 主存 | 主記憶體 | 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 | +| 客戶端 | 用戶端 (also 客戶端) | client (both used; prefer 用戶端 in formal TW) | +| 線程 | 執行緒 | thread | +| 進程 | 行程 / 處理程序 | process | +| 內核 | 核心 | kernel | +| 內置 | 內建 | built-in | +| 集群 | 叢集 | cluster | +| 守護進程 | 常駐程式 / daemon | daemon | +| 句柄 | 控制代碼 / handle | OS handle | +| 進程間通信 | 行程間通訊 | IPC | + +#### Programming language constructs + +| S-Chinese | T-Chinese | Meaning | +|---|---|---| +| 程序 (computing context) | 程式 | program | +| 編程 | 程式設計 | programming | +| 函數 | 函式 (`函数` 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) | +| 字符 | 字元 | 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 | +| 短信 | 簡訊 | 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 | +| 激活 | 啟用 | 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 | +| 快捷方式 | 捷徑 | 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 | + +#### 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 | + +#### `復` 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) | + +#### 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 +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/agents/paper-summary-author.md b/.claude/agents/paper-summary-author.md index b728014..a7171cf 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,59 @@ 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 **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, 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//`; + `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 +306,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/.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 d3e3617..8d25cf6 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`, `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 @@ -106,6 +107,54 @@ 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". + +**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. + +**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. 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 red +constant stays in the palette in case a future non-text accent shape +(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 | Topic | Subagent (in `.claude/agents/`) | @@ -113,8 +162,10 @@ 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` | | 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/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 f5d5732..655de62 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( + "--light-mode", + action="store_true", + help=( + "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( "--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=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 deb3f56..a80526d 100644 --- a/autopapertoppt/core/models.py +++ b/autopapertoppt/core/models.py @@ -391,6 +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 (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/exporters/pptx.py b/autopapertoppt/exporters/pptx.py index c5a16d8..6f381b0 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.text import MSO_AUTO_SIZE, PP_ALIGN +from pptx.enum.shapes import MSO_SHAPE +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 from autopapertoppt.core.constants import EXPORT_PPTX @@ -104,15 +106,100 @@ # 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 _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) + +# 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) + +# 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 + (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 + # 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 +# 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), + # _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) _RQ_BOX_BORDER = RGBColor(0x1F, 0x3A, 0x66) _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) @@ -266,6 +353,13 @@ 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) + if options.dark_mode: + _apply_dark_mode(prs) return prs @@ -814,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_ACCENT, + # 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 @@ -857,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_ACCENT, + # 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]) @@ -913,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_ACCENT, + # 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: @@ -1252,6 +1356,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: @@ -1387,7 +1499,12 @@ 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 + # 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})" @@ -1398,6 +1515,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) @@ -1410,31 +1544,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, ...]: @@ -1606,3 +1803,241 @@ 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) + + +# --------------------------------------------------------------------------- +# 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: + """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): + 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 not None: + 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..c1aaa3e 100644 --- a/autopapertoppt/gui/i18n.py +++ b/autopapertoppt/gui/i18n.py @@ -1504,6 +1504,22 @@ "hi": "Abstract slides शामिल करें", "id": "Sertakan slide abstrak", }, + "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", "zh-tw": "輸出", diff --git a/autopapertoppt/gui/pages/deck.py b/autopapertoppt/gui/pages/deck.py index fde71a9..8f8293d 100644 --- a/autopapertoppt/gui/pages/deck.py +++ b/autopapertoppt/gui/pages/deck.py @@ -137,6 +137,12 @@ def _build_ui(self) -> None: ) self._include_abstract_check.setChecked(True) options_form.addRow(self._include_abstract_check) + # 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._light_mode_check.setChecked(False) + options_form.addRow(self._light_mode_check) outer.addWidget(options_box) # Action row @@ -257,6 +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=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/_audit_dark_text.py b/scripts/_audit_dark_text.py new file mode 100644 index 0000000..46720d7 --- /dev/null +++ b/scripts/_audit_dark_text.py @@ -0,0 +1,158 @@ +"""Find dark-mode readability bugs in a rendered .pptx. + +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 +""" +from __future__ import annotations + +import sys +from pathlib import Path + +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, 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) +} + + +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 _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 + 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}" + ) + # 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]: + 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/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/_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/scripts/regen_llm_security_batch_zh_tw.py b/scripts/regen_llm_security_batch_zh_tw.py index 03c8498..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", - "信任網絡 (海外親屬匯款) 成為救命工具", + "信任網路 (海外親屬匯款) 成為救命工具", )), ("為流離者設計往往是想像而非真實", ( "設計師可以隨意想像永遠不見面的使用者", @@ -1252,7 +1252,7 @@ def _fig(paper_key: str, filename: str) -> str: )), ("防禦設計受裝置限制", ( "電池 / 計算 / 記憶體限制排除強加密", - "病患差異使異常偵測難以普適", + "病患差異使例外偵測難以普適", "缺即時顯示,靠病患監看不切實際", )), ), @@ -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=( @@ -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/scripts/regen_speculative_decoding_zh_tw.py b/scripts/regen_speculative_decoding_zh_tw.py index 81a0b80..f06fcbc 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,40 @@ "多模態 LLM 的 Speculative Decoding 變體", "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"), + ( + "雙軸分類:Drafting (Independent / Self) × Verification (Greedy / SpecSampling / Token Tree)。", + "20+ 代表方法依此分群,新方法可在這張圖上找到自己的位置。", + ), + ), + ( + "Spec-Bench 加速比較 — 不同硬體 (Figure 7)", + _fig("xia2024speculative", "p08-04-Speedup-comparison-of-various-Speculative.png"), + ( + "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×。", + ), + ), + ), ), ) @@ -390,6 +431,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 +653,88 @@ "Dynamic offloading 與 EdgeLLM 的協同", "雲端 + 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"), + ( + "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。", + ), + ), + ( + "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× 能耗節省。", + ), + ), + ), ), ) @@ -766,6 +915,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 在抗重複等修正解碼策略下仍有效。", + ), + ), + ), ), ) @@ -776,6 +960,15 @@ def main() -> None: out_dir = ROOT / "exports" / _RUN_DIR_NAME out_dir.mkdir(parents=True, exist_ok=True) + # 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], ...] = ( + (True, ""), + (False, "-light"), + ) for paper in ALL_PAPERS: collection = PaperCollection( query=Query( @@ -785,19 +978,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", - ) - 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 2bc5cd7..56d31a6 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,11 +123,268 @@ 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 +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 + + +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 run carries the swapped near-white text 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="default-dark", + ) + written = export_collection(collection, options) + prs = Presentation(str(written["pptx"])) + bg_rgb = list(prs.slides)[0].background.fill.fore_color.rgb + assert tuple(bg_rgb) == tuple(RGBColor(0x12, 0x15, 0x1B)) + assert _find_run_color(prs, (0xE5, 0xE7, 0xEB)), ( + "no run was re-coloured to the dark-mode near-white text" + ) + + +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 _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_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_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 + ``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_HIGHLIGHT (teal) " + "for headlines or _BRAND_GREY for captions 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. + + 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 pptx import Presentation @@ -309,7 +581,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_i18n.py b/tests/test_i18n.py index a420fbd..9033520 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"(?