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,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.
|
||||
@@ -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.
|
||||
BIN
Binary file not shown.
@@ -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