mathruffian-dot/teaching-exam-skills · Archived

jh-math-context-questions

國中數學生活? 要結合時事新聞出數學題、或想要有解析的非選題時,? 觸發? 「出有? 本技能會上網搜尋與指定數學單? 應用/分析層次的? 數學方程式以 OMML 格式正確呈現(分數、根號、上標、絕對值等)。

First seen Jun 5, 2026

Installation

$ npx skills add mathruffian-dot/teaching-exam-skills --skill jh-math-context-questions

Summary

國中數學生活情境非選擇題命題技能。當使用者要出非選擇題、要出情境題、要出生活化數學題、 要結合時事新聞出數學題、或想要有解析的非選題時,必須使用此技能。 觸發情境包含:「幫我出非選題」、「出五大題非選」、「出情境題」、「結合生活情境出題」、…

Stronger alternatives

This repository is archived — consider an actively maintained alternative.

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from mathruffian-dot/teaching-exam-skills.

npx skills add mathruffian-dot/teaching-exam-skills

Browse all from mathruffian-dot/teaching-exam-skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 2
License LICENSE
Default branch master
Open issues 0
Status Archived

Skill metadata

Parsed from SKILL.md frontmatter.

Declared agents claude-code

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 29,601 B
  • docs SUMMARY.md 838 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 1 installs

SKILL.md

國中數學生活情境非選擇題命題技能

技能概覽

本技能依使用者指定的國中數學單元,上網搜尋相關生活情境與時事新聞, 將真實素材轉化為非選擇題,並匯出格式正確的 Word 文件。

  • 五大題,每大題含兩小題
  • 第(1)小題:閱讀素材,理解條件即可作答(Bloom 第2級:理解)
  • 第(2)小題:承接第(1)題,結合數學單元,達到應用或分析層次(Bloom 第3-4級)
  • 每大題皆含完整解析
  • 幾何圖形支援:若題目涉及幾何情境,自動呼叫 jh-math-geometry 技能的渲染腳本產生 PNG 並插入 Word
  • 匯出 Word 文件,數學式以 OMML 正確格式呈現

數學標記語法(題目撰寫規則)

在題目文字中,用大括號 {} 包住數學式,系統會自動轉為 Word 正確格式:

標記語法 說明
{x^2} x 的平方(上標)
{x^{n+1}} 上標含運算式
{x_1} x 下標 1
{sqrt(2)} 根號 2
{sqrt[3](8)} 三次方根 8
{frac(3,4)} 分數 3/4
{(a+b)/2} 含運算式的分數
{ x-3 } 絕對值
{<=} 小於等於符號
{>=} 大於等於符號
{!=} 不等於符號

規則:分數一律 {frac(分子,分母)}、次方一律 {x^2}、根號一律 {sqrt(x)}、不等號一律 {<=} {>=} {!=},不直接貼 ²、√ 等字元;簡單方程式(如 2x + 3 = 7)不需加 {}


環境約定(所有 Step 共用)

本技能不假設任何特定沙箱或工具名稱,路徑一律依下列約定:

名稱 意義
本技能目錄$SKILL_DIR 這份 SKILL.md 所在的資料夾
output/ 當前工作目錄下的產出資料夾:中間檔(exam_data.json、幾何圖)與最終 Word 都放這裡
$GEOM_DIR jh-math-geometry 技能的 scripts/:同 repo 的 skills/jh-math-geometry/scripts/,或已安裝的 jh-math-geometry 技能
SKILL_DIR="<本技能目錄的絕對路徑>"        # 例:~/.claude/skills/jh-math-context-questions
mkdir -p output

# 幾何腳本位置:同 repo 的 skills/jh-math-geometry/,或已安裝的 jh-math-geometry 技能
GEOM_DIR=""
for d in "$SKILL_DIR/../jh-math-geometry/scripts" "$HOME/.claude/skills/jh-math-geometry/scripts"; do
  [ -f "$d/geometry_renderer.py" ] && GEOM_DIR="$d" && break
done
echo "幾何腳本目錄:${GEOM_DIR:-(找不到,幾何題將無法配圖)}"

