import pandas as pd
from pathlib import Path
from datetime import datetime
from docx import Document
from docx.shared import Pt, Inches, Cm
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.enum.table import WD_TABLE_ALIGNMENT
import zipfile
import re
# ---------- Utils ----------
def safe_filename(name: str) -> str:
s = str(name)
s = s.replace('/', '/').replace('\\', '\')
s = re.sub(r'[\:*?"<>|]', '_', s)
return s
def add_hyperlink(paragraph, url, text=None):
if text is None:
text = url
text = str(text)
part = paragraph.part
r_id = part.relate_to(url, reltype="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", is_external=True)
hyperlink = OxmlElement('w:hyperlink')
hyperlink.set(qn('r:id'), r_id)
new_run = OxmlElement('w:r')
rPr = OxmlElement('w:rPr')
u = OxmlElement('w:u')
u.set(qn('w:val'), 'single')
rPr.append(u)
color = OxmlElement('w:color')
color.set(qn('w:val'), '0563C1')
rPr.append(color)
new_run.append(rPr)
text_elem = OxmlElement('w:t')
text_elem.text = text
new_run.append(text_elem)
hyperlink.append(new_run)
paragraph._p.append(hyperlink)
paragraph.add_run("")
def is_image_url(url):
return bool(re.search(r"\.(png|jpg|jpeg|gif|webp)(\?|$)", str(url or ''), re.I))
def set_doc_margins(doc: Document, top=1.8, bottom=1.8, left=1.8, right=1.8):
sect = doc.sections[0]
sect.top_margin = Cm(top)
sect.bottom_margin = Cm(bottom)
sect.left_margin = Cm(left)
sect.right_margin = Cm(right)
def add_header_footer(doc: Document, header_text: str):
sect = doc.sections[0]
# Header
hdr = sect.header
p = hdr.paragraphs[0]
p.text = header_text
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.runs[0].font.size = Pt(10)
# Footer with PAGE field
f = sect.footer.paragraphs[0]
f.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = f.add_run("第 ")
run.font.size = Pt(10)
r_field = f.add_run()
fldChar1 = OxmlElement('w:fldChar'); fldChar1.set(qn('w:fldCharType'), 'begin')
instrText = OxmlElement('w:instrText'); instrText.text = 'PAGE'
fldChar2 = OxmlElement('w:fldChar'); fldChar2.set(qn('w:fldCharType'), 'end')
r_field._r.append(fldChar1)
r_field._r.append(instrText)
r_field._r.append(fldChar2)
f.add_run(" 頁").font.size = Pt(10)
def set_cell_border(cell, **kwargs):
"""Set cell border: set_cell_border(cell, top=(sz, val, color), bottom=..., left=..., right=...)"""
tc = cell._tc
tcPr = tc.get_or_add_tcPr()
tcBorders = tcPr.first_child_found_in('w:tcBorders')
if tcBorders is None:
tcBorders = OxmlElement('w:tcBorders')
tcPr.append(tcBorders)
for edge in ('top', 'left', 'bottom', 'right'):
if edge in kwargs:
edge_data = kwargs[edge]
tag = 'w:' + edge
element = tcBorders.find(qn(tag))
if element is None:
element = OxmlElement(tag)
tcBorders.append(element)
sz, val, color = edge_data
element.set(qn('w:val'), val)
element.set(qn('w:sz'), str(sz))
element.set(qn('w:color'), color)
def doc_add_title_block(doc: Document, section_name: str, subtitle: str):
title_p = doc.add_paragraph()
title_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
r = title_p.add_run(str(section_name))
r.bold = True
r.font.size = Pt(18)
sub_p = doc.add_paragraph(subtitle)
sub_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
sub_p.runs[0].font.size = Pt(11)
head = doc.add_paragraph("班級:______________ 姓名:______________ 座號:______")
head.alignment = WD_ALIGN_PARAGRAPH.LEFT
head.paragraph_format.space_after = Pt(6)
def add_question_block_v4(doc: Document, idx: int, item: dict, with_answer: bool):
topic = item.get('主題') or item.get('topic') or ''
desc = item.get('附件說明') or item.get('desc') or ''
url = item.get('附件連結') or item.get('url') or ''
# 題號 + 主題
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(6)
r1 = p.add_run(f"第 {idx} 題 ")
r1.bold = True
r1.font.size = Pt(12)
if topic:
p.add_run("主題:").bold = True
p.add_run(str(topic))
# 題面連結
m = doc.add_paragraph("題面連結:")
if url:
if is_image_url(url):
m.add_run("(圖片)")
add_hyperlink(m, str(url), str(url))
else:
m.add_run("(無附件連結)")
if with_answer:
ap = doc.add_paragraph("參考解答:")
ap.add_run(str(desc) if str(desc).strip() else "(此題答案待補)")
else:
# 12 行橫線 + 批改欄(以邊框畫線,線條更清楚)
tbl = doc.add_table(rows=1, cols=2)
tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
tbl.columns[0].width = Inches(5.5)
tbl.columns[1].width = Inches(1.6)
calc_cell = tbl.cell(0, 0)
inner = calc_cell.add_table(rows=12, cols=1)
for i, row in enumerate(inner.rows):
row.height = Cm(0.8)
c = row.cells[0]
# 底線(加粗)
set_cell_border(c, bottom=(12, 'single', 'AAAAAA'))
# 清空段落的多餘間距
p2 = c.paragraphs[0]
p2.paragraph_format.space_after = Pt(0)
p2.paragraph_format.space_before = Pt(0)
# 批改欄
mark = tbl.cell(0, 1)
mp = mark.paragraphs[0]
mp.add_run("配分: / 得分: ").bold = True
mark.add_paragraph("□ 對 □ 誤 □ 部分給分")
mark.add_paragraph("批註:")
for _ in range(3):
# 使用底線段落供書寫
pp = mark.add_paragraph(" ")
run = pp.runs[0]
run.font.underline = True
doc.add_paragraph("")
# ---------- Read CSV ----------
csv_path = Path('5.csv')
try:
df = pd.read_csv(csv_path, encoding='utf-8')
except Exception:
df = pd.read_csv(csv_path, encoding='utf-8-sig')
if '區段' not in df.columns:
raise RuntimeError('CSV 缺少「區段」欄位,無法分組匯出。')
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
root = Path(f'批次匯出_v4_{stamp}')
docx_dir = root / 'docx'
docx_dir.mkdir(parents=True, exist_ok=True)
exported_files = []
for section, g in df.groupby('區段'):
items = g.to_dict(orient='records')
sec_safe = safe_filename(section)
# 練習卷
doc = Document()
set_doc_margins(doc, 1.8, 1.8, 1.8, 1.8)
add_header_footer(doc, f"{section}(練習卷)")
doc_add_title_block(doc, section, "(練習卷)")
for i, it in enumerate(items, start=1):
add_question_block_v4(doc, i, it, with_answer=False)
fn_practice = docx_dir / f"{sec_safe}_練習卷.docx"
doc.save(fn_practice)
exported_files.append(str(fn_practice))
# 答案卷
doc_a = Document()
set_doc_margins(doc_a, 1.8, 1.8, 1.8, 1.8)
add_header_footer(doc_a, f"{section}(答案卷/教師用)")
doc_add_title_block(doc_a, section, "(答案卷/教師用)")
for i, it in enumerate(items, start=1):
add_question_block_v4(doc_a, i, it, with_answer=True)
fn_answer = docx_dir / f"{sec_safe}_答案卷.docx"
doc_a.save(fn_answer)
exported_files.append(str(fn_answer))
zip_path = root / f"練習卷_答案卷_打包.zip"
with zipfile.ZipFile(zip_path, 'w', compression=zipfile.ZIP_DEFLATED) as z:
for f in exported_files:
z.write(f, arcname=Path(f).name)
print({'export_folder': str(root), 'zip': str(zip_path), 'docx_files_count': len(exported_files)})