# SSP Doc Parser — Release Notes

> Branch: `feat/ssp-doc-parser` (BE + FE) — 自 2026-04-26 起累積至 2026-04-30 完成
> Last BE commit: `332a256` · Last FE commit: `1f66640`

## 1. 需求概述

讓使用者把既有的 docx 格式 SSP（System Security Plan）/合規程序文件，
**自動帶入**到 Guidant AI 的合規資源庫 / 專案規劃 / 編輯模板等模組，
省去逐控制項手動複製貼上的成本。

支援三條入口：

| 入口位置 | UI 觸發 | mode | 用途 |
|---------|--------|------|------|
| 新增合規資源庫 (`ModuleFrame.vue` Step 1) | 「從 docx 匯入」按鈕 | `full` (preselect-new) | 上傳後自動勾選 baseline 控制項 + 預先填入現況，建立資源庫時一併寫入 |
| 編輯現況與程序書 (`ModuleFrameTemplateEditView.vue`) | 「批次維護」 → 「從 docx 匯入」 | `statement_only` | 寫入既有 module_frame 的 control_defaults / objective_defaults |
| 專案規劃 (`ProjectPlanningView.vue`) | 「批次維護」 → 「從 docx 匯入」 | `statement_only` | 寫入既有 SSP 的 control_implementations / objectives |

---

## 2. 功能行為

### 解析邏輯

- **H3 標題識別**：以 `Heading 3` 樣式的段落為主，加上 `_promote_pseudo_h3`
  容錯（作者忘了套樣式但段落以 framework ID 開頭時自動推升）
- **跨版本 ID 配對**：CMMC 1.0 (`AC.L1-b.1.i`) ↔ CMMC 2.0 (`AC.L1-3.1.1`)
  自動透過 fuzzy 名稱比對對接，雙語標題 + `[FCI DATA]【FCI 資料】` 雜訊都會
  被剝除後再比
- **typo 容錯**：catalog 內若有 typo（如 `Malicious Code Proection`），
  fuzzy 採 `max(token_set_ratio(raw), ratio(cleaned), ratio(ascii_only))`
  仍能正確命中
- **現況抓取**：H3 區段內第一條 Word 自動編號 list item 起算才是現況；
  英中描述、Objective 表格、紅字註記都自動排除
- **AO 分類**：每條 list item 內以 `；` / `;` 切 clause，每 clause 帶
  `(letter)` 自動分配；多 letter（如 `(b)(c)(d)`）同 clause 同時掛多個 AO
- **Markdown 輸出**：control 級保留 `1./2./...` 編號，前端 markdown renderer
  直接呈現有序清單

### 寫入策略

- 寫入時 `implementation_status` 自動設為 `'implemented'`（control + AO 兩層）
- 編輯模式 candidates 從 catalog 載入（含真實 control_name + AO letter keys），
  並透過 `oscal_profile.include_controls` 過濾，docx 提到 baseline 之外的
  control 不會偷偷建 default row
- **不會修改** `oscal_profile.include_controls`（baseline 本身），只 upsert
  `*_defaults` / `control_implementations` 兩張表

### UI 行為

- 三個入口共用同一套 `SspDocxImportDialog`
- `mode='statement_only'` 與 `preselect-new` 都改用簡化版 `PreselectSummary`
  （勾選清單 + AO 預覽 + 一鍵套用），只有 SSP `mode='full'` 路徑保留舊版
  diff/drag-drop UI（per-control 衝突解決有實質意義時）
- `framework` 自動從 context（project / module_frame）推導，dialog 內不再
  顯示 framework 下拉
- 預覽畫面把 unmatched 段落分兩類：「疑似漏掉的控制項」（顯示）vs
  「敘述性內容」（只計數），避免雜訊干擾使用者
- `dialog_title` 不再顯示 SSP 字樣（內部 i18n key 不變）

---

## 3. 上版步驟

### Step 1 — 跑 DB migration（依序）

```bash
psql -h <DB_HOST> -p 25432 -U cmmgr -d <DB_NAME> -f scripts/sql/ssp_docx_parse_jobs_migration.sql
psql -h <DB_HOST> -p 25432 -U cmmgr -d <DB_NAME> -f scripts/sql/ssp_docx_parse_jobs_source_uid_nullable.sql
psql -h <DB_HOST> -p 25432 -U cmmgr -d <DB_NAME> -f scripts/sql/ssp_docx_parse_jobs_add_org_unit_id.sql
psql -h <DB_HOST> -p 25432 -U cmmgr -d <DB_NAME> -f scripts/sql/2026-05-01-fix-ssp-docx-parse-jobs-rls.sql
```