# draw 生圖技能(選配):找不到就跳過 Step 5.7/6.6 的情境插圖,不影響出題與匯出
DRAW="$HOME/.claude/skills/draw/draw.py"
[ -f "$DRAW" ] || echo "ℹ️ 無 draw 技能,將跳過情境插圖"

python3 -m pip install -q python-docx lxml cairosvg pillow   # 首次使用安裝一次;Linux 系統 Python 拒絕時加 --break-system-packages

- Windows 若沒有 python3 指令,把下文的 python3 換成 python;Bash 片段請在 Git Bash/WSL 執行。
- 文中的「視覺確認」一律指讀取檔案(用你的檔案讀取工具開啟 .svg.png),不依賴任何特定工具。
- 產出留在 output/,完成後在對話中列出檔案路徑即可,不需再複製到別處。


處理流程

Step 1:確認需求

向使用者確認(未提供則詢問):數學單元、年級(七/八/九年級)、版本(選填)


Step 2:網路搜尋生活情境

使用可用的網路搜尋工具,至少進行 3–5 次搜尋,找 5 種不同主題素材:

  • 購物/消費、環境/氣候、交通/運動、健康/醫療、科技/財經

Step 3:設計題目

每大題結構:素材段落 + 第(1)小題(理解層次)+ 第(2)小題(應用/分析層次)。 數學式必須使用 {} 標記。兩小題需有連貫性,第(2)題用到第(1)題答案。


Step 4:撰寫解析

每大題含:(1)解題關鍵+計算過程、(2)解題過程+答案+Bloom層次標註。


Step 4.5:數學自我驗算(必做,生圖與匯出前)

⚠️ 數學題目在產出前必須先跑 Python 實算。AI 常有「解題描述對、但最後數值算錯」的問題。驗算不通過就修正,再繼續。

對每題 (1)/(2) 小題:

  1. question / source 讀出已知條件
  2. 用 Python 實算(下表速查)
  3. 比對 answer / solution 是否相符
  4. 有差異 → 修正 JSON(優先改 solution/answer)後重跑

常見題型驗算速查

題型 驗算方式
比例式(a:b = c:d) ad == bc
總量按比例分配 總量 × 各份數 / 總份數
時間/速度/距離 t = d/v、多段合計
畢氏定理 c² = a² + b²
餘弦定理 c² = a² + b² − 2ab·cos(C)
圓周角定理 圓周角 = 圓心角 / 2
弧長/扇形面積 2πr × (θ/360)πr² × (θ/360)
三角形全等 SAS/SSS/ASA 對應邊/角相等
預算/造價 數量 × 單價 vs 預算

執行範本

# output/verify_math.py
import json, math
from pathlib import Path

exam = json.loads(Path('output/exam_data.json').read_text(encoding='utf-8'))
issues = []

for q in exam['questions']:
    n = q['number']
    # 對本份題目逐題實算(見速查表)
    # 範例:餘弦定理
    # ac_sq = 12**2 + 8**2 - 2*12*8*math.cos(math.radians(60))
    # if abs(ac_sq - 112) > 0.1: issues.append(f"q{n} AC² 算錯")
    pass

if issues:
    print("[ABORT] 驗算失敗:", issues)
    raise SystemExit(1)
print(f"[OK] 驗算通過 {len(exam['questions'])} 題")

實作紀律:驗算不通過 → 不准進 Step 5。


Step 5:整理為 JSON 資料

將題目整理為以下格式,儲存為 output/exam_data.json

{
  "title": "國中數學非選擇題",
  "unit": "單元名稱",
  "grade": "年級",
  "questions": [
    {
      "number": 1,
      "total_points": 10,
      "source": "素材文字,可含 {math} 標記",
      "geometry": null,
      "sub1": {
        "points": 4,
        "question": "第(1)題題目,可含 {math} 標記",
        "answer": "答案說明",
        "solution": "解題過程",
        "geometry": null
      },
      "sub2": {
        "points": 6,
        "question": "第(2)題題目,可含 {math} 標記",
        "answer": "答案說明",
        "solution": "解題過程",
        "bloom_level": "第3級(應用)",
        "bloom_reason": "判定理由",
        "geometry": null
      }
    }
  ]
}

