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
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+131
View File
@@ -0,0 +1,131 @@
---
name: markdown-to-docx
description: Convert one Markdown file or a top-level folder of Markdown articles into DOCX with pandoc, preserving Obsidian image embeds, shared resources folders, captions, Mermaid diagrams, and optional Word reference templates. Use when exporting Markdown to Word documents.
---
# Skill: Markdown to DOCX
Use this skill when the user wants `.md` files exported to `.docx` with `pandoc`.
## What this skill does
- Converts one Markdown file or all top-level Markdown files in a directory.
- Creates one `.docx` per source `.md`.
- Preserves images by setting a broad `--resource-path`.
- Normalizes Obsidian image embeds like `![[image.png]]` and `![[image.png|697]]` before conversion.
- Repairs the common case where a figure caption and an Obsidian image were accidentally merged onto one line.
- Can render Markdown against a Word `.dotx`/`.docx` reference template and apply post-processing for polished Word output.
- Renders Mermaid code blocks to PNG images when Node.js and `npx` are available.
## Reference Template
A publisher reference template is available at:
```
SKILL_DIR/resources/machinery-industry-press-writing-template.dotx
```
This is the **机械工业出版社** Word template (`.dotx`). Use it as the `<template-dotx-or-docx>` argument when running the template workflow. When the user asks to export to Word without specifying a template, default to this one.
## Workflow
1. Confirm `pandoc` is installed with `pandoc --version`.
2. Inspect the source folder for Markdown files and image syntax if needed.
3. Run the bundled script from this skill folder:
```bash
SKILL_DIR="/path/to/markdown-to-docx"
"$SKILL_DIR/scripts/convert_markdown_to_docx.sh" \
"<source-path>" \
"<output-dir>" \
"[resource-root]"
```
## Parameters
- `source-path`: a single `.md` file or a directory that contains `.md` files.
- `output-dir`: destination directory for generated `.docx` files.
- `resource-root` (optional): root directory that contains shared assets such as `resources/`. If omitted, the script infers likely roots from the source location.
## Template Workflow
When the user provides a Word template:
### Step 1: Convert (auto-fixes captions)
The conversion script **automatically inserts missing table/figure captions** into the normalized markdown before calling pandoc. No manual pre-check is needed — the pipeline:
1. Normalizes Obsidian embeds and markdown syntax
2. Runs `validate_captions.py fix` to auto-insert any missing `表N-M` / `图N-M` captions (derived from table headers or surrounding context)
3. Converts with pandoc + Lua style filter
4. Post-processes the docx (fonts, styles, tables, borders, headers)
You can still run a manual pre-check to preview what will be fixed:
```bash
SKILL_DIR="/path/to/markdown-to-docx"
python3 "$SKILL_DIR/scripts/validate_captions.py" \
pre "<source-md>"
```
```bash
SKILL_DIR="/path/to/markdown-to-docx"
"$SKILL_DIR/scripts/render_markdown_with_dotx.sh" \
"<source-md>" \
"<output-docx>" \
"<template-dotx-or-docx>" \
"[book-title]" \
"[resource-root]" \
"[shortcut-template]"
```
This script automatically:
- Auto-inserts missing `表N-M` / `图N-M` captions (derived from table headers or surrounding context)
- Maps image blocks, figure/table captions to publisher paragraph styles via Lua filter
- Post-processes for code style, fonts (Times New Roman + 宋体), table borders, layout
- Rewrites unordered-list indentation so bullet text aligns with Chinese paragraph first-line indent instead of Word's default deep indent
- Rewrites ordered and unordered lists so the marker column aligns with the Chinese body paragraph's two-character first-line indent instead of drifting too far left
- Applies Word `keep with next` to image paragraphs and table captions so images stay with figure captions and captions stay with tables
- Removes the code-block first-line indent from the exported `Source Code` paragraph style
- Clears first-line indent inside every table cell paragraph so table content does not inherit body-text indentation
- Replaces header text with chapter title
- Suppresses template auto-numbering when headings already contain explicit chapter numbers
- Extracts figure explanations and shortens captions for editor style
- Optionally injects Word shortcut bindings from the original `.dotx` template
- Automatically preserves Word shortcut bindings when the provided template already contains `word/customizations.xml`
### Step 2: Post-check — validate generated docx
```bash
SKILL_DIR="/path/to/markdown-to-docx"
python3 "$SKILL_DIR/scripts/validate_captions.py" \
post "<output-docx>"
```
This checks:
1. No `Compact` style paragraphs (undefined style)
2. No VML horizontal rules (`o:hr="t"`)
3. Font defaults = Times New Roman + 宋体 (not Calibri)
4. First-line indent present in Normal style
5. All tables have borders (tblBorders or tcBorders)
6. Image paragraphs (`图`) and table captions (`表题1-1`) have `keep with next`
7. Code block style has no first-line indent
8. Ordered and unordered list geometry keeps the marker column aligned with Chinese paragraph first-line indent
9. Table cell paragraphs explicitly clear first-line indent
10. Table captions (表X-Y) present and sequentially numbered
11. Figure captions (图X-Y) present and sequentially numbered
**If post-check reports ERRORs, investigate and fix.** The most common post-check error is missing captions — which means the markdown source was missing them (go back to Step 1).
Current editorial rules are tracked in:
- `references/editorial-template-rules.md`
## Notes
- The script only converts top-level `.md` files when a directory is passed.
- Source Markdown files are not modified. Normalization happens in a temporary directory.
- If the user wants recursive conversion, patch the script first instead of reimplementing the workflow ad hoc.
- If the publisher template contains `word/customizations.xml`, the render script now auto-injects those keymap customizations into the generated `.docx`. You can still pass an explicit `shortcut-template` when the shortcut source differs from the reference template.
- **MANDATORY**: Always run post-check after conversion. Never skip validation. Caption auto-fix runs automatically during conversion.
@@ -0,0 +1,7 @@
interface:
display_name: "Markdown to DOCX"
short_description: "Convert Markdown files to DOCX with pandoc, images, captions, and optional Word templates"
default_prompt: "Use $markdown-to-docx to convert one Markdown file or a folder of articles into .docx files with pandoc."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,34 @@
# DOCX Template Rules
This file records Word-export rules that the template rendering pipeline tries to enforce.
## Non-negotiable layout rules
- Prefer the latest reviewed `.docx` as the `--reference-doc` when available; treat a `.dotx` as a base template.
- If the reference template contains Word shortcut bindings, generated `.docx` files should retain `word/customizations.xml` so style hotkeys such as `ALT+1` remain available. The render pipeline auto-copies these bindings when the reference template already contains them.
- Replace only the default header text. Keep first-page and even-page headers blank unless the editor file shows otherwise.
- Strip Pandoc body styles such as `FirstParagraph`, `BodyText`, and `Compact` so the reference template's body style wins.
- Keep screenshot/image paragraphs mapped to the custom style `图`.
- Map figure captions to the custom style `图题`.
- Apply Word `keep with next` to every `图` paragraph so each image stays on the same page as the following figure caption.
- Apply Word `keep with next` to every `表题1-1` paragraph so each table caption stays on the same page as the following table.
- Map only real note/warning labels such as `注:`, `注意:`, and `关键注意:` to the custom style `注意`.
- Keep generic explanatory lead-ins such as `说明:` and `解释:` in body text, or rewrite them into prose in the source manuscript.
- Remove the first-line indent from exported code-block paragraphs (`Source Code` / `代码清单`) so code starts flush-left inside the code block.
- Clear first-line indent for all paragraphs inside Word table cells so table content does not visually inherit body-text indentation.
- Align unordered-list text with the Chinese body-text first-line indent. Avoid Word's default deep bullet indentation; level-0 bullet text should start at the same visual column as a normal Chinese paragraph first line.
## Figure and table caption rules
- Figure captions must use the short form `图1-1 标题`.
- Table captions must use the short form `表1-1 标题`.
- Use hyphen serials such as `1-1`, not dotted serials such as `1.1`.
- Do not place explanatory text inside figure captions.
- If a source figure caption contains a second explanatory sentence, move that sentence into body text and keep only the short title in the caption.
- Figure and table captions should not end with a full stop.
## Editorial writing rules reflected in export
- When the text says `确认`, `确保`, or `检查`, provide an explicit verification action or acceptance signal nearby instead of leaving the confirmation vague.
- Prefer `如图1-1所示` style references in body text when explanation needs to point readers back to a screenshot.
- Treat structural rewrites separately from template rules. Examples: adding bridge sentences, converting numbered explanation lists into prose, or rewriting a subsection outline.
@@ -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]+\]\]}{![](<$2>)\n\n*图:$1*}mg;
s{!\[\[([^]|]+)\|[0-9]+\]\]}{![](<$1>)}g;
s{!\[\[([^]|]+)\]\]}{![](<$1>)}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]+\]\]}{![](<$2>)\n\n图:$1}mg;
s{!\[\[([^]|]+)\|[0-9]+\]\]}{![](<$1>)}g;
s{!\[\[([^]|]+)\]\]}{![](<$1>)}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"![](resources/{out_name})")
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())