> ⚠️ **必用 `cmmgr`**，不要用 `cm_app`（被 RLS 擋住會 INSERT 0 rows 靜默失敗）

驗證 table 跟 RLS policy：

```sql
\d compliance.ssp_docx_parse_jobs
SELECT polname, polcmd FROM pg_policy
WHERE polrelid = 'compliance.ssp_docx_parse_jobs'::regclass;
-- 應看到 4 條 policy（select / insert / update / delete）
```

### Step 2 — Backend 部署

- Pull `feat/ssp-doc-parser` → merge / deploy
- **必跑 `poetry install`**：本次新增兩個 PyPI 公開套件（commit `ee14231`）
  - `python-docx (>=1.2.0,<2.0.0)` — 讀 .docx 結構 + `w:numPr` XML 偵測
  - `rapidfuzz (>=3.14.5,<4.0.0)` — 控制項名稱 fuzzy 比對（cross-version、typo 容錯）
  - 沒有 jedi-* 內部套件變動需要進版發佈；`jedi-oscal` 從 0.0.13 → 0.0.14
    是 SSP versioning 順帶升的（commit `2de7359`），跟本 feature 無關但同 branch
- **必須重啟 BE service**（parser 邏輯、DI container、ENUM 處理都改了）
- 重啟用 `lsof -i :8000` 找實際 listener 後 `kill -9`，nohup 留下 orphan PID
  容易撞 port，請勿只用 `pgrep python`

```bash
lsof -i :8000  # 找出 PID
kill -9 <PID>
# 重新啟動
set -a; source .env; set +a; nohup python main_socketio.py > log/app.log 2>&1 &
```

### Step 3 — Frontend 部署

- 標準 Vue3 build 流程 (`npm run build` → `dist/` deploy)
- 不需要清 user 端 cache（i18n 改動會自動帶 hash）

### Step 4 — 上版後 smoke test

照下列順序跑一遍三條入口（建議用 `ASIA-CMMC-SSP-DRAFT-202604.docx` 或同類
真實 docx，不要用 dummy）：

#### 入口 A：新增合規資源庫
1. 「合規資源庫管理」→「新增」
2. Step 1 選 framework + version (e.g. `CMMC Level 1`)
3. 點「從 docx 預選控制項」→ 上傳 docx
4. 預覽畫面看到 matched 控制項 + AO 數 + orphan/noise 摘要 → 套用
5. Step 2 看到 Tree 自動勾選預選結果（不是全選也不是空）
6. Step 3 確認 → 建立成功
7. 進入「編輯現況與程序書」→ 任意 control → 應看到 docx 帶入的 markdown
   numbered list + 實施狀態為「已實作」
8. 展開 AO → 應看到對應 letter 的 clause 內容

#### 入口 B：編輯既有 module_frame
1. 「合規資源庫管理」→ 任一已有資源庫 → 「編輯現況與程序書」
2. 「批次維護」→「從 docx 匯入」
3. dialog 內**不應**看到 framework 下拉（自動帶入）
4. 上傳 docx → 預覽勾選 → 確認匯入
5. dialog 自動關閉，頁面 reload，control / AO 內容顯示已更新

#### 入口 C：專案規劃
1. 進入任一 in-progress 專案的「專案規劃」頁
2. 「批次維護」→「從 docx 匯入」
3. 同樣 framework 自動帶入、預覽勾選、確認匯入
4. SSP 控制項現況已寫入

### Step 5 — 異常處理 sanity

- 上傳非 .docx → 提示「請上傳 .docx 檔案」
- 上傳 framework 不符的 docx（如 CMMC L1 baseline 但 docx 是 ISO 27001）→
  提示「docx 主要控制項 ID 模式與選擇的 framework 不符」
- 預覽期間關閉 dialog（按 X）→ 後端 parse_job 應被 discard，DB 內
  `compliance.ssp_docx_parse_jobs` 對應 row 消失

---

## 4. 已知限制