▶ illustration 欄位說明(情境氛圍圖,由 draw skill 生成)

illustration 讓題目能嵌入一張「生活情境氛圍圖」,補足精確幾何圖所沒有的代入感。可與 geometry 並存

何時加 illustration:

  • 情境題本就該有情境感(手搖飲店、海岸跑道、科技教室、蓮花池)
  • geometry 是精確幾何(冷靜)、illustration 是情境氛圍(溫暖)
  • 封面、行動頁式大題特別適合

illustration 欄位格式(放在題目層級或 sub1/sub2):

"illustration": {
  "id": "q1_illus",
  "prompt": "一個溫馨的手搖飲店櫃檯,一杯檸檬紅茶,旁邊有一包糖與一瓶水,扁平向量插畫,柔和色調,無任何文字",
  "size": "1024x1024",
  "quality": "low",
  "width_cm": 5.5
}

欄位說明

  • id:唯一識別,Word 插入時用
  • prompt:送給 draw skill 的完整提示詞(結尾加「無任何文字」避免 AI 亂寫字
  • size1024x1024(方)/1024x1536(直)/1536x1024(橫)
  • quality:預設 low(NT$0.3),關鍵頁才升 medium
  • width_cm:插入 Word 時的寬度(公分),預設 5.5

生圖由 Step 5.7 自動執行(呼叫「環境約定」的 $DRAW);若無 draw 技能則跳過配圖,其餘流程不受影響。

▶ geometry 欄位說明

geometry 可放在大題(source 旁,全題共用一圖)或子題(sub1/sub2,各自有圖)。不需要圖形時設為 null

何時需要填 geometry:

  • 題目文字含「如圖」、「右圖」、「下圖」
  • 情境涉及幾何形狀(土地面積、建築結構、路線距離等)
  • 需要圖形輔助讀題理解

geometry 欄位格式:

"geometry": {
  "caption": "圖一",
  "spec": {
    "type": "triangle",
    "config": {
      "subtype": "right",
      "vertex_labels": ["A", "B", "C"],
      "right_angle_at": "C",
      "side_labels": {"AB": "5", "BC": "3", "CA": "4"}
    },
    "canvas": {"width": 260, "height": 210}
  }
}

常用範例見本 SKILL.md 末尾「幾何圖形 spec 速查」;完整參數見 jh-math-geometry 技能的 references/figure-catalog.md(同 repo skills/jh-math-geometry/references/figure-catalog.md


Step 5.5:幾何圖形渲染(若 JSON 中有 geometry 欄位)

判斷是否需要執行:掃描 exam_data.json,若任何題目或子題的 geometry 欄位不為 null,則執行本步驟。

# 前提:已依「環境約定」設定 $GEOM_DIR

# ── 1. 從 exam_data.json 提取所有 geometry spec ─────────────
python3 - <<'PYEOF'
import json

exam = json.load(open('output/exam_data.json', encoding='utf-8'))
figures = []

def collect(geo, prefix):
    if geo and geo.get('spec'):
        spec = dict(geo['spec'])
        spec['id'] = prefix
        spec.setdefault('canvas', {'width': 260, 'height': 210})
        figures.append({
            'id': spec['id'],
            'type': spec['type'],
            'config': spec.get('config', {}),
            'canvas': spec['canvas'],
            '_caption': geo.get('caption', ''),
            '_key': prefix
        })

for q in exam.get('questions', []):
    n = q['number']
    collect(q.get('geometry'), f"q{n}_main")
    collect(q.get('sub1', {}).get('geometry'), f"q{n}_sub1")
    collect(q.get('sub2', {}).get('geometry'), f"q{n}_sub2")

if figures:
    spec_data = {
        'figures': [{'id': f['id'], 'type': f['type'], 'config': f['config'], 'canvas': f['canvas']} for f in figures],
        'options': {'format': 'png', 'dpi': 150}
    }
    json.dump(spec_data, open('output/geometry_spec.json', 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
    mapping = {f['id']: {'caption': f['_caption'], 'key': f['_key']} for f in figures}
    json.dump(mapping, open('output/geometry_mapping.json', 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
    print(f"✅ 需要渲染 {len(figures)} 張幾何圖形")
else:
    print("ℹ️ 本份題目無幾何圖形,跳過渲染")
PYEOF

# ── 2. 渲染 ────────────────────────────────────────────────
if [ -f output/geometry_spec.json ]; then
  mkdir -p output/geometry_output
  python3 "$GEOM_DIR/geometry_renderer.py" \
      output/geometry_spec.json \
      output/geometry_output/
  echo "✅ 幾何圖形渲染完成:"
  ls output/geometry_output/*.png 2>/dev/null
fi

視覺確認:讀取 output/geometry_output/*.svg(或 .png)確認圖形正確再繼續。若有誤,修改對應題目的 geometry.spec.config 後重跑。


Step 5.7:生情境插圖(若 JSON 中有 illustration 欄位)

判斷是否需要執行:掃描 exam_data.json,若任何題目或子題的 illustration 欄位不為 null,則執行本步驟。

# 前提:已依「環境約定」設定 $DRAW;找不到 draw.py 就整段跳過
if [ ! -f "$DRAW" ]; then
  echo "ℹ️ 無 draw 技能,跳過情境插圖"
else
mkdir -p output/illustrations
DRAW="$DRAW" python3 - <<'PYEOF'
import json, os, subprocess, sys

exam = json.load(open('output/exam_data.json', encoding='utf-8'))
jobs = [b['illustration'] for q in exam.get('questions', [])
        for b in (q, q.get('sub1'), q.get('sub2')) if b and b.get('illustration')]

print(f"→ 共 {len(jobs)} 張情境圖要生")
for job in jobs:
    cmd = [sys.executable, os.environ['DRAW'], job['prompt'],
           '--size', job.get('size', '1024x1024'),
           '--quality', job.get('quality', 'low'),
           '--name', job['id'],
           '--outdir', 'output/illustrations/']
    result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='replace')
    print(f"  [draw] {job['id']}  rc={result.returncode}")
PYEOF
ls output/illustrations/*.png 2>/dev/null
fi

視覺確認:生完後讀取 output/illustrations/*.png 檢查情境圖是否符合主題(教室情境、店內情境等),不合格就重生該張。


Step 6:匯出 Word 文件(必做)

本 repo 不附 generateexamdocx.py;請依下列規格用 python-docx 撰寫 output/generateexamdocx.py(工作目錄已有就直接沿用),再執行:

python3 output/generate_exam_docx.py output/exam_data.json output/exam_output.docx

腳本規格

項目 規格
介面 generateexamdocx.py <exam_data.json> <輸出.docx>
版面 A4 直式、邊界 2 cm、標楷體/Times New Roman 12pt;標題 title(置中、粗體)、副標「<grade> <unit> 滿分 <總分>」、「班級__座號__姓名___」
每大題 一段標題 第 N 題(<total_points> 分)(必須含「第N題」字樣,Step 6.5/6.6 靠它定位插圖)→ source 素材段落 → (1)(<points> 分)題目 一段 → 作答空白 → (2)(<points> 分)題目 一段 → 作答空白
解析 全部題目之後分頁,「解析」章節:每大題列 (1)(2)answersolution(依換行分段)與 bloomlevelbloomreason
數學式 所有 {…} 標記轉成 OMML(用下方 addmathtext),不可留成純文字

{} → OMML 轉換(可直接複製進腳本)

import re
from docx.oxml import OxmlElement
from docx.oxml.ns import qn

def mt(s):                               # <m:r><m:t>s</m:t></m:r>
    r = OxmlElement('m:r'); t = OxmlElement('m:t'); t.text = s; r.append(t); return r
def wrap(tag, *parts):
    e = OxmlElement(tag)
    for p in parts: e.append(p)
    return e
def frac(n, d): return wrap('m:f', wrap('m:num', mt(n)), wrap('m:den', mt(d)))
def sup(b, x):  return wrap('m:sSup', wrap('m:e', mt(b)), wrap('m:sup', mt(x)))
def sub(b, x):  return wrap('m:sSub', wrap('m:e', mt(b)), wrap('m:sub', mt(x)))
def sqrt(x, deg=None):
    pr = OxmlElement('m:radPr'); dh = OxmlElement('m:degHide'); dh.set(qn('m:val'), '0' if deg else '1'); pr.append(dh)
    return wrap('m:rad', pr, wrap('m:deg', *([mt(deg)] if deg else [])), wrap('m:e', mt(x)))

SYM = {'<=': '≤', '>=': '≥', '!=': '≠'}
def math_node(s):
    s = s.strip()
    if m := re.fullmatch(r'frac\((.+?),(.+)\)', s):          return frac(m[1], m[2])
    if m := re.fullmatch(r'sqrt(?:\[(.+?)\])?\((.+)\)', s):  return sqrt(m[2], m[1])
    if m := re.fullmatch(r'(.+?)\^\{?([^{}]+)\}?', s):        return sup(m[1], m[2])
    if m := re.fullmatch(r'(.+?)_(\w+)', s):                  return sub(m[1], m[2])
    if m := re.fullmatch(r'\((.+)\)/(.+)', s):                return frac(m[1], m[2])
    return mt(SYM.get(s, s.replace('*', '×')))

def add_math_text(paragraph, text):
    """把含 {math} 標記的字串寫入段落:純文字用 add_run,{…} 轉成 <m:oMath>,順序保持"""
    for piece in re.split(r'(\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\})', text):
        if not piece: continue
        if piece[0] == '{' and piece[-1] == '}':
            paragraph._p.append(wrap('m:oMath', math_node(piece[1:-1])))
        else:
            paragraph.add_run(piece)

腳本中所有寫入題目/選項/解答文字的地方,一律呼叫 addmathtext(paragraph, text),不要直接 add_run


Step 6.5:將幾何圖形插入 Word(若有幾何圖)

if [ ! -f output/geometry_mapping.json ]; then
  echo "ℹ️ 無幾何圖形,跳過插入步驟"
else

python3 - <<'PYEOF'
import json, re
from pathlib import Path
from docx import Document
from docx.shared import Cm, Pt
from docx.enum.text import WD_ALIGN_PARAGRAPH

mapping = json.load(open('output/geometry_mapping.json', encoding='utf-8'))
doc = Document('output/exam_output.docx')

# 建立「題號關鍵字 → 段落索引」對照
para_index = {}
for i, para in enumerate(doc.paragraphs):
    text = para.text.strip()
    # 大題標記:「第1題」「第一題」等
    m = re.search(r'第\s*([1-5一二三四五])\s*題', text)
    if m:
        raw = m.group(1)
        n = {'一':1,'二':2,'三':3,'四':4,'五':5}.get(raw, int(raw) if raw.isdigit() else None)
        if n:
            para_index.setdefault(f"q{n}_main", i)
    # 子題標記
    for sub_label, sub_key_suffix in [('(1)','sub1'),('(1)','sub1'),('(2)','sub2'),('(2)','sub2')]:
        if sub_label in text:
            for back in range(1, 10):
                if i - back < 0:
                    break
                prev = doc.paragraphs[i - back].text.strip()
                m2 = re.search(r'第\s*([1-5一二三四五])\s*題', prev)
                if m2:
                    raw2 = m2.group(1)
                    n2 = {'一':1,'二':2,'三':3,'四':4,'五':5}.get(raw2, int(raw2) if raw2.isdigit() else None)
                    if n2:
                        para_index.setdefault(f"q{n2}_{sub_key_suffix}", i)
                    break

# 從後往前插入
insertions = []
for fig_id, info in mapping.items():
    png = Path(f"output/geometry_output/{fig_id}.png")
    if not png.exists():
        print(f"⚠️ 找不到圖形:{png}")
        continue
    key = info['key']
    caption = info.get('caption', '')
    idx = para_index.get(key, len(doc.paragraphs) - 1)
    insertions.append((idx, png, caption))

insertions.sort(key=lambda x: -x[0])

for para_idx, png, caption in insertions:
    img_para = doc.add_paragraph()
    img_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
    img_para.add_run().add_picture(str(png), width=Cm(6.0))
    if caption:
        cap_para = doc.add_paragraph(caption)
        cap_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
        for r in cap_para.runs:
            r.font.size = Pt(9)
            r.font.italic = True
    target_el = doc.paragraphs[para_idx]._element
    if caption:
        target_el.addnext(cap_para._element)
    target_el.addnext(img_para._element)
    print(f"✅ 插入 {png.name} → 段落 {para_idx}")

doc.save('output/exam_output.docx')
print("✅ 幾何圖形插入完成")
PYEOF

fi

Step 6.6:將情境插圖插入 Word(若有 illustration)

if [ ! -d output/illustrations ] || [ -z "$(ls -A output/illustrations/*.png 2>/dev/null)" ]; then
  echo "ℹ️ 無情境插圖,跳過"
else

python3 - <<'PYEOF'
import json, re, glob
from pathlib import Path
from docx import Document
from docx.shared import Cm, Pt
from docx.enum.text import WD_ALIGN_PARAGRAPH

exam = json.load(open('output/exam_data.json', encoding='utf-8'))
doc = Document('output/exam_output.docx')

# 建立「題號 → 段落索引」對照
para_idx = {}
for i, p in enumerate(doc.paragraphs):
    m = re.search(r'第\s*([1-5一二三四五])\s*題', p.text.strip())
    if m:
        raw = m.group(1)
        n = {'一':1,'二':2,'三':3,'四':4,'五':5}.get(raw, int(raw) if raw.isdigit() else None)
        if n:
            para_idx.setdefault(f"q{n}_main", i)

# 蒐集每題 illustration
inserts = []
for q in exam['questions']:
    il = q.get('illustration')
    if not il:
        continue
    key = f"q{q['number']}_main"
    if key not in para_idx:
        continue
    cands = sorted(glob.glob(f"output/illustrations/{il['id']}_*.png"))
    if not cands:
        continue
    inserts.append((para_idx[key], cands[-1], float(il.get('width_cm', 5.5))))

# 從後往前插(避免索引變動)
inserts.sort(key=lambda x: -x[0])
for idx, png_path, width_cm in inserts:
    img_para = doc.add_paragraph()
    img_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
    img_para.add_run().add_picture(png_path, width=Cm(width_cm))
    doc.paragraphs[idx]._element.addnext(img_para._element)
    print(f"✅ 插入情境圖:{Path(png_path).name} → 段落 {idx}")

doc.save('output/exam_output.docx')
print("✅ 情境圖插入完成")
PYEOF

fi

Step 6.7:完成回報

UNIT=$(python3 -c "import json; print(json.load(open('output/exam_data.json', encoding='utf-8'))['unit'])")
cp output/exam_output.docx "output/非選擇題_${UNIT}.docx"
echo "輸出:output/非選擇題_${UNIT}.docx"

在對話中告知 Word 檔路徑(output/非選擇題_<單元>.docx)。


Step 7(選配):產出「Word 風直版 PNG」(使用者要求才跑)

使用者若說「再做一張 Word 風圖片版」、「同一份出成直版圖」、「做成 LINE 能分享的」時,執行本步驟。

特點:直版 A4 PNG(1240×1754,150dpi)、文字 100% 正確(Pillow 渲染)、情境圖 + 精確幾何圖並列、可直接發 LINE/IG/FB。

前置需求

  • illustration 已生(Step 5.7)
  • geometry 已生(Step 5.5)
  • 自我驗算已通過(Step 4.5)

執行腳本

# output/compose_word_style.py
import json, re, glob, sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont

W, H = 1240, 1754
MARGIN = 70
BLACK = (30, 30, 30); BLUE = (30, 58, 95); GOLD = (201, 152, 0); GRAY = (150, 150, 150)

# 中文字型依平台選擇(找不到請改成你系統上的中文字型路徑)
import platform
BOLD, REG = {
    "Windows": ("C:/Windows/Fonts/msjhbd.ttc", "C:/Windows/Fonts/msjh.ttc"),
    "Darwin":  ("/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/PingFang.ttc"),
}.get(platform.system(), ("/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
                          "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"))
F_TITLE = ImageFont.truetype(BOLD, 36)
F_SUB = ImageFont.truetype(REG, 20)
F_INFO = ImageFont.truetype(REG, 16)
F_QNUM = ImageFont.truetype(BOLD, 22)
F_PTS = ImageFont.truetype(BOLD, 18)
F_SRC = ImageFont.truetype(REG, 18)
F_CAP = ImageFont.truetype(REG, 12)

def clean_math(t):
    t = re.sub(r"\{frac\(([^,]+),([^)]+)\)\}", r"\1/\2", t)
    t = t.replace("{x^2}", "x²")
    return re.sub(r"\{([^{}]+)\}", r"\1", t)

def wrap(draw, text, font, max_w):
    out = []
    for para in text.split("\n"):
        line = ""
        for ch in para:
            if draw.textlength(line + ch, font=font) > max_w and line:
                out.append(line); line = ch
            else:
                line += ch
        out.append(line)
    return out

def block(draw, text, x, y, font, max_w, gap=6, color=BLACK):
    for ln in wrap(draw, text, font, max_w):
        draw.text((x, y), ln, font=font, fill=color)
        y += font.size + gap
    return y

def badge(draw, x, y, text, font, bg, fg):
    tw = draw.textlength(text, font=font); th = font.size
    w, h = int(tw + 24), int(th + 12)
    draw.rounded_rectangle((x, y, x+w, y+h), radius=8, fill=bg)
    draw.text((x+12, y+4), text, font=font, fill=fg)
    return y + h

exam = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
out = sys.argv[2] if len(sys.argv) > 2 else "output/exam_word_style.png"

img = Image.new("RGB", (W, H), "white")
d = ImageDraw.Draw(img)

# 頂部
y = MARGIN
title = exam.get("title", "國中數學非選擇題")
d.text(((W - d.textlength(title, font=F_TITLE))//2, y), title, font=F_TITLE, fill=BLACK)
y += F_TITLE.size + 8
sub = f"{exam.get('grade','')} {exam.get('unit','')} 滿分 20 分"
d.text(((W - d.textlength(sub, font=F_SUB))//2, y), sub, font=F_SUB, fill=GRAY)
y += F_SUB.size + 12
info = "班級 _______ 座號 _______ 姓名 ________________"
d.text(((W - d.textlength(info, font=F_INFO))//2, y), info, font=F_INFO, fill=BLACK)
y += F_INFO.size + 16
d.line([(MARGIN, y), (W-MARGIN, y)], fill=BLACK, width=2); y += 20

base = Path(sys.argv[1]).parent
for q in exam["questions"]:
    n = q["number"]
    y2 = badge(d, MARGIN, y, f"第 {n} 題", F_QNUM, BLUE, (255,255,255))
    # 分數
    pts = f"{q.get('total_points',10)} 分"
    pw = d.textlength(pts, font=F_PTS)
    d.rounded_rectangle((W-MARGIN-pw-24, y, W-MARGIN, y2), radius=8, fill=GOLD)
    d.text((W-MARGIN-pw-12, y+6), pts, font=F_PTS, fill=(255,255,255))
    y = y2 + 14

    # 右側圖區
    img_x = W - MARGIN - 320
    img_y = y

    # 情境圖
    cands = sorted(glob.glob(str(base / "illustrations" / f"q{n}_illus_*.png")))
    if cands:
        il = Image.open(cands[-1]).convert("RGB")
        il.thumbnail((320, 320), Image.LANCZOS)
        img.paste(il, (img_x, img_y))
        img_y += il.height + 20

    # 幾何圖
    geom_cands = [base / "geometry_output" / f"q{n}_main.png"]
    for gp in geom_cands:
        if gp.exists():
            g = Image.open(gp).convert("RGB")
            g.thumbnail((320, 320), Image.LANCZOS)
            img.paste(g, (img_x, img_y))
            img_y += g.height + 20
            break

    # 左側文字
    text_w = img_x - MARGIN - 20
    y = block(d, clean_math(q.get("source","")), MARGIN, y, F_SRC, text_w)
    y += 12
    for key, lab in [("sub1","(1)"), ("sub2","(2)")]:
        s = q.get(key, {})
        txt = f"{lab} ({s.get('points',0)}分) {clean_math(s.get('question',''))}"
        y = block(d, txt, MARGIN, y, F_SRC, text_w)
        y += 58  # 作答空白
    y = max(y, img_y) + 14
    d.line([(MARGIN, y), (W-MARGIN, y)], fill=GRAY, width=1)
    y += 20

img.save(out, "PNG")
print(f"[OK] {out}")

執行

python3 output/compose_word_style.py output/exam_data.json output/exam_word_style.png
UNIT=$(python3 -c "import json; print(json.load(open('output/exam_data.json', encoding='utf-8'))['unit'])")
cp output/exam_word_style.png "output/非選擇題_${UNIT}_word風.png"

跨平台注意

  • 字型:腳本依作業系統自動選字型;找不到時改成你系統上任一中文字型(.ttc.ttf)路徑
  • Windows 無 cairo:geometry_renderer 產 SVG 後可用 Edge headless 轉 PNG:

``bash "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" \ --headless --disable-gpu --window-size=520,420 \ --screenshot="<absout.png>" "file:///<absin.svg>" ``


Bloom 層次快速對照

層次 定義 對應小題
第2級:理解 讀懂條件、整理資訊、換算 第(1)小題
第3級:應用 帶入公式、解題、計算結果 第(2)小題(應用)
第4級:分析 比較、推論、判斷合理性 第(2)小題(分析)

常見數學單元標記範例

單元 標記範例
一元一次方程式 設未知數 {x},列式 {2*x+3=15}
一元一次不等式 {x} {>=} 100
比與比例式 {frac(a,b)} = {frac(c,d)}
多項式(八年級) {x^2} + 2x - 3 = 0
根式(九年級) {sqrt(2)}、{sqrt(3)}
二次方程式 {x^2} + bx + c = 0

幾何圖形 spec 速查

每種 type 的完整 config 參數表請讀取 jh-math-geometry 技能的 references/figure-catalog.md;下表是情境題最常用的圖形,可直接複製進 geometry.spec
批次格式:{"figures":[{"id":"q1_main","type":…,"config":{…},"canvas":{"width":260,"height":210}}],"options":{"format":"png","dpi":150}};單圖 canvas 建議 260×210,兩圖並排各 200×160

情境題類型 直接複製的 spec
直角三角形(畢氏定理) {"type":"triangle","config":{"subtype":"right","vertexlabels":["A","B","C"],"rightangleat":"C","sidelabels":{"AB":"c","BC":"a","CA":"b"}}}
矩形土地/建築 {"type":"quadrilateral","config":{"subtype":"rectangle","vertexlabels":["A","B","C","D"],"sidelabels":{"AB":"長","BC":"寬"}}}
梯形截面 {"type":"quadrilateral","config":{"subtype":"trapezoid","vertexlabels":["A","B","C","D"],"sidelabels":{"AD":"上底","BC":"下底"}}}
圓形場地/扇形 {"type":"circle","config":{"centerlabel":"O","points":{"A":20,"B":110},"radiuslines":["A","B"],"centralangle":["A","B"],"radiuslabel":"r"}}
坐標平面路線 {"type":"coordinateplane","config":{"xrange":[-1,6],"y_range":[-1,5],"segments":[{"x1":0,"y1":0,"x2":4,"y2":3}],"points":[{"x":0,"y":0,"label":"A"},{"x":4,"y":3,"label":"B"}]}}
四角柱包裝盒 {"type":"solid3d","config":{"subtype":"rectangularprism","vertexlabels":["A","B","C","D","E","F","G","H"],"showhidden":true}}
圓柱容器 {"type":"solid_3d","config":{"subtype":"cylinder","labels":{"radius":"r","height":"h"}}}