國中數學生活情境非選擇題命題技能
技能概覽
本技能依使用者指定的國中數學單元,上網搜尋相關生活情境與時事新聞, 將真實素材轉化為非選擇題,並匯出格式正確的 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 |
| {sqrt3} | 三次方根 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) 小題:
- 從
question/source讀出已知條件 - 用 Python 實算(下表速查)
- 比對
answer/solution是否相符 - 有差異 → 修正 JSON(優先改 solution/answer)後重跑
常見題型驗算速查:
| 題型 | 驗算方式 |
|---|---|
| 比例式(a:b = c:d) | a*d == b*c |
| 總量按比例分配 | 總量 × 各份數 / 總份數 |
| 時間/速度/距離 | 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 亂寫字)size:1024x1024(方)/1024x1536(直)/1536x1024(橫)quality:預設low(NT$0.3),關鍵頁才升mediumwidth_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(同 reposkills/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 不附 generate_exam_docx.py;請依下列規格用 python-docx 撰寫 output/generate_exam_docx.py(工作目錄已有就直接沿用),再執行:
python3 output/generate_exam_docx.py output/exam_data.json output/exam_output.docx
腳本規格:
| 項目 | 規格 |
|---|---|
| 介面 | generate_exam_docx.py <exam_data.json> <輸出.docx> |
| 版面 | A4 直式、邊界 2 cm、標楷體/Times New Roman 12pt;標題 title(置中、粗體)、副標「 滿分 <總分>」、「班級__座號__姓名___」 |
| 每大題 | 一段標題 第 N 題(<total_points> 分)(必須含「第N題」字樣,Step 6.5/6.6 靠它定位插圖)→ source 素材段落 → (1)(<points> 分)題目 一段 → 作答空白 → (2)(<points> 分)題目 一段 → 作答空白 |
| 解析 | 全部題目之後分頁,「解析」章節:每大題列 (1)/(2) 的 answer、solution(依換行分段)與 bloom_level+bloom_reason |
| 數學式 | 所有 {…} 標記轉成 OMML(用下方 add_math_text),不可留成純文字 |
{} → 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)
腳本中所有寫入題目/選項/解答文字的地方,一律呼叫 add_math_text(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:"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" \ --headless --disable-gpu --window-size=520,420 \ --screenshot="<abs_out.png>" "file:///<abs_in.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","vertex_labels":["A","B","C"],"right_angle_at":"C","side_labels":{"AB":"c","BC":"a","CA":"b"}}} |
| 矩形土地/建築 | {"type":"quadrilateral","config":{"subtype":"rectangle","vertex_labels":["A","B","C","D"],"side_labels":{"AB":"長","BC":"寬"}}} |
| 梯形截面 | {"type":"quadrilateral","config":{"subtype":"trapezoid","vertex_labels":["A","B","C","D"],"side_labels":{"AD":"上底","BC":"下底"}}} |
| 圓形場地/扇形 | {"type":"circle","config":{"center_label":"O","points":{"A":20,"B":110},"radius_lines":["A","B"],"central_angle":["A","B"],"radius_label":"r"}} |
| 坐標平面路線 | {"type":"coordinate_plane","config":{"x_range":[-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":"solid_3d","config":{"subtype":"rectangular_prism","vertex_labels":["A","B","C","D","E","F","G","H"],"show_hidden":true}} |
| 圓柱容器 | {"type":"solid_3d","config":{"subtype":"cylinder","labels":{"radius":"r","height":"h"}}} |