| 限制 | 說明 | follow-up |
|------|------|-----------|
| CMMC 1.0 → 2.0 部分映射 | docx 用 1.0 ID merged 標題（如 `b.1.ix Manage Visitors & Physical Access`），catalog 拆成 2.0 多條（3.10.3/4/5）→ 一條 docx 段落只能配一個 control | 使用者匯入後手動補 |
| 名稱差異控制項 | docx 名「System & File Scanning」vs catalog 名「Periodic Scans」→ fuzzy 比不到 | 落到 unmatched，使用者進編輯頁手動填 |
| Word 手打編號 | 若作者沒用 Word 自動編號功能，而是手打 `1.` `2.` 在文字內，parser 抓不到 | 罕見場景，未支援；可日後在 `_is_list_item` 加 fallback regex |
| Catalog typo 仍存在 | DB 內 `SI.L1-3.14.2` 名稱拼成 `Malicious Code Proection`（少 t）| parser 已能容錯命中，但 catalog 資料應修正 |

---

## 5. Rollback 策略

若上版發生重大問題：

1. **FE rollback** — 直接 deploy 前一版 build，使用者立刻看不到三個入口
2. **BE rollback** — `git revert` last 兩個 commit (`332a256`, `46db6d8`)
   或 checkout 前一版 release tag，重啟 service
3. **DB rollback** — `compliance.ssp_docx_parse_jobs` table 是新建的，留著
   不影響其他模組；若需清乾淨：
   ```sql
   DROP TABLE compliance.ssp_docx_parse_jobs CASCADE;
   ```
   `module_frame_control_defaults.implementation_status` 欄位是既有的，
   docx 寫入會把 status 從 `'unknown'` 改 `'implemented'` — 若需還原，
   依 docx 匯入時間範圍 reset：
   ```sql
   -- 只示意，請依實際匯入時間調整
   UPDATE compliance.module_frame_control_defaults
   SET implementation_status = 'unknown'
   WHERE updated_at >= '2026-05-01' AND implementation_status = 'implemented';
   ```

---

## 6. 主要 commit / changelog 索引

### BE branch commits
```
332a256 feat(ssp-docx): real-world parser hardening + edit-mode catalog candidates + 現況 status default
46db6d8 feat(ssp-docx): preselect-new path loads catalog AO keys → AO defaults imported
3f2176e docs(changelog): SSP docx 4 critical fixes + e2e + follow-ups
e817be7 test(ssp-docx): e2e — 新增合規資源庫 + docx 匯入完整流程
c6e63a1 fix(ssp-docx): 4 critical issues — RLS / ENUM / matched current_* / cmmc-l1 2.0 ID regex
3e54d20 feat(ssp-docx): preselect-new loads catalog candidates via oscal_framework_version_uid
9872e47 feat(ssp-docx): _load_candidates loads SSP impls + catalog titles
fe5be86 feat(ssp-docx): support preselect mode (nullable source_uid + confirm payload override)
b689258 feat(ssp-docx): add Marshmallow serializers for SSP docx import API
86a7967 feat(ssp-docx): SspDocxImportAppService — orchestrate parse/preview/confirm/discard
9a43dc7 feat(ssp-docx): SspWriteStrategy — write SSP impl + objectives + info_system metadata
02be8f3 feat(ssp-docx): ModuleFrameWriteStrategy — write module_frame defaults + objectives
29dd24d feat(ssp-docx): IWriteStrategy interface + ImportDecisions/ImportResult DTO
d23c62f feat(ssp-docx): add ssp_docx_parse_jobs migration
（共 40+ commits，完整列表見 git log）
```

### FE branch commits
```
1f66640 tweak(ssp-docx): unify entry points + simplified preview + AO binding fixes
a63a1bb feat(ssp-docx): preselect summary — expand-all / collapse-all toolbar
6f41622 feat(ssp-docx): preselect-only mode uses simplified PreselectSummary view
4df300f feat(ssp-docx): forward oscal_framework_version_uid to BE
038a47a feat(ssp-docx): preselect mode for new module_frame creation (chain confirm)
5128f7c feat(ssp-docx): Phase I — i18n strings + 3 entry points integration
3327328 feat(ssp-docx): Phase H — drag-drop + conflict modal
0f6928b feat(ssp-docx): Phase G — wizard step 0/1/2 + diff/banner/toolbar
b27e333 feat(ssp-docx): Phase F — API constants + service + Pinia store + dialog shell
（共 15+ commits）
```

### 重要 changelog
- `docs/features/FR-022-2604-ssp-doc-parser/spec.md` — 完整需求規格
- `docs/features/FR-022-2604-ssp-doc-parser/implementation-plan.md` — 階段切分 plan
- `docs/changelog/2026-04-30-fix-ssp-docx-parser-real-world-numbering-and-status.md`
- `docs/changelog/2026-04-30-fix-ssp-docx-edit-mode-candidates-from-catalog.md`
- `docs/changelog/2026-04-30-tweak-ssp-docx-clause-level-ao-split.md`
- FE `docs/changelog/2026-04-30-tweak-template-edit-use-simplified-preview.md`
- FE `docs/changelog/2026-04-30-fix-ao-binding-key-and-objectives-i18n.md`

---

## 7. 上版風險評估

| 風險 | 機率 | 影響 | 對策 |
|------|------|------|------|
| RLS migration 漏跑 | 低 | 高（INSERT 全失敗、看似系統當）| Step 1 RLS policy 驗證 SQL；上版 SOP 加 checklist |
| BE service 沒重啟 | 中 | 高（新 parser 邏輯沒 load）| 用 `lsof -i :8000` 看 PID 確認 |
| 既有 module_frame default 被 docx 覆蓋 | 低 | 中（使用者重要的客製化現況遺失）| baseline scope guard 已限制不寫 baseline 外的 row；對已存在 row 一律 upsert（會覆蓋）— 上版前可選擇先 dump `module_frame_control_defaults` |
| 既有 SSP 的 implementation_status 被改 | 低 | 中 | 只在 docx 匯入時觸發，不會自動跑；使用者**主動**操作才會改 |
| catalog typo 配錯到別的 control | 低 | 中 | 已用 `ratio(cleaned, name)` 而非 `partial_ratio` 避開 superset 配對；smoke test SI.L1-3.14.2 要確認 |

---

## 8. 後續建議（非阻擋本次上版）

1. Catalog 資料修正：`SI.L1-3.14.2 Malicious Code Proection` typo 修成
   `Protection`（DB 既有資料髒污，影響其他模組顯示）
2. CMMC 1.0 ↔ 2.0 ID mapping table 建立（避免每次仰賴 fuzzy 名稱比對）
3. SSP `mode='full'` 編輯流程是否也改用 PreselectSummary（目前還是舊 diff
   UI，使用者反饋後再評估）
4. Word 手打編號 fallback regex（罕見但有真實案例）

---

## 9. 上線後迭代（2026-05-03）

v2 上線後跟 user 跑 smoke 一連串收 feedback + 修補 + UI 重設計。本節**累積**
所有 post-launch 改動、設計決策變更、後續方向，跟原 design-v2.md 一併閱讀。

### 9.1 Smoke 修補（資料 / 流程 / DI bug）

| 主題 | 問題 | 修法 | Commit |
|------|------|------|--------|
| MinIO 存儲 parser 找不到檔案 | docx 存 MinIO bucket，parser 用 local path 讀失敗 | tempfile 兩線：parser 拿 NamedTemporaryFile、upload 進 MinIO 拿 file_uid 給 FE iframe | 34109d2 |
| Adapter registry DI 沒 resolve | `adapters={"cmmc-l1": cmmc_ssp_adapter}` 是普通 dict，provider 不會 auto-resolve → registry.get() 回 Singleton 物件 → `.adapt()` AttributeError | 改用 `providers.Dict({...})` 包裝 | a09c740 |
| Information-provider 應為 person 不是 org | docx 上游廠商欄位實際填的是聯絡人 (姓名+Title+Email)，當 organization 永遠 reconcile 不到 org_unit | `_ORGANIZATION_ROLES = {"responsible-organization"}` 只剩這個是 org | 012e46a |
| 編輯頁全空（profile_controls = 0） | FE 送 `include_controls: ["AC.L1-3.1.1", ...]` (control_id) 但 `add_profile` 預期 catalog UID list → 全 miss → 0 個 profile_controls 寫入 | baseline_controls 加 `catalog_control_uid` 欄位、FE 改送 UID | b898879 |
| 跨 catalog UID 撈錯 | 第一版 lookup 用 `CatalogControlQueryEntity(catalog_id=...)` 但 OscalCatalogControl 沒 catalog_id column → 靜默忽略 → 全表掃 → dict 覆蓋拿到別 framework 的 UID | candidate 直接帶 catalog_control_uid，沿 groups path 走對的 catalog | 6671a25 |
| Confirm 後 412 log 汙染 | router.push 觸發 onBeforeUnmount 對已 completed 的 parse_job DELETE → BE 噴 412 | confirm 成功後 parseUid.value=null + parsedResult.value=null 阻斷 | 07d5864 (FE) |

### 9.2 UI / UX 重設計（多輪迭代）

**第 0 輪 — v2 上線初版**：iframe + 右側 stacked Fieldset (基本資料 fieldset 永遠展開) + 標題列三顆按鈕。

**第 1 輪 — 嘗試三欄 outline layout**：iframe (30%) / Outline (18%) / content (55%) + scroll-spy + 響應式。User 直接 reject 「好醜」。

**最終 — Step wizard 兩階段**（commit `2faaa0b`，採 user 提的方向）：
- **Step 1 上傳**：置中卡片 (Card)，內含 framework cascaded picker + dropzone，極簡。Parse loading 顯示 ProgressSpinner。
- **Step 2 預覽與確認**：iframe 左 (40%) + TabView 右 (60%)，三 tab：
  - **基本資料**（create mode 才有，第一個 tab）— 垂直單欄 form (max-width 600px 置中)，必填 * 紅色，header 帶 ⚠ icon 當必填空
  - **控制項 (15/17)** — header 帶實時進度
  - **參與人員 (5)** — header 帶人數
- 確認按鈕 disabled until basicInfoComplete；submit 撞到必填空切回基本資料 tab + toast 缺漏欄位
- 砍 `ImportOutline.vue` / scroll-spy / 響應式邏輯 / 原 stacked Fieldset

### 9.3 控制項挑選 / 編輯（commits `12882d0`、`3e75c35`、其他）

- **per-control checkbox**：每個 matched control 卡片標題列加 Checkbox，未勾 → opacity-60 + confirm `action='skip'`
- **per-AO checkbox**：每個 AO row Checkbox，可單獨 skip 該 AO
- **inline edit**：主述 / 每個 AO 旁邊 ✎ 按鈕 → 切 Textarea + 顯式「儲存 / 取消」buttons → 寫入 `draft.controlOverrides` (localStorage)
- **未匹配控制項可勾選**：missing 段每筆加 Checkbox，**預設不勾**（docx 沒提的由 user 主動加 baseline）
- BE confirm payload 多 `content_overrides` 欄位，`_dict_to_parsed_docx` 後 mutate `parsed.matched_controls` 再交 strategy

### 9.4 主述 / AO 內容渲染（commits `cc7ff51` / `0a9d399` / `b63b1aa`）

- BE parser 已輸出 `1./2./...\n\n` markdown-ish 字串，FE 改 `marked` + `v-html` 渲染（編號 list / 段落間距正常顯示）
- AO 卡片改兩列：
  - 上：catalog 來的「題目」(BE 加 `catalog_objective_descriptions` 欄位專門帶這個)
  - 下：docx 解析內容（縮排 + 次要色，無 prefix 無 → 箭頭，user 反饋過於 verbose）
- bold / italic 在 python-docx 那層已 strip，要保留得改 BE 走 runs (follow-up)
- URL / `[text](url)` 不渲染為連結（marked link renderer 回 plain text）

### 9.5 Parties reconcile + UX（commits `e99ef49`、`39afeda`、`793d6df`）

- **parse-time reconcile**：之前只 confirm 才 reconcile，FE 預覽永遠看到「未連結」。改成 `adapter.adapt` 後立刻跑一次，email match 的 person 直接填 matched_user_id
- **取消連結 / 重新連結 button**：已連結時旁邊多兩顆按鈕，未連結時只有「連結帳號」
- **連結對話框 dynamic header**：organization → 「連結到組織單位」、person → 「連結到系統使用者」（之前混在一起叫「系統帳號 / 部門」看不出走 user 還是 org_unit）

### 9.6 模板編輯頁編輯入口大整併（commits `b093592`、`f40ffba`）

User 反饋：「合規資源庫的編輯 Dialog 是不是也可以省略, 全部統一到編輯頁處理」。
採方案 C：

