refactor: 重组项目目录结构

以讲义内容为骨架迁移到标准目录格式:
- officefile/ 主内容(12章 + 附录 + CC4SI补充)
- dofile/ 代码示例(11个Python脚本)
- data/ 图片资源
- output/ 生成输出(忽略)
- Archive/ 归档旧目录(忽略)
- .claude/skills/ 保留markdown-to-docx工具链
- .pandoc/ 保留CSL和本地化配置

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 14:00:56 +08:00
parent af083069d0
commit 219232de74
91 changed files with 39365 additions and 10 deletions
@@ -0,0 +1,592 @@
#!/usr/bin/env python3
from __future__ import annotations
import copy
import os
import re
import shutil
import sys
import tempfile
import zipfile
import xml.etree.ElementTree as ET
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
VML_NS = "urn:schemas-microsoft-com:vml"
OFFICE_NS = "urn:schemas-microsoft-com:office:office"
NS = {"w": W_NS, "v": VML_NS, "o": OFFICE_NS}
ET.register_namespace("w", W_NS)
ET.register_namespace("r", R_NS)
ET.register_namespace("v", VML_NS)
ET.register_namespace("o", OFFICE_NS)
CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
KEYMAP_REL_TYPE = "http://schemas.microsoft.com/office/2006/relationships/keyMapCustomizations"
KEYMAP_CONTENT_TYPE = "application/vnd.ms-word.keyMapCustomizations+xml"
ORDERED_LIST_BASE_LEFT = 800
BULLET_LIST_BASE_LEFT = 840
LIST_LEVEL_STEP = 420
LIST_HANGING = 420
BULLET_ABSTRACT_IDS = {"990", "991", "992"}
ORDERED_NUMFMTS = {"decimal", "lowerLetter", "upperLetter", "lowerRoman", "upperRoman"}
SPECIAL_NUMBERING_PREFIXES = ("", "", "代码清单")
BULLET_GLYPHS = {
"\u2022",
"\u25cf",
"\u25cb",
"\u25aa",
"\u25a0",
"\uF06C",
"\uf0b7",
"",
"",
"o",
"",
}
BROKEN_REL_PREFIX_RE = re.compile(r"\bns\d+:id=")
def qn(tag: str) -> str:
return f"{{{W_NS}}}{tag}"
def first(root, xpath: str):
return root.find(xpath, NS)
def sanitize_relationship_prefixes(xml_path: str) -> None:
if not os.path.exists(xml_path):
return
text = open(xml_path, "r", encoding="utf-8").read()
if not BROKEN_REL_PREFIX_RE.search(text):
return
text = BROKEN_REL_PREFIX_RE.sub("r:id=", text)
if 'xmlns:r="' not in text:
text = text.replace("<w:document ", f'<w:document xmlns:r="{R_NS}" ', 1)
with open(xml_path, "w", encoding="utf-8") as handle:
handle.write(text)
def is_bullet_level(absid: str, lvl: ET.Element) -> bool:
if absid in BULLET_ABSTRACT_IDS:
return True
num_fmt = lvl.find(qn("numFmt"))
if num_fmt is not None and num_fmt.get(qn("val")) == "bullet":
return True
lvl_text = lvl.find(qn("lvlText"))
if lvl_text is not None and (lvl_text.get(qn("val")) or "") in BULLET_GLYPHS:
return True
return False
def is_generic_ordered_level(lvl: ET.Element) -> bool:
num_fmt = lvl.find(qn("numFmt"))
if num_fmt is None or num_fmt.get(qn("val")) not in ORDERED_NUMFMTS:
return False
lvl_text = lvl.find(qn("lvlText"))
if lvl_text is None:
return False
value = lvl_text.get(qn("val")) or ""
if "%" not in value:
return False
if any(prefix in value for prefix in SPECIAL_NUMBERING_PREFIXES):
return False
return True
def style_display_name(style) -> str:
name = first(style, "w:name")
if name is not None and name.get(qn("val")):
return name.get(qn("val"))
return style.get(qn("styleId"), "")
def find_style(styles: dict[str, ET.Element], style_ids=(), style_names=()):
for style_id in style_ids:
style = styles.get(style_id)
if style is not None:
return style
wanted_names = set(style_names)
if wanted_names:
for style in styles.values():
if style_display_name(style) in wanted_names:
return style
return None
def matching_styles(root, style_ids=(), style_names=()):
wanted_ids = set(style_ids)
wanted_names = set(style_names)
out = []
for style in root.findall("w:style", NS):
sid = style.get(qn("styleId"), "")
name = style_display_name(style)
if sid in wanted_ids or name in wanted_names:
out.append(style)
return out
def normalize_code_style(style) -> None:
if style is None:
return
ppr = first(style, "w:pPr")
if ppr is None:
ppr = ET.SubElement(style, qn("pPr"))
ind = first(ppr, "w:ind")
if ind is None:
ind = ET.SubElement(ppr, qn("ind"))
ind.set(qn("firstLine"), "0")
ind.set(qn("firstLineChars"), "0")
for attr in ("hanging", "hangingChars", "left", "leftChars"):
ind.attrib.pop(qn(attr), None)
def patch_styles(styles_path: str):
tree = ET.parse(styles_path)
root = tree.getroot()
styles = {
style.get(qn("styleId")): style for style in root.findall("w:style", NS)
}
style_name_by_id = {
style_id: (
name.get(qn("val")) if (name := first(style, "w:name")) is not None else style_id
)
for style_id, style in styles.items()
}
for style_id in ["2", "3", "4", "5", "6", "78", "91"]:
style = styles.get(style_id)
if style is None:
continue
ppr = first(style, "w:pPr")
if ppr is None:
continue
numpr = first(ppr, "w:numPr")
if numpr is not None:
ppr.remove(numpr)
source_code = find_style(
styles,
style_ids=("SourceCode", "93"),
style_names=("Source Code", "SourceCode"),
)
template_code = find_style(
styles,
style_ids=("af9",),
style_names=("代码清单",),
)
if source_code is not None and template_code is not None:
existing_ppr = first(source_code, "w:pPr")
existing_rpr = first(source_code, "w:rPr")
if existing_ppr is not None:
source_code.remove(existing_ppr)
if existing_rpr is not None:
source_code.remove(existing_rpr)
template_ppr = first(template_code, "w:pPr")
template_rpr = first(template_code, "w:rPr")
if template_ppr is not None:
source_code.append(copy.deepcopy(template_ppr))
if template_rpr is not None:
source_code.append(copy.deepcopy(template_rpr))
normalize_code_style(source_code)
normalize_code_style(template_code)
for style in matching_styles(
root,
style_ids=("SourceCode", "93", "af9"),
style_names=("Source Code", "SourceCode", "代码清单"),
):
normalize_code_style(style)
tree.write(styles_path, encoding="UTF-8", xml_declaration=True)
return style_name_by_id
def strip_explicit_body_styles(document_path: str, style_name_by_id) -> None:
tree = ET.parse(document_path)
root = tree.getroot()
removable = {"FirstParagraph", "BodyText", "Compact"}
removable_ids = {"FirstParagraph", "BodyText", "Compact"}
for paragraph in root.findall(".//w:p", NS):
ppr = first(paragraph, "w:pPr")
if ppr is None:
continue
pstyle = first(ppr, "w:pStyle")
if pstyle is None:
continue
style_id = pstyle.get(qn("val"))
if style_id in removable_ids or style_name_by_id.get(style_id) in removable:
ppr.remove(pstyle)
tree.write(document_path, encoding="UTF-8", xml_declaration=True)
def patch_header(header_path: str, header_text: str) -> None:
tree = ET.parse(header_path)
root = tree.getroot()
paragraphs = root.findall("w:p", NS)
if not paragraphs:
return
target = None
for paragraph in paragraphs:
texts = "".join(node.text or "" for node in paragraph.findall(".//w:t", NS)).strip()
if texts:
target = paragraph
break
if target is None:
return
for child in list(target):
if child.tag != qn("pPr"):
target.remove(child)
run = ET.SubElement(target, qn("r"))
rpr = ET.SubElement(run, qn("rPr"))
rfonts = ET.SubElement(rpr, qn("rFonts"))
rfonts.set(qn("hint"), "eastAsia")
text = ET.SubElement(run, qn("t"))
text.text = header_text
tree.write(header_path, encoding="UTF-8", xml_declaration=True)
def resolve_default_header(extracted_dir: str):
doc_path = os.path.join(extracted_dir, "word", "document.xml")
rels_path = os.path.join(extracted_dir, "word", "_rels", "document.xml.rels")
doc_tree = ET.parse(doc_path)
doc_root = doc_tree.getroot()
sect = first(doc_root, ".//w:body/w:sectPr")
if sect is None:
return None
default_rid = None
for header_ref in sect.findall("w:headerReference", NS):
if header_ref.get(qn("type")) == "default":
default_rid = header_ref.get(f"{{{R_NS}}}id")
break
if not default_rid:
return None
rel_tree = ET.parse(rels_path)
rel_root = rel_tree.getroot()
for rel in rel_root.findall(f"{{{PKG_REL_NS}}}Relationship"):
if rel.get("Id") == default_rid:
target = rel.get("Target")
if target:
return os.path.join(extracted_dir, "word", target)
return None
def next_rid(rel_root) -> str:
max_id = 0
for rel in rel_root.findall(f"{{{PKG_REL_NS}}}Relationship"):
rel_id = rel.get("Id", "")
if rel_id.startswith("rId"):
try:
max_id = max(max_id, int(rel_id[3:]))
except ValueError:
continue
return f"rId{max_id + 1}"
def inject_keymap_customizations(extracted_dir: str, shortcut_template_path: str | None) -> None:
if not shortcut_template_path or not os.path.exists(shortcut_template_path):
return
with zipfile.ZipFile(shortcut_template_path) as template_archive:
if "word/customizations.xml" not in template_archive.namelist():
return
customizations_bytes = template_archive.read("word/customizations.xml")
word_dir = os.path.join(extracted_dir, "word")
os.makedirs(word_dir, exist_ok=True)
with open(os.path.join(word_dir, "customizations.xml"), "wb") as handle:
handle.write(customizations_bytes)
rels_path = os.path.join(word_dir, "_rels", "document.xml.rels")
rel_tree = ET.parse(rels_path)
rel_root = rel_tree.getroot()
keymap_rel = None
for rel in rel_root.findall(f"{{{PKG_REL_NS}}}Relationship"):
if rel.get("Type") == KEYMAP_REL_TYPE:
keymap_rel = rel
break
if keymap_rel is None:
keymap_rel = ET.SubElement(rel_root, f"{{{PKG_REL_NS}}}Relationship")
keymap_rel.set("Id", next_rid(rel_root))
keymap_rel.set("Type", KEYMAP_REL_TYPE)
keymap_rel.set("Target", "customizations.xml")
rel_tree.write(rels_path, encoding="UTF-8", xml_declaration=True)
content_types_path = os.path.join(extracted_dir, "[Content_Types].xml")
ct_tree = ET.parse(content_types_path)
ct_root = ct_tree.getroot()
override_tag = f"{{{CONTENT_TYPES_NS}}}Override"
override = None
for node in ct_root.findall(override_tag):
if node.get("PartName") == "/word/customizations.xml":
override = node
break
if override is None:
override = ET.SubElement(ct_root, override_tag)
override.set("PartName", "/word/customizations.xml")
override.set("ContentType", KEYMAP_CONTENT_TYPE)
ct_tree.write(content_types_path, encoding="UTF-8", xml_declaration=True)
def remove_horizontal_rules(document_path: str) -> None:
tree = ET.parse(document_path)
root = tree.getroot()
hr_tag = f"{{{OFFICE_NS}}}hr"
parent_map = {child: parent for parent in root.iter() for child in parent}
for paragraph in root.findall(".//" + qn("p")):
for pict in paragraph.findall(".//" + qn("pict")):
for rect in pict.findall(f".//{{{VML_NS}}}rect"):
if rect.get(hr_tag) == "t":
parent = parent_map.get(paragraph)
if parent is not None:
parent.remove(paragraph)
break
tree.write(document_path, encoding="UTF-8", xml_declaration=True)
def patch_doc_defaults(extracted_dir: str, template_path: str) -> None:
styles_path = os.path.join(extracted_dir, "word", "styles.xml")
if not os.path.exists(styles_path) or not os.path.exists(template_path):
return
tmpl_tmp = tempfile.mkdtemp(prefix="tmpl-defaults-")
try:
with zipfile.ZipFile(template_path) as archive:
archive.extractall(tmpl_tmp)
tmpl_styles_path = os.path.join(tmpl_tmp, "word", "styles.xml")
if not os.path.exists(tmpl_styles_path):
return
tmpl_tree = ET.parse(tmpl_styles_path)
tmpl_root = tmpl_tree.getroot()
tmpl_defaults = tmpl_root.find(qn("docDefaults"))
if tmpl_defaults is None:
return
gen_tree = ET.parse(styles_path)
gen_root = gen_tree.getroot()
gen_defaults = gen_root.find(qn("docDefaults"))
if gen_defaults is not None:
gen_root.remove(gen_defaults)
new_defaults = copy.deepcopy(tmpl_defaults)
rfonts = new_defaults.find(f".//{qn('rFonts')}")
if rfonts is not None:
for attr in ("ascii", "hAnsi"):
if rfonts.get(qn(attr)) == "Calibri":
rfonts.set(qn(attr), "Times New Roman")
gen_root.insert(0, new_defaults)
gen_tree.write(styles_path, encoding="UTF-8", xml_declaration=True)
finally:
shutil.rmtree(tmpl_tmp, ignore_errors=True)
def patch_tables(document_path: str) -> None:
tree = ET.parse(document_path)
root = tree.getroot()
for tbl in root.findall(".//" + qn("tbl")):
tbl_pr = tbl.find(qn("tblPr"))
if tbl_pr is None:
continue
tbl_style = tbl_pr.find(qn("tblStyle"))
if tbl_style is not None:
tbl_style.set(qn("val"), "24")
tbl_layout = tbl_pr.find(qn("tblLayout"))
if tbl_layout is not None:
tbl_layout.set(qn("type"), "autofit")
tbl_w = tbl_pr.find(qn("tblW"))
if tbl_w is not None:
tbl_pr.remove(tbl_w)
existing_borders = tbl_pr.find(qn("tblBorders"))
if existing_borders is not None:
tbl_pr.remove(existing_borders)
borders = ET.SubElement(tbl_pr, qn("tblBorders"))
for side in ("top", "left", "bottom", "right", "insideH", "insideV"):
border = ET.SubElement(borders, qn(side))
border.set(qn("val"), "single")
border.set(qn("color"), "000000")
border.set(qn("sz"), "4")
border.set(qn("space"), "0")
tree.write(document_path, encoding="UTF-8", xml_declaration=True)
def patch_numbering(extracted_dir: str) -> None:
numbering_path = os.path.join(extracted_dir, "word", "numbering.xml")
if not os.path.exists(numbering_path):
return
tree = ET.parse(numbering_path)
root = tree.getroot()
for absnum in root.findall(qn("abstractNum")):
absid = absnum.get(qn("abstractNumId"), "")
for lvl in absnum.findall(qn("lvl")):
bullet_level = is_bullet_level(absid, lvl)
ordered_level = is_generic_ordered_level(lvl)
if not bullet_level and not ordered_level:
continue
if bullet_level:
# Unify bullet glyphs and font so list symbols render consistently in Word.
num_fmt = lvl.find(qn("numFmt"))
if num_fmt is None:
num_fmt = ET.SubElement(lvl, qn("numFmt"))
if num_fmt.get(qn("val")) != "bullet":
num_fmt.set(qn("val"), "bullet")
lvl_text = lvl.find(qn("lvlText"))
if lvl_text is None:
lvl_text = ET.SubElement(lvl, qn("lvlText"))
lvl_text.set(qn("val"), "\uF06C")
rpr = lvl.find(qn("rPr"))
if rpr is None:
rpr = ET.SubElement(lvl, qn("rPr"))
rfonts = rpr.find(qn("rFonts"))
if rfonts is None:
rfonts = ET.SubElement(rpr, qn("rFonts"))
rfonts.set(qn("ascii"), "Wingdings")
rfonts.set(qn("hAnsi"), "Wingdings")
rfonts.set(qn("hint"), "default")
ppr = lvl.find(qn("pPr"))
if ppr is None:
ppr = ET.SubElement(lvl, qn("pPr"))
ind = ppr.find(qn("ind"))
if ind is None:
ind = ET.SubElement(ppr, qn("ind"))
try:
level = int(lvl.get(qn("ilvl"), "0"))
except ValueError:
level = 0
if bullet_level:
left = BULLET_LIST_BASE_LEFT + (level * LIST_LEVEL_STEP)
else:
left = ORDERED_LIST_BASE_LEFT + (level * LIST_LEVEL_STEP)
ind.set(qn("left"), str(left))
ind.set(qn("hanging"), str(LIST_HANGING))
for attr in ("leftChars", "hangingChars", "firstLine", "firstLineChars"):
ind.attrib.pop(qn(attr), None)
tree.write(numbering_path, encoding="UTF-8", xml_declaration=True)
def ensure_keep_next(ppr) -> None:
keep_next = first(ppr, "w:keepNext")
if keep_next is None:
keep_next = ET.SubElement(ppr, qn("keepNext"))
keep_next.set(qn("val"), "1")
def set_zero_first_line_indent(ppr) -> None:
ind = first(ppr, "w:ind")
if ind is None:
ind = ET.SubElement(ppr, qn("ind"))
ind.set(qn("firstLine"), "0")
ind.set(qn("firstLineChars"), "0")
for attr in ("hanging", "hangingChars"):
ind.attrib.pop(qn(attr), None)
def patch_layout_constraints(document_path: str, style_name_by_id) -> None:
tree = ET.parse(document_path)
root = tree.getroot()
code_style_names = {"Source Code", "SourceCode", "代码清单"}
keep_next_style_names = {"", "表题1-1"}
for paragraph in root.findall(".//" + qn("p")):
ppr = first(paragraph, "w:pPr")
if ppr is None:
ppr = ET.Element(qn("pPr"))
paragraph.insert(0, ppr)
pstyle = first(ppr, "w:pStyle")
style_name = ""
if pstyle is not None:
style_name = style_name_by_id.get(pstyle.get(qn("val")), "")
if style_name in keep_next_style_names:
ensure_keep_next(ppr)
if style_name in code_style_names:
ind = first(ppr, "w:ind")
if ind is not None:
for attr in ("firstLine", "firstLineChars", "hanging", "hangingChars"):
ind.attrib.pop(qn(attr), None)
if not ind.attrib:
ppr.remove(ind)
for cell in root.findall(".//" + qn("tc")):
for paragraph in cell.findall(qn("p")):
ppr = first(paragraph, "w:pPr")
if ppr is None:
ppr = ET.Element(qn("pPr"))
paragraph.insert(0, ppr)
set_zero_first_line_indent(ppr)
tree.write(document_path, encoding="UTF-8", xml_declaration=True)
def main() -> int:
if len(sys.argv) not in {4, 5}:
print(
"Usage: postprocess_template_docx.py <docx-path> <template-path> <header-text> [shortcut-template]",
file=sys.stderr,
)
return 1
docx_path = sys.argv[1]
template_path = sys.argv[2]
header_text = sys.argv[3]
shortcut_template = sys.argv[4] if len(sys.argv) == 5 else None
if not os.path.exists(docx_path):
print(f"Error: file not found: {docx_path}", file=sys.stderr)
return 1
temp_dir = tempfile.mkdtemp(prefix="template-docx-")
try:
with zipfile.ZipFile(docx_path) as archive:
archive.extractall(temp_dir)
styles_path = os.path.join(temp_dir, "word", "styles.xml")
document_path = os.path.join(temp_dir, "word", "document.xml")
sanitize_relationship_prefixes(document_path)
style_name_by_id = {}
if os.path.exists(styles_path):
style_name_by_id = patch_styles(styles_path)
if os.path.exists(document_path):
strip_explicit_body_styles(document_path, style_name_by_id)
if os.path.exists(document_path):
remove_horizontal_rules(document_path)
patch_tables(document_path)
patch_layout_constraints(document_path, style_name_by_id)
patch_doc_defaults(temp_dir, template_path)
patch_numbering(temp_dir)
default_header = resolve_default_header(temp_dir)
if default_header and os.path.exists(default_header):
patch_header(default_header, header_text)
inject_keymap_customizations(temp_dir, shortcut_template)
rebuilt = docx_path + ".tmp"
with zipfile.ZipFile(rebuilt, "w", zipfile.ZIP_DEFLATED) as archive:
for root, _, files in os.walk(temp_dir):
for filename in files:
full_path = os.path.join(root, filename)
rel_path = os.path.relpath(full_path, temp_dir)
archive.write(full_path, rel_path)
shutil.move(rebuilt, docx_path)
return 0
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())