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:
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage:
|
||||
convert_markdown_to_docx.sh <source-path> <output-dir> [resource-root]
|
||||
|
||||
Arguments:
|
||||
source-path A single .md file or a directory containing .md files
|
||||
output-dir Destination directory for generated .docx files
|
||||
resource-root Optional root directory for shared assets such as resources/
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ $# -lt 2 || $# -gt 3 ]] && usage
|
||||
|
||||
if ! command -v pandoc >/dev/null 2>&1; then
|
||||
echo "Error: pandoc is not installed or not in PATH." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SOURCE_PATH="$1"
|
||||
OUTPUT_DIR="$2"
|
||||
|
||||
if [[ -f "$SOURCE_PATH" ]]; then
|
||||
SOURCE_DIR="$(cd "$(dirname "$SOURCE_PATH")" && pwd -P)"
|
||||
elif [[ -d "$SOURCE_PATH" ]]; then
|
||||
SOURCE_DIR="$(cd "$SOURCE_PATH" && pwd -P)"
|
||||
else
|
||||
echo "Error: source path not found: $SOURCE_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SOURCE_PARENT="$(cd "$SOURCE_DIR/.." && pwd -P)"
|
||||
RESOURCE_ROOT="${3:-$SOURCE_PARENT}"
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
normalize_markdown() {
|
||||
local input_file="$1"
|
||||
local output_file="$2"
|
||||
|
||||
perl -0pe '
|
||||
s{^图:([^\n!]+)!\[\[([^]|]+)\|[0-9]+\]\]}{\n\n*图:$1*}mg;
|
||||
s{!\[\[([^]|]+)\|[0-9]+\]\]}{}g;
|
||||
s{!\[\[([^]|]+)\]\]}{}g;
|
||||
' "$input_file" > "$output_file"
|
||||
}
|
||||
|
||||
collect_sources() {
|
||||
if [[ -f "$SOURCE_PATH" ]]; then
|
||||
printf '%s\n' "$SOURCE_PATH"
|
||||
return
|
||||
fi
|
||||
|
||||
find "$SOURCE_PATH" -maxdepth 1 -type f -name '*.md' | sort
|
||||
}
|
||||
|
||||
convert_one() {
|
||||
local src_file="$1"
|
||||
local base_name normalized_file output_file src_dir resource_path
|
||||
|
||||
base_name="$(basename "$src_file" .md)"
|
||||
normalized_file="$TMP_DIR/$base_name.md"
|
||||
output_file="$OUTPUT_DIR/$base_name.docx"
|
||||
src_dir="$(cd "$(dirname "$src_file")" && pwd -P)"
|
||||
resource_path="$src_dir:$SOURCE_DIR:$SOURCE_PARENT:$RESOURCE_ROOT:$RESOURCE_ROOT/resources"
|
||||
|
||||
normalize_markdown "$src_file" "$normalized_file"
|
||||
|
||||
pandoc "$normalized_file" \
|
||||
-f markdown \
|
||||
-t docx \
|
||||
--resource-path="$resource_path" \
|
||||
-o "$output_file"
|
||||
|
||||
printf 'OK\t%s\n' "$output_file"
|
||||
}
|
||||
|
||||
converted_count=0
|
||||
|
||||
while IFS= read -r src_file; do
|
||||
[[ -n "$src_file" ]] || continue
|
||||
convert_one "$src_file"
|
||||
converted_count=$((converted_count + 1))
|
||||
done < <(collect_sources)
|
||||
|
||||
if [[ "$converted_count" -eq 0 ]]; then
|
||||
echo "Error: no Markdown files found to convert." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Converted %d file(s) into %s\n' "$converted_count" "$OUTPUT_DIR"
|
||||
@@ -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())
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage:
|
||||
render_markdown_with_dotx.sh <source-md> <output-docx> <template-dotx-or-docx> [book-title] [resource-root] [shortcut-template]
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ $# -lt 3 || $# -gt 6 ]] && usage
|
||||
|
||||
SOURCE_MD="$1"
|
||||
OUTPUT_DOCX="$2"
|
||||
TEMPLATE_DOC="$3"
|
||||
BOOK_TITLE="${4:-}"
|
||||
SHORTCUT_TEMPLATE="${6:-}"
|
||||
SOURCE_NAME="$(basename "$SOURCE_MD")"
|
||||
|
||||
if [[ ! -f "$SOURCE_MD" ]]; then
|
||||
echo "Error: source markdown not found: $SOURCE_MD" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$TEMPLATE_DOC" ]]; then
|
||||
echo "Error: template file not found: $TEMPLATE_DOC" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v pandoc >/dev/null 2>&1; then
|
||||
echo "Error: pandoc is not installed or not in PATH." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
template_has_keymap_customizations() {
|
||||
python3 - "$1" <<'PY'
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(sys.argv[1]) as zf:
|
||||
raise SystemExit(0 if "word/customizations.xml" in zf.namelist() else 1)
|
||||
except Exception:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
SOURCE_DIR="$(cd "$(dirname "$SOURCE_MD")" && pwd -P)"
|
||||
SOURCE_PARENT="$(cd "$SOURCE_DIR/.." && pwd -P)"
|
||||
RESOURCE_ROOT="${5:-$SOURCE_PARENT}"
|
||||
|
||||
if [[ -z "$SHORTCUT_TEMPLATE" ]] && template_has_keymap_customizations "$TEMPLATE_DOC"; then
|
||||
SHORTCUT_TEMPLATE="$TEMPLATE_DOC"
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$OUTPUT_DOCX")"
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
NORMALIZED_MD="$TMP_DIR/normalized.md"
|
||||
TMP_RESOURCES_DIR="$TMP_DIR/resources"
|
||||
|
||||
perl -0pe '
|
||||
s{^图:([^\n!]+)!\[\[([^]|]+)\|[0-9]+\]\]}{\n\n图:$1}mg;
|
||||
s{!\[\[([^]|]+)\|[0-9]+\]\]}{}g;
|
||||
s{!\[\[([^]|]+)\]\]}{}g;
|
||||
s{^\*([图表]:[^\n*]+)\*$}{$1}mg;
|
||||
s{(?m)^(!\[[^\n]*\]\([^\n]+\))$}{\n$1\n}g;
|
||||
s{(?m)^([图表]:[^\n]+)$}{\n$1\n}g;
|
||||
s{(?m)^(\*\*[^\n*]+\*\*)$}{\n$1\n}g;
|
||||
s{^---$}{}mg;
|
||||
s{^(#{3,})\s+\d+\.\d+(?:\.\d+)?\s+(小结|可执行清单)}{$1 $2}mg;
|
||||
s{\n{3,}}{\n\n}g;
|
||||
' "$SOURCE_MD" > "$NORMALIZED_MD"
|
||||
|
||||
python3 "$SCRIPT_DIR/render_mermaid_blocks_for_docx.py" \
|
||||
"$NORMALIZED_MD" \
|
||||
"$SOURCE_NAME" \
|
||||
"$RESOURCE_ROOT" \
|
||||
"$TMP_RESOURCES_DIR"
|
||||
|
||||
# Auto-fix missing table/figure captions before conversion
|
||||
python3 "$SCRIPT_DIR/validate_captions.py" fix "$NORMALIZED_MD"
|
||||
|
||||
CHAPTER_TITLE="$(sed -n 's/^# //p' "$NORMALIZED_MD" | head -n 1)"
|
||||
CHAPTER_PREFIX="$(printf '%s\n' "$CHAPTER_TITLE" | perl -ne 'print "$1\n" if /(第[0-9]+章)/')"
|
||||
|
||||
HEADER_TEXT="${CHAPTER_TITLE:-Markdown Export}"
|
||||
if [[ -n "$BOOK_TITLE" ]]; then
|
||||
HEADER_TEXT="《${BOOK_TITLE}》"
|
||||
fi
|
||||
if [[ -n "$BOOK_TITLE" && -n "$CHAPTER_PREFIX" ]]; then
|
||||
HEADER_TEXT="${HEADER_TEXT}${CHAPTER_PREFIX}"
|
||||
fi
|
||||
|
||||
RESOURCE_PATH="$TMP_DIR:$TMP_RESOURCES_DIR:$SOURCE_DIR:$SOURCE_PARENT:$RESOURCE_ROOT:$RESOURCE_ROOT/resources"
|
||||
|
||||
pandoc "$NORMALIZED_MD" \
|
||||
-f markdown \
|
||||
-t docx \
|
||||
--reference-doc="$TEMPLATE_DOC" \
|
||||
--lua-filter="$SCRIPT_DIR/template_style_filter.lua" \
|
||||
--resource-path="$RESOURCE_PATH" \
|
||||
-o "$OUTPUT_DOCX"
|
||||
|
||||
"$SCRIPT_DIR/postprocess_template_docx.py" "$OUTPUT_DOCX" "$TEMPLATE_DOC" "$HEADER_TEXT" "$SHORTCUT_TEMPLATE"
|
||||
|
||||
printf 'OK\t%s\n' "$OUTPUT_DOCX"
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MERMAID_FENCE_RE = re.compile(r"^```\s*mermaid\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
slug = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff._-]+", "-", text)
|
||||
slug = slug.strip("-._")
|
||||
return slug or "diagram"
|
||||
|
||||
|
||||
def render_mermaid(
|
||||
*,
|
||||
code: str,
|
||||
out_path: Path,
|
||||
theme: str = "neutral",
|
||||
width: int = 1200,
|
||||
height: int = 900,
|
||||
scale: float = 2.0,
|
||||
) -> None:
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="docx-mermaid-") as tmpdir:
|
||||
tmp_mmd = Path(tmpdir) / "diagram.mmd"
|
||||
tmp_mmd.write_text(code, encoding="utf-8")
|
||||
cmd = [
|
||||
"npx",
|
||||
"-y",
|
||||
"@mermaid-js/mermaid-cli",
|
||||
"-i",
|
||||
str(tmp_mmd),
|
||||
"-o",
|
||||
str(out_path),
|
||||
"--outputFormat",
|
||||
"png",
|
||||
"--theme",
|
||||
theme,
|
||||
"--backgroundColor",
|
||||
"white",
|
||||
"--width",
|
||||
str(width),
|
||||
"--height",
|
||||
str(height),
|
||||
"--scale",
|
||||
str(scale),
|
||||
"-q",
|
||||
]
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
stderr = (proc.stderr or "").strip()
|
||||
stdout = (proc.stdout or "").strip()
|
||||
detail = stderr or stdout or str(proc.returncode)
|
||||
raise RuntimeError(f"Mermaid render failed for {out_path.name}: {detail}")
|
||||
|
||||
|
||||
def process_markdown(md_path: Path, source_name: str, temp_resources_dir: Path) -> int:
|
||||
lines = md_path.read_text(encoding="utf-8").splitlines()
|
||||
out_lines: list[str] = []
|
||||
mermaid_count = 0
|
||||
rendered = 0
|
||||
temp_resources_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
if MERMAID_FENCE_RE.match(line.strip()):
|
||||
j = i + 1
|
||||
code_lines: list[str] = []
|
||||
while j < len(lines) and not lines[j].strip().startswith("```"):
|
||||
code_lines.append(lines[j])
|
||||
j += 1
|
||||
if j >= len(lines):
|
||||
raise RuntimeError(f"Unclosed mermaid block in {md_path}")
|
||||
|
||||
mermaid_count += 1
|
||||
out_name = f"{slugify(Path(source_name).stem)}-mermaid-{mermaid_count:02d}.png"
|
||||
out_path = temp_resources_dir / out_name
|
||||
render_mermaid(code="\n".join(code_lines).strip() + "\n", out_path=out_path)
|
||||
|
||||
out_lines.append(f"")
|
||||
rendered += 1
|
||||
i = j + 1
|
||||
continue
|
||||
|
||||
out_lines.append(line)
|
||||
i += 1
|
||||
|
||||
md_path.write_text("\n".join(out_lines) + "\n", encoding="utf-8")
|
||||
return rendered
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Render Mermaid code blocks in Markdown to PNG images for DOCX export."
|
||||
)
|
||||
parser.add_argument("markdown_path", help="Normalized Markdown file to rewrite in place.")
|
||||
parser.add_argument("source_name", help="Original Markdown basename, used for output names.")
|
||||
parser.add_argument(
|
||||
"resource_root",
|
||||
help="Compatibility argument; existing image lookup is handled by the calling script.",
|
||||
)
|
||||
parser.add_argument("temp_resources_dir", help="Temporary resources directory for generated diagrams.")
|
||||
args = parser.parse_args()
|
||||
|
||||
rendered = process_markdown(
|
||||
md_path=Path(args.markdown_path),
|
||||
source_name=args.source_name,
|
||||
temp_resources_dir=Path(args.temp_resources_dir),
|
||||
)
|
||||
print(f"MERMAID_OK {Path(args.markdown_path)} rendered={rendered}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,170 @@
|
||||
local stringify = pandoc.utils.stringify
|
||||
|
||||
local function trim(text)
|
||||
return (text:gsub("^%s+", ""):gsub("%s+$", ""))
|
||||
end
|
||||
|
||||
local function wrap_para(style_name, para)
|
||||
return pandoc.Div({ para }, pandoc.Attr("", {}, { { "custom-style", style_name } }))
|
||||
end
|
||||
|
||||
local function para_from_markdown(text)
|
||||
local doc = pandoc.read(text, "markdown")
|
||||
if #doc.blocks > 0 and doc.blocks[1].t == "Para" then
|
||||
return doc.blocks[1]
|
||||
end
|
||||
return pandoc.Para({ pandoc.Str(text) })
|
||||
end
|
||||
|
||||
local function image_only_para(block)
|
||||
return block.t == "Para" and #block.content == 1 and block.content[1].t == "Image"
|
||||
end
|
||||
|
||||
local function figure_to_image_para(block)
|
||||
if block.t ~= "Figure" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local first = block.content and block.content[1] or nil
|
||||
if not first then
|
||||
return nil
|
||||
end
|
||||
|
||||
if (first.t == "Para" or first.t == "Plain") and #first.content == 1 and first.content[1].t == "Image" then
|
||||
return pandoc.Para({ first.content[1] })
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
local function normalize_serial(num)
|
||||
return (num:gsub("%.", "-"))
|
||||
end
|
||||
|
||||
local function ensure_sentence(text)
|
||||
if text == "" then
|
||||
return text
|
||||
end
|
||||
|
||||
if text:match("[。!?%.%!%?]$") then
|
||||
return text
|
||||
end
|
||||
|
||||
return text .. "。"
|
||||
end
|
||||
|
||||
local function parse_caption(text, kind)
|
||||
-- Try with fullwidth colon first, then without.
|
||||
-- Cannot use :? because Lua ? applies to a single byte, not a multi-byte char.
|
||||
local num, rest = text:match("^" .. kind .. ":%s*([0-9]+[%.%-][0-9]+)%s+(.+)$")
|
||||
if not num then
|
||||
num, rest = text:match("^" .. kind .. "%s*([0-9]+[%.%-][0-9]+)%s+(.+)$")
|
||||
end
|
||||
if not num then
|
||||
return nil
|
||||
end
|
||||
|
||||
rest = trim(rest)
|
||||
local title, description = rest:match("^(.-)。(.*)$")
|
||||
if not title or title == "" then
|
||||
title = rest
|
||||
description = ""
|
||||
end
|
||||
|
||||
return {
|
||||
number = normalize_serial(num),
|
||||
title = trim(title),
|
||||
description = trim(description or ""),
|
||||
label = kind .. normalize_serial(num) .. " " .. trim(title),
|
||||
}
|
||||
end
|
||||
|
||||
local function build_figure_explanation(fig)
|
||||
if fig.description == "" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local description = ensure_sentence(fig.description)
|
||||
if description:match("^如图") then
|
||||
return para_from_markdown(description)
|
||||
end
|
||||
|
||||
return para_from_markdown("如图" .. fig.number .. "所示," .. description)
|
||||
end
|
||||
|
||||
local function is_note_text(text)
|
||||
return text:match("^注:")
|
||||
or text:match("^注意:")
|
||||
or text:match("^关键注意:")
|
||||
or text:match("^⚠️%s*关键注意:")
|
||||
end
|
||||
|
||||
local function transform_para(block)
|
||||
local text = trim(stringify(block))
|
||||
|
||||
if image_only_para(block) then
|
||||
return { wrap_para("图", block) }
|
||||
end
|
||||
|
||||
local fig = parse_caption(text, "图")
|
||||
if fig then
|
||||
return { wrap_para("图题", para_from_markdown(fig.label)) }
|
||||
end
|
||||
|
||||
local tbl = parse_caption(text, "表")
|
||||
if tbl then
|
||||
return { wrap_para("表题1-1", para_from_markdown(tbl.label)) }
|
||||
end
|
||||
|
||||
if is_note_text(text) then
|
||||
return { wrap_para("注意", para_from_markdown(text)) }
|
||||
end
|
||||
|
||||
return { block }
|
||||
end
|
||||
|
||||
function Blocks(blocks)
|
||||
local out = {}
|
||||
local i = 1
|
||||
|
||||
while i <= #blocks do
|
||||
local block = blocks[i]
|
||||
local next_block = blocks[i + 1]
|
||||
local image_block = nil
|
||||
|
||||
if image_only_para(block) then
|
||||
image_block = block
|
||||
else
|
||||
image_block = figure_to_image_para(block)
|
||||
end
|
||||
|
||||
if image_block and next_block and next_block.t == "Para" then
|
||||
local fig = parse_caption(trim(stringify(next_block)), "图")
|
||||
if fig then
|
||||
local explanation = build_figure_explanation(fig)
|
||||
if explanation then
|
||||
table.insert(out, explanation)
|
||||
end
|
||||
table.insert(out, wrap_para("图", image_block))
|
||||
table.insert(out, wrap_para("图题", para_from_markdown(fig.label)))
|
||||
i = i + 2
|
||||
else
|
||||
table.insert(out, wrap_para("图", image_block))
|
||||
i = i + 1
|
||||
end
|
||||
else
|
||||
if image_block then
|
||||
table.insert(out, wrap_para("图", image_block))
|
||||
elseif block.t == "Para" then
|
||||
for _, transformed in ipairs(transform_para(block)) do
|
||||
table.insert(out, transformed)
|
||||
end
|
||||
else
|
||||
table.insert(out, block)
|
||||
end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
|
||||
return out
|
||||
end
|
||||
@@ -0,0 +1,597 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate and auto-fix table/figure captions in markdown or docx.
|
||||
|
||||
Modes:
|
||||
validate_captions.py pre <source.md> — check only
|
||||
validate_captions.py fix <source.md> — auto-insert missing captions, write in-place
|
||||
validate_captions.py post <output.docx> — check generated docx
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||
EXPECTED_ORDERED_LEFT = "800"
|
||||
EXPECTED_BULLET_LEFT = "840"
|
||||
EXPECTED_BULLET_HANGING = "420"
|
||||
EXPECTED_LIST_STEP = "420"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_chapter(lines: list[str]) -> str | None:
|
||||
for line in lines:
|
||||
m = re.match(r"^#\s+第(\d+)章", line)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _qn(tag: str) -> str:
|
||||
return f"{{{W}}}{tag}"
|
||||
|
||||
|
||||
TABLE_CAPTION_RE = re.compile(r"^\*?表[::]?\s*(\d+)[-.]\s*(\d+)\s+\S.*\*?$")
|
||||
FIGURE_CAPTION_RE = re.compile(r"^\*?图\s*(\d+)[-.]\s*(\d+)\s+\S.*\*?$")
|
||||
FIGURE_CAPTION_ALT_RE = re.compile(r"^\*?图[::]?\s*(\d+)[-.]\s*(\d+)\s+\S.*\*?$")
|
||||
GENERIC_ORDERED_NUMFMTS = {"decimal", "lowerLetter", "upperLetter", "lowerRoman", "upperRoman"}
|
||||
SPECIAL_NUMBERING_PREFIXES = ("表", "图", "代码清单")
|
||||
|
||||
|
||||
def _is_generic_ordered_level(lvl: ET.Element) -> bool:
|
||||
num_fmt = lvl.find(f"{{{W}}}numFmt")
|
||||
if num_fmt is None or num_fmt.get(f"{{{W}}}val") not in GENERIC_ORDERED_NUMFMTS:
|
||||
return False
|
||||
lvl_text = lvl.find(f"{{{W}}}lvlText")
|
||||
if lvl_text is None:
|
||||
return False
|
||||
value = lvl_text.get(f"{{{W}}}val") or ""
|
||||
if "%" not in value:
|
||||
return False
|
||||
if any(prefix in value for prefix in SPECIAL_NUMBERING_PREFIXES):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _find_table_blocks(lines: list[str]) -> list[tuple[int, int, str]]:
|
||||
"""Return (start, end, header_line) for each contiguous table block."""
|
||||
blocks: list[tuple[int, int, str]] = []
|
||||
in_table = False
|
||||
table_start = 0
|
||||
header = ""
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("|") and "|" in stripped[1:]:
|
||||
if not in_table:
|
||||
in_table = True
|
||||
table_start = i
|
||||
header = stripped
|
||||
else:
|
||||
if in_table:
|
||||
blocks.append((table_start, i - 1, header))
|
||||
in_table = False
|
||||
if in_table:
|
||||
blocks.append((table_start, len(lines) - 1, header))
|
||||
return blocks
|
||||
|
||||
|
||||
def _find_figure_items(lines: list[str]) -> list[tuple[int, int, str]]:
|
||||
"""Return (start_line, end_line, type) for images and mermaid blocks."""
|
||||
items: list[tuple[int, int, str]] = []
|
||||
for i, line in enumerate(lines):
|
||||
if re.match(r"^!\[", line.strip()):
|
||||
items.append((i, i, "image"))
|
||||
in_code = False
|
||||
code_lang = ""
|
||||
code_start = 0
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("```"):
|
||||
if not in_code:
|
||||
in_code = True
|
||||
code_lang = stripped[3:].strip().lower()
|
||||
code_start = i
|
||||
else:
|
||||
if code_lang == "mermaid":
|
||||
items.append((code_start, i, "mermaid"))
|
||||
in_code = False
|
||||
code_lang = ""
|
||||
items.sort(key=lambda x: x[0])
|
||||
return items
|
||||
|
||||
|
||||
def _has_caption_before(lines: list[str], start: int, pattern: re.Pattern) -> tuple[bool, tuple[str, str] | None]:
|
||||
for look_back in range(1, 4):
|
||||
idx = start - look_back
|
||||
if idx < 0:
|
||||
break
|
||||
prev = lines[idx].strip()
|
||||
if prev == "":
|
||||
continue
|
||||
m = pattern.match(prev)
|
||||
if m:
|
||||
return True, (m.group(1), m.group(2))
|
||||
return False, None
|
||||
return False, None
|
||||
|
||||
|
||||
def _has_caption_after(lines: list[str], search_start: int, pattern: re.Pattern, alt_pattern: re.Pattern | None = None) -> tuple[bool, tuple[str, str] | None]:
|
||||
for idx in range(search_start, min(search_start + 4, len(lines))):
|
||||
nxt = lines[idx].strip()
|
||||
if nxt == "":
|
||||
continue
|
||||
m = pattern.match(nxt)
|
||||
if m:
|
||||
return True, (m.group(1), m.group(2))
|
||||
if alt_pattern:
|
||||
m2 = alt_pattern.match(nxt)
|
||||
if m2:
|
||||
return True, (m2.group(1), m2.group(2))
|
||||
return False, None
|
||||
return False, None
|
||||
|
||||
|
||||
def _derive_table_title(lines: list[str], start: int, header: str) -> str:
|
||||
"""Derive a short table title from the header row columns."""
|
||||
# Extract column names from header row: | Col1 | Col2 | ...
|
||||
cols = [c.strip() for c in header.split("|") if c.strip()]
|
||||
if len(cols) >= 2:
|
||||
return "、".join(cols[:3]) + ("等" if len(cols) > 3 else "")
|
||||
# Fallback: use preceding paragraph
|
||||
for look_back in range(1, 5):
|
||||
idx = start - look_back
|
||||
if idx < 0:
|
||||
break
|
||||
prev = lines[idx].strip()
|
||||
if prev and not prev.startswith("|") and not prev.startswith("#"):
|
||||
# Truncate to first clause
|
||||
for sep in (":", "。", ",", ":"):
|
||||
if sep in prev:
|
||||
prev = prev[: prev.index(sep)]
|
||||
break
|
||||
if len(prev) > 30:
|
||||
prev = prev[:30]
|
||||
return prev
|
||||
return "数据总览"
|
||||
|
||||
|
||||
def _derive_figure_title(lines: list[str], start: int, end: int, fig_type: str) -> str:
|
||||
"""Derive a short figure title from surrounding context."""
|
||||
# Look at line before
|
||||
for look_back in range(1, 5):
|
||||
idx = start - look_back
|
||||
if idx < 0:
|
||||
break
|
||||
prev = lines[idx].strip()
|
||||
if prev and not prev.startswith("```") and not prev.startswith("#"):
|
||||
# Truncate
|
||||
for sep in (":", "。", ","):
|
||||
if sep in prev:
|
||||
prev = prev[: prev.index(sep)]
|
||||
break
|
||||
if len(prev) > 30:
|
||||
prev = prev[:30]
|
||||
return prev
|
||||
return "系统架构图"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def pre_check(md_path: str) -> list[str]:
|
||||
with open(md_path, encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
issues: list[str] = []
|
||||
chapter_num = _extract_chapter(lines) or "?"
|
||||
if chapter_num == "?":
|
||||
issues.append("WARN: Cannot extract chapter number from H1 heading")
|
||||
|
||||
table_blocks = _find_table_blocks(lines)
|
||||
for idx, (start, end, header) in enumerate(table_blocks, 1):
|
||||
found, nums = _has_caption_before(lines, start, TABLE_CAPTION_RE)
|
||||
if not found:
|
||||
issues.append(
|
||||
f"ERROR: Table at line {start + 1} missing caption. "
|
||||
f"Expected: 表{chapter_num}-{idx} <title>"
|
||||
)
|
||||
else:
|
||||
if nums[0] != chapter_num:
|
||||
issues.append(f"WARN: Table at line {start + 1}: chapter {nums[0]}, expected {chapter_num}")
|
||||
if nums[1] != str(idx):
|
||||
issues.append(f"WARN: Table at line {start + 1}: 表{nums[0]}-{nums[1]}, expected seq {idx}")
|
||||
|
||||
figure_items = _find_figure_items(lines)
|
||||
for idx, (start, end, fig_type) in enumerate(figure_items, 1):
|
||||
search_start = end + 1
|
||||
found, nums = _has_caption_after(lines, search_start, FIGURE_CAPTION_RE, FIGURE_CAPTION_ALT_RE)
|
||||
if not found:
|
||||
found, nums = _has_caption_before(lines, start, FIGURE_CAPTION_RE)
|
||||
if not found:
|
||||
issues.append(
|
||||
f"ERROR: {fig_type.capitalize()} at line {start + 1} missing caption. "
|
||||
f"Expected: 图{chapter_num}-{idx} <title>"
|
||||
)
|
||||
else:
|
||||
if nums[0] != chapter_num:
|
||||
issues.append(f"WARN: Figure near line {start + 1}: chapter {nums[0]}, expected {chapter_num}")
|
||||
if nums[1] != str(idx):
|
||||
issues.append(f"WARN: Figure near line {start + 1}: 图{nums[0]}-{nums[1]}, expected seq {idx}")
|
||||
|
||||
if not issues:
|
||||
issues.append(
|
||||
f"OK: {len(table_blocks)} tables, {len(figure_items)} figures — "
|
||||
f"all captions present and correctly numbered"
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-fix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def auto_fix(md_path: str) -> list[str]:
|
||||
"""Insert missing captions into markdown. Returns log of changes."""
|
||||
with open(md_path, encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
chapter_num = _extract_chapter(lines) or "0"
|
||||
log: list[str] = []
|
||||
|
||||
# We need to process from bottom to top so that line insertions
|
||||
# don't shift indices of items not yet processed.
|
||||
|
||||
# Collect all items that need fixing
|
||||
insertions: list[tuple[int, str]] = [] # (line_index, caption_text)
|
||||
|
||||
# --- Tables: caption goes BEFORE the table ---
|
||||
table_blocks = _find_table_blocks(lines)
|
||||
for idx, (start, end, header) in enumerate(table_blocks, 1):
|
||||
found, _ = _has_caption_before(lines, start, TABLE_CAPTION_RE)
|
||||
if not found:
|
||||
title = _derive_table_title(lines, start, header)
|
||||
caption = f"表{chapter_num}-{idx} {title}"
|
||||
insertions.append((start, caption))
|
||||
log.append(f"FIXED: Inserted '{caption}' before line {start + 1}")
|
||||
|
||||
# --- Figures: caption goes AFTER the figure ---
|
||||
figure_items = _find_figure_items(lines)
|
||||
for idx, (start, end, fig_type) in enumerate(figure_items, 1):
|
||||
search_start = end + 1
|
||||
found, _ = _has_caption_after(lines, search_start, FIGURE_CAPTION_RE, FIGURE_CAPTION_ALT_RE)
|
||||
if not found:
|
||||
found, _ = _has_caption_before(lines, start, FIGURE_CAPTION_RE)
|
||||
if not found:
|
||||
title = _derive_figure_title(lines, start, end, fig_type)
|
||||
caption = f"图{chapter_num}-{idx} {title}"
|
||||
insert_at = end + 1
|
||||
insertions.append((insert_at, caption))
|
||||
log.append(f"FIXED: Inserted '{caption}' after line {end + 1}")
|
||||
|
||||
if not insertions:
|
||||
log.append("OK: No missing captions to fix")
|
||||
return log
|
||||
|
||||
# Sort by line index descending so insertions don't shift each other
|
||||
insertions.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
for insert_at, caption in insertions:
|
||||
# Insert: blank line + caption + blank line
|
||||
new_lines = ["\n", caption + "\n", "\n"]
|
||||
lines[insert_at:insert_at] = new_lines
|
||||
|
||||
with open(md_path, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
log.append(f"DONE: {len(insertions)} captions inserted into {md_path}")
|
||||
return log
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post-check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def post_check(docx_path: str) -> list[str]:
|
||||
VML = "urn:schemas-microsoft-com:vml"
|
||||
O = "urn:schemas-microsoft-com:office:office"
|
||||
|
||||
issues: list[str] = []
|
||||
|
||||
with zipfile.ZipFile(docx_path) as z:
|
||||
doc = ET.fromstring(z.read("word/document.xml"))
|
||||
styles = ET.fromstring(z.read("word/styles.xml"))
|
||||
numbering = ET.fromstring(z.read("word/numbering.xml"))
|
||||
|
||||
style_name_by_id: dict[str, str] = {}
|
||||
style_by_name: dict[str, ET.Element] = {}
|
||||
for style in styles.findall(f"{{{W}}}style"):
|
||||
sid = style.get(f"{{{W}}}styleId")
|
||||
name_el = style.find(f"{{{W}}}name")
|
||||
name = name_el.get(f"{{{W}}}val") if name_el is not None else sid
|
||||
if sid:
|
||||
style_name_by_id[sid] = name
|
||||
if name:
|
||||
style_by_name[name] = style
|
||||
|
||||
# 1. Compact style
|
||||
compact = sum(
|
||||
1
|
||||
for p in doc.findall(f".//{{{W}}}p")
|
||||
if (ppr := p.find(f"{{{W}}}pPr")) is not None
|
||||
and (ps := ppr.find(f"{{{W}}}pStyle")) is not None
|
||||
and ps.get(f"{{{W}}}val") == "Compact"
|
||||
)
|
||||
if compact > 0:
|
||||
issues.append(f"ERROR: {compact} paragraphs with undefined 'Compact' style")
|
||||
|
||||
# 2. VML horizontal rules
|
||||
hr = sum(
|
||||
1
|
||||
for r in doc.findall(f".//{{{VML}}}rect")
|
||||
if r.get(f"{{{O}}}hr") == "t"
|
||||
)
|
||||
if hr > 0:
|
||||
issues.append(f"ERROR: {hr} VML horizontal rules (ugly dividers)")
|
||||
|
||||
# 3. Fonts
|
||||
defaults = styles.find(f"{{{W}}}docDefaults")
|
||||
if defaults is not None:
|
||||
rf = defaults.find(f".//{{{W}}}rFonts")
|
||||
if rf is not None:
|
||||
ascii_f = rf.get(f"{{{W}}}ascii", "?")
|
||||
ea_f = rf.get(f"{{{W}}}eastAsia", "?")
|
||||
if ascii_f == "Calibri":
|
||||
issues.append("WARN: docDefaults ascii font is Calibri, expected Times New Roman")
|
||||
if ea_f != "宋体":
|
||||
issues.append(f"WARN: docDefaults eastAsia font is {ea_f}, expected 宋体")
|
||||
|
||||
# 4. First-line indent (Normal style or docDefaults)
|
||||
has_indent = False
|
||||
for style in styles.findall(f"{{{W}}}style"):
|
||||
name_el = style.find(f"{{{W}}}name")
|
||||
if name_el is not None and name_el.get(f"{{{W}}}val") == "Normal":
|
||||
ppr = style.find(f"{{{W}}}pPr")
|
||||
if ppr is not None:
|
||||
ind = ppr.find(f"{{{W}}}ind")
|
||||
if ind is not None and ind.get(f"{{{W}}}firstLine"):
|
||||
has_indent = True
|
||||
break
|
||||
if not has_indent and defaults is not None:
|
||||
ppr_d = defaults.find(f".//{{{W}}}pPrDefault")
|
||||
if ppr_d is not None:
|
||||
ppr = ppr_d.find(f"{{{W}}}pPr")
|
||||
if ppr is not None:
|
||||
ind = ppr.find(f"{{{W}}}ind")
|
||||
if ind is not None and ind.get(f"{{{W}}}firstLine"):
|
||||
has_indent = True
|
||||
if not has_indent:
|
||||
issues.append("WARN: No first-line indent in Normal style or docDefaults")
|
||||
|
||||
# 5. Table borders
|
||||
tables = doc.findall(f".//{{{W}}}tbl")
|
||||
tables_no_borders = 0
|
||||
for tbl in tables:
|
||||
tpr = tbl.find(f"{{{W}}}tblPr")
|
||||
has_tbl_borders = tpr is not None and tpr.find(f"{{{W}}}tblBorders") is not None
|
||||
has_cell_borders = any(
|
||||
tc.find(f"{{{W}}}tcPr") is not None
|
||||
and tc.find(f"{{{W}}}tcPr").find(f"{{{W}}}tcBorders") is not None
|
||||
for tc in tbl.findall(f".//{{{W}}}tc")
|
||||
)
|
||||
if not has_tbl_borders and not has_cell_borders:
|
||||
tables_no_borders += 1
|
||||
if tables_no_borders > 0:
|
||||
issues.append(f"ERROR: {tables_no_borders}/{len(tables)} tables missing borders")
|
||||
|
||||
# 6. Keep-with-next for figure images and table captions
|
||||
image_keep_next_missing = 0
|
||||
table_caption_keep_next_missing = 0
|
||||
for p in doc.findall(f".//{{{W}}}p"):
|
||||
ppr = p.find(f"{{{W}}}pPr")
|
||||
if ppr is None:
|
||||
continue
|
||||
ps = ppr.find(f"{{{W}}}pStyle")
|
||||
sid = ps.get(f"{{{W}}}val") if ps is not None else None
|
||||
style_name = style_name_by_id.get(sid, sid or "")
|
||||
has_keep_next = ppr.find(f"{{{W}}}keepNext") is not None
|
||||
if style_name == "图" and not has_keep_next:
|
||||
image_keep_next_missing += 1
|
||||
if style_name == "表题1-1" and not has_keep_next:
|
||||
table_caption_keep_next_missing += 1
|
||||
if image_keep_next_missing > 0:
|
||||
issues.append(f"ERROR: {image_keep_next_missing} image paragraphs missing keep-with-next")
|
||||
if table_caption_keep_next_missing > 0:
|
||||
issues.append(f"ERROR: {table_caption_keep_next_missing} table captions missing keep-with-next")
|
||||
|
||||
# 7. Code block first-line indent
|
||||
code_style = None
|
||||
for style_name in ("Source Code", "SourceCode", "代码清单"):
|
||||
candidate = style_by_name.get(style_name)
|
||||
if candidate is not None:
|
||||
code_style = candidate
|
||||
break
|
||||
if code_style is not None:
|
||||
ppr = code_style.find(f"{{{W}}}pPr")
|
||||
if ppr is not None:
|
||||
ind = ppr.find(f"{{{W}}}ind")
|
||||
if ind is None:
|
||||
issues.append("ERROR: Code block style is missing explicit zero first-line indent override")
|
||||
else:
|
||||
if ind.get(f"{{{W}}}firstLine") != "0" or ind.get(f"{{{W}}}firstLineChars") != "0":
|
||||
issues.append("ERROR: Code block style still has first-line indentation")
|
||||
if ind.get(f"{{{W}}}hanging") or ind.get(f"{{{W}}}hangingChars"):
|
||||
issues.append("ERROR: Code block style still has hanging indentation")
|
||||
|
||||
# 8. List indentation should align with Chinese body-text first-line indent
|
||||
bullet_indent_issues = 0
|
||||
ordered_indent_issues = 0
|
||||
num_to_abs: dict[str, str] = {}
|
||||
abstract_lookup: dict[str, ET.Element] = {}
|
||||
for num in numbering.findall(f"{{{W}}}num"):
|
||||
num_id = num.get(f"{{{W}}}numId")
|
||||
abs_el = num.find(f"{{{W}}}abstractNumId")
|
||||
abs_id = abs_el.get(f"{{{W}}}val") if abs_el is not None else None
|
||||
if num_id and abs_id:
|
||||
num_to_abs[num_id] = abs_id
|
||||
for absnum in numbering.findall(f"{{{W}}}abstractNum"):
|
||||
abs_id = absnum.get(f"{{{W}}}abstractNumId")
|
||||
if abs_id:
|
||||
abstract_lookup[abs_id] = absnum
|
||||
|
||||
for p in doc.findall(f".//{{{W}}}p"):
|
||||
ppr = p.find(f"{{{W}}}pPr")
|
||||
if ppr is None:
|
||||
continue
|
||||
numpr = ppr.find(f"{{{W}}}numPr")
|
||||
if numpr is None:
|
||||
continue
|
||||
num_id_el = numpr.find(f"{{{W}}}numId")
|
||||
ilvl_el = numpr.find(f"{{{W}}}ilvl")
|
||||
if num_id_el is None:
|
||||
continue
|
||||
abs_id = num_to_abs.get(num_id_el.get(f"{{{W}}}val", ""))
|
||||
if not abs_id:
|
||||
continue
|
||||
absnum = abstract_lookup.get(abs_id)
|
||||
if absnum is None:
|
||||
continue
|
||||
ilvl = ilvl_el.get(f"{{{W}}}val", "0") if ilvl_el is not None else "0"
|
||||
try:
|
||||
ilvl_num = int(ilvl)
|
||||
except ValueError:
|
||||
ilvl_num = 0
|
||||
lvl = absnum.find(f"{{{W}}}lvl[@{{{W}}}ilvl='{ilvl}']")
|
||||
if lvl is None:
|
||||
continue
|
||||
ind = lvl.find(f"{{{W}}}pPr/{{{W}}}ind")
|
||||
num_fmt = lvl.find(f"{{{W}}}numFmt")
|
||||
is_bullet = num_fmt is not None and num_fmt.get(f"{{{W}}}val") == "bullet"
|
||||
is_ordered = _is_generic_ordered_level(lvl)
|
||||
if not is_bullet and not is_ordered:
|
||||
continue
|
||||
if ind is None:
|
||||
if is_bullet:
|
||||
bullet_indent_issues += 1
|
||||
else:
|
||||
ordered_indent_issues += 1
|
||||
continue
|
||||
if is_bullet:
|
||||
expected_left = str(int(EXPECTED_BULLET_LEFT) + ilvl_num * int(EXPECTED_LIST_STEP))
|
||||
if (
|
||||
ind.get(f"{{{W}}}left") != expected_left
|
||||
or ind.get(f"{{{W}}}hanging") != EXPECTED_BULLET_HANGING
|
||||
):
|
||||
bullet_indent_issues += 1
|
||||
else:
|
||||
expected_left = str(int(EXPECTED_ORDERED_LEFT) + ilvl_num * int(EXPECTED_LIST_STEP))
|
||||
if (
|
||||
ind.get(f"{{{W}}}left") != expected_left
|
||||
or ind.get(f"{{{W}}}hanging") != EXPECTED_BULLET_HANGING
|
||||
):
|
||||
ordered_indent_issues += 1
|
||||
if bullet_indent_issues > 0:
|
||||
issues.append(
|
||||
"ERROR: "
|
||||
f"{bullet_indent_issues} bullet list paragraphs still use over-indented list geometry "
|
||||
f"(expected left={EXPECTED_BULLET_LEFT}, hanging={EXPECTED_BULLET_HANGING})"
|
||||
)
|
||||
if ordered_indent_issues > 0:
|
||||
issues.append(
|
||||
"ERROR: "
|
||||
f"{ordered_indent_issues} ordered list paragraphs still use over-indented list geometry "
|
||||
f"(expected left={EXPECTED_ORDERED_LEFT}, hanging={EXPECTED_BULLET_HANGING})"
|
||||
)
|
||||
|
||||
# 9. Table cell paragraphs should not inherit body first-line indent
|
||||
table_cell_indent_issues = 0
|
||||
for tc in doc.findall(f".//{{{W}}}tc"):
|
||||
for p in tc.findall(f"{{{W}}}p"):
|
||||
ppr = p.find(f"{{{W}}}pPr")
|
||||
ind = ppr.find(f"{{{W}}}ind") if ppr is not None else None
|
||||
if ind is None:
|
||||
table_cell_indent_issues += 1
|
||||
continue
|
||||
if ind.get(f"{{{W}}}firstLine") not in ("0", None) or ind.get(f"{{{W}}}firstLineChars") not in ("0", None):
|
||||
table_cell_indent_issues += 1
|
||||
if table_cell_indent_issues > 0:
|
||||
issues.append(f"ERROR: {table_cell_indent_issues} table cell paragraphs still inherit first-line indentation")
|
||||
|
||||
# 10. Table and figure captions
|
||||
all_texts = []
|
||||
for p in doc.findall(f".//{{{W}}}p"):
|
||||
text = "".join(t.text or "" for t in p.findall(f".//{{{W}}}t"))
|
||||
if text.strip():
|
||||
all_texts.append(text.strip())
|
||||
|
||||
table_captions = [t for t in all_texts if re.match(r"^表\s*\d+[-.]\d+\s+\S", t)]
|
||||
figure_captions = [t for t in all_texts if re.match(r"^图\s*\d+[-.]\d+\s+\S", t)]
|
||||
|
||||
if len(tables) > 0 and len(table_captions) == 0:
|
||||
issues.append(f"ERROR: {len(tables)} tables found but 0 table captions (表X-Y)")
|
||||
elif len(tables) > len(table_captions):
|
||||
issues.append(f"WARN: {len(tables)} tables but only {len(table_captions)} table captions")
|
||||
|
||||
for kind, captions in [("表", table_captions), ("图", figure_captions)]:
|
||||
nums = []
|
||||
for cap in captions:
|
||||
m = re.match(rf"^{kind}\s*(\d+)[-.]\s*(\d+)", cap)
|
||||
if m:
|
||||
nums.append((int(m.group(1)), int(m.group(2))))
|
||||
if nums:
|
||||
chapter = nums[0][0]
|
||||
for i, (ch, seq) in enumerate(nums, 1):
|
||||
if ch != chapter:
|
||||
issues.append(f"WARN: {kind} caption #{i} has chapter {ch}, expected {chapter}")
|
||||
if seq != i:
|
||||
issues.append(f"WARN: {kind} caption #{i} is {kind}{ch}-{seq}, expected {kind}{chapter}-{i}")
|
||||
|
||||
if not issues:
|
||||
issues.append(
|
||||
f"OK: {len(tables)} tables, {len(table_captions)} table captions, "
|
||||
f"{len(figure_captions)} figure captions — all checks passed"
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 3 or sys.argv[1] not in ("pre", "post", "fix"):
|
||||
print(
|
||||
"Usage:\n"
|
||||
" validate_captions.py pre <source.md> — check only\n"
|
||||
" validate_captions.py fix <source.md> — auto-insert missing captions\n"
|
||||
" validate_captions.py post <output.docx> — check generated docx",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
mode = sys.argv[1]
|
||||
path = sys.argv[2]
|
||||
|
||||
if mode == "pre":
|
||||
results = pre_check(path)
|
||||
elif mode == "fix":
|
||||
results = auto_fix(path)
|
||||
else:
|
||||
results = post_check(path)
|
||||
|
||||
has_error = False
|
||||
for line in results:
|
||||
if line.startswith("ERROR"):
|
||||
has_error = True
|
||||
print(line)
|
||||
|
||||
return 1 if has_error else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user