**列表頁簡化**
- v1 編輯 Dialog 廢棄
- 列表 row「編輯」按鈕 → `router.push` 到 `/module-frame/<uid>/template-edit`
- 列表頁「新增」 dialog **簡化成單頁**：砍 Step 3 review、Step 1+2 合併（基本資料 + framework + 折疊式 control tree picker），watch on selectedVersionLevels 自動 fetch catalog

**編輯頁集中所有編輯入口**
標題列只剩兩顆按鈕（之前 4 顆）：

| 按鈕 | 內容 |
|------|------|
| **✎ 編輯** menu | 4 個 menuitem：編輯基本資料 / 編輯適用控制項 / 參與單位 / 人員 / 程序書池 |
| **🗄 批次維護** menu | 維持原樣（匯出 / 匯入 / 從 docx 匯入） |

**新元件 / endpoints：**
- `EditIncludeControlsDialog.vue`（FE）— Tree picker，預勾現有 baseline，diff +/-，移除有 defaults 的 control 跳警告。動態高度 (loading 短、tree 載入後長)，save overlay 顯示 spinner。
- `ModuleFramePartiesDialog.vue`（FE）— GET / POST / PUT / DELETE 串接 module_frame 的 parties，含 link / unlink / 編輯 / 刪除。
- `ModuleFramePartyService` + 4 個 BE endpoints（commit `59963a0`）：`GET / POST / PUT / DELETE /module-frame/<uid>/parties[/<party_uid>]`。DI 放在 oscal_container（避免 module_frame ↔ oscal 雙向 override 撞 deepcopy 遞迴）。

### 9.7 進度 hover 顯示未設定（commit `07d5864`）

編輯頁上方「控制項預設值 100% (15/15)」「評估目標 89% (50/56)」mini-stat 加：
- hover tooltip：「點擊查看 N 個未設定」
- click → OverlayPanel 列出未設定的 control_id / AO key + name
- 點 overlay 內 control → 自動展開 group + 選中 control（scrollIntoView）

### 9.8 對 Parties「未連結」的處理共識

User 跟 FE 確認：unmatched parties **照常寫入 `oscal_parties`**（含 name / title / email / phone / address 等純文字資料），`user_id` / `org_unit_id` 對不到時 NULL，但其他欄位寫入。後續處理：
- User 在編輯頁 PartiesDialog 手動補連結
- 重 import 時 BE parse-time reconcile 再跑（user 表新增的 email 自動補上）
- DB 直接撈純文字資料供 CSV / 報表參考

不會 block confirm，也不會建空殼 user。Confirm 前若有 unmatched 跳警告（待實作）。

### 9.9 流程模式對照（更新版）

| 模式 | 入口 | target_uid | 寫入位置 |
|------|------|------------|----------|
| `create` | 合規資源庫管理 → 「新增」 → 「從 docx 建立」 | 無（FE pre-create module_frame after confirm） | module_frame_control_defaults + objective_defaults + oscal_parties (context_type='module_frame') |
| `update-mf` | 編輯現況與程序書 → 批次維護 → 從 docx 匯入 | module_frame.uid | 同上，但 module_frame 已存在 |
| `update-ssp` | 專案規劃 → 批次維護 → 從 docx 匯入 | ssp.uid | SSP 的 control_implementation + objectives |

`module_frame` 是模板（template，clone 給專案用），`SSP` 是專案實例（一個專案 + 一輪 AP = 一個 SSP）。

### 9.10 後續 follow-up（本次未做）

- BE parser 走 paragraph runs 保留 bold / italic（目前在 python-docx text 那層已 strip）
- jedi-oscal `BaseRepositoryImpl` 對不存在的 filter 欄位應 raise 而不是靜默忽略（防止 9.1 第 5 行那種 bug 再發生）
- Confirm 前 unmatched parties 跳警告 dialog
- Update-SSP 模式下 `existing-SSP` candidate 載入路徑也補 `catalog_objective_descriptions`（目前只 create / update-mf 有）
- LeveragedSection.vue / MetadataSection.vue / UnmatchedSection.vue 已停用，待清理
- Server-side libreoffice 缺 CJK 字型導致 PDF 預覽中文字醜（ops 層問題，需安裝 fonts-noto-cjk）
