feat(convert): template-driven docx postprocess + pdf table left align

- Move docx post-processing from convert.py to template-specific postprocess.py hooks
- Resolve relative markdown image paths before pandoc for docx and pdf
- Auto-generate centered figure captions (图 1, 图 2...) in docx
- Center tables and apply three-line table borders in docx
- Left-align tables in PDF via template.typ show rule
This commit is contained in:
Zhengshou Lai
2026-06-28 11:27:14 +08:00
parent 3ea86867e3
commit 75bb7dec32
5 changed files with 501 additions and 307 deletions
+32 -305
View File
@@ -1,12 +1,13 @@
"""Universal format conversion.""" """Universal format conversion."""
import tempfile
from pathlib import Path from pathlib import Path
import click import click
from pypdf import PdfReader, PdfWriter from pypdf import PdfReader, PdfWriter
from PIL import Image from PIL import Image
from mytoolkit.md_to_pdf import build_pdf from mytoolkit.md_to_pdf import build_pdf, resolve_markdown_image_paths
from mytoolkit import templates_registry from mytoolkit import templates_registry
from mytoolkit.utils import handle_errors, run_command from mytoolkit.utils import handle_errors, run_command
@@ -96,8 +97,9 @@ def convert_cmd(inputs, output, template, quality, density, reference_doc, bookm
# --- md → docx --- # --- md → docx ---
if all(p.suffix.lower() == ".md" for p in input_paths) and out_ext == ".docx": if all(p.suffix.lower() == ".md" for p in input_paths) and out_ext == ".docx":
ref_doc = reference_doc ref_doc = reference_doc
template_dir = templates_registry.get_md_to_docx_root() / template
if not ref_doc: if not ref_doc:
ref_path = templates_registry.get_md_to_docx_root() / template / "template.docx" ref_path = template_dir / "template.docx"
if ref_path.exists(): if ref_path.exists():
ref_doc = str(ref_path) ref_doc = str(ref_path)
else: else:
@@ -109,12 +111,34 @@ def convert_cmd(inputs, output, template, quality, density, reference_doc, bookm
msg += f" (template={ref_doc})" msg += f" (template={ref_doc})"
click.echo(msg) click.echo(msg)
return return
cmd = ["pandoc"] + [str(p) for p in input_paths] + ["-o", str(out_path)] # Resolve relative image paths so pandoc can find resources regardless of cwd.
if ref_doc: resolved_inputs = []
cmd.extend(["--reference-doc", ref_doc]) try:
run_command(cmd, check=True) for p in input_paths:
_fix_docx_postprocess(out_path, reference_doc_path=ref_doc) content = p.read_text(encoding="utf-8")
click.secho(f"Word document generated: {output}", fg="green") content = resolve_markdown_image_paths(content, p.parent)
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False, encoding="utf-8") as f:
f.write(content)
resolved_inputs.append(Path(f.name))
cmd = ["pandoc"] + [str(p) for p in resolved_inputs] + ["-o", str(out_path)]
if ref_doc:
cmd.extend(["--reference-doc", ref_doc])
run_command(cmd, check=True)
# Run template-specific post-processor if present.
postprocess_path = template_dir / "postprocess.py"
if postprocess_path.exists():
import importlib.util
spec = importlib.util.spec_from_file_location(
f"md_to_docx_{template}_postprocess", str(postprocess_path)
)
if spec is not None and spec.loader is not None:
pp_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(pp_module)
pp_module.postprocess(out_path, reference_doc_path=ref_doc)
click.secho(f"Word document generated: {output}", fg="green")
finally:
for tmp in resolved_inputs:
tmp.unlink(missing_ok=True)
return return
# --- md → pdf --- # --- md → pdf ---
@@ -207,300 +231,3 @@ def _images_to_pdf(images: list[Path], output: Path) -> None:
writer.write(f) writer.write(f)
def _fix_docx_postprocess(docx_path: Path, reference_doc_path: str | None = None) -> None:
"""Post-process pandoc-generated docx for Chinese academic typography.
Applies the following fixes:
- Apply paragraph-level typography from reference-doc Normal style
(first-line indent, spacing, line spacing, justification)
- Convert BodyText paragraphs to Normal and apply same typography
- Add heading spacing before/after Heading 1-4
- Add 6pt spacing after reference entries (detected after "参考文献")
- Three-line table borders (1.5pt top/bottom, 0.75pt header separator)
- Remove table styles that override custom borders
- Remove first-line indent inside table cells
- Add 6pt spacing before paragraphs following tables
- Convert '---' horizontal rules to HorizontalRule style (no indent)
- Remove first-line indent from empty paragraphs
- Inject HorizontalRule style into styles.xml
"""
try:
import lxml.etree as ET
except ImportError:
return
import zipfile
import os
import copy
from tempfile import TemporaryDirectory
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
wtag = lambda t: "{" + W + "}" + t
def _paragraph_text(p):
parts = []
for r in p.findall(wtag("r")):
t = r.find(wtag("t"))
if t is not None and t.text:
parts.append(t.text)
return "".join(parts)
def _copy_ppr_children(src_ppr, dst_ppr):
for child in src_ppr:
tag_name = child.tag.split("}")[-1]
# Remove existing same-tag elements
for existing in list(dst_ppr.findall(wtag(tag_name))):
dst_ppr.remove(existing)
dst_ppr.append(copy.deepcopy(child))
with TemporaryDirectory() as workdir:
with zipfile.ZipFile(docx_path, "r") as z:
z.extractall(workdir)
# --- Load reference-doc Normal style paragraph properties ---
normal_ppr = None
if reference_doc_path and Path(reference_doc_path).exists():
try:
with zipfile.ZipFile(reference_doc_path, "r") as z:
styles_content = z.read("word/styles.xml")
ref_styles_root = ET.fromstring(styles_content)
for style in ref_styles_root.findall(wtag("style")):
if style.get(wtag("styleId")) == "Normal":
normal_ppr = style.find(wtag("pPr"))
break
except Exception:
normal_ppr = None
# Heading spacing in twips (1pt = 20 twips)
heading_spacing = {
"Heading1": {"before": "360", "after": "120"},
"Heading2": {"before": "280", "after": "120"},
"Heading3": {"before": "200", "after": "80"},
"Heading4": {"before": "160", "after": "80"},
}
doc_xml = os.path.join(workdir, "word", "document.xml")
tree = ET.parse(doc_xml)
root = tree.getroot()
body = root.find(".//" + wtag("body"))
if body is None:
return
# --- 1. Table borders (three-line) ---
for tbl in body.findall(".//" + wtag("tbl")):
tblPr = tbl.find(wtag("tblPr"))
if tblPr is None:
tblPr = ET.SubElement(tbl, wtag("tblPr"))
tbl.insert(0, tblPr)
# Remove table style so borders are not overridden
for ts in list(tblPr.findall(wtag("tblStyle"))):
tblPr.remove(ts)
for existing in list(tblPr.findall(wtag("tblBorders"))):
tblPr.remove(existing)
borders = ET.SubElement(tblPr, wtag("tblBorders"))
for pos, sz in [("top", "12"), ("bottom", "12")]:
b = ET.SubElement(borders, wtag(pos))
b.set(wtag("val"), "single")
b.set(wtag("sz"), sz)
b.set(wtag("space"), "0")
b.set(wtag("color"), "auto")
for pos in ["left", "right", "insideH", "insideV"]:
b = ET.SubElement(borders, wtag(pos))
b.set(wtag("val"), "none")
b.set(wtag("sz"), "0")
b.set(wtag("space"), "0")
b.set(wtag("color"), "auto")
# Header row bottom border
rows = tbl.findall(wtag("tr"))
if rows:
for tc in rows[0].findall(wtag("tc")):
tcPr = tc.find(wtag("tcPr"))
if tcPr is None:
tcPr = ET.SubElement(tc, wtag("tcPr"))
for existing in list(tcPr.findall(wtag("tcBorders"))):
tcPr.remove(existing)
tcBorders = ET.SubElement(tcPr, wtag("tcBorders"))
for pos in ["top", "left", "right"]:
b = ET.SubElement(tcBorders, wtag(pos))
b.set(wtag("val"), "none")
b.set(wtag("sz"), "0")
b.set(wtag("space"), "0")
b.set(wtag("color"), "auto")
bottom = ET.SubElement(tcBorders, wtag("bottom"))
bottom.set(wtag("val"), "single")
bottom.set(wtag("sz"), "6")
bottom.set(wtag("space"), "0")
bottom.set(wtag("color"), "auto")
# --- 2. Remove indent in table cells ---
for tc in body.findall(".//" + wtag("tc")):
for p in tc.findall(wtag("p")):
pPr = p.find(wtag("pPr"))
if pPr is None:
pPr = ET.SubElement(p, wtag("pPr"))
for ind in list(pPr.findall(wtag("ind"))):
pPr.remove(ind)
ET.SubElement(pPr, wtag("ind")).set(wtag("firstLine"), "0")
spacing = pPr.find(wtag("spacing"))
if spacing is not None:
spacing.set(wtag("before"), "0")
# --- 3. Horizontal rule + empty paragraphs ---
for p in body.findall(wtag("p")):
p_str = ET.tostring(p, encoding="unicode")
is_hr = "o:hr=" in p_str and "o:hrstd=" in p_str
has_text = False
for r in p.findall(wtag("r")):
t = r.find(wtag("t"))
if t is not None and t.text and t.text.strip():
has_text = True
break
if is_hr:
pPr = p.find(wtag("pPr"))
if pPr is None:
pPr = ET.SubElement(p, wtag("pPr"))
p.insert(0, pPr)
for ps in list(pPr.findall(wtag("pStyle"))):
pPr.remove(ps)
ET.SubElement(pPr, wtag("pStyle")).set(wtag("val"), "HorizontalRule")
for ind in list(pPr.findall(wtag("ind"))):
pPr.remove(ind)
ET.SubElement(pPr, wtag("ind")).set(wtag("firstLine"), "0")
spacing = pPr.find(wtag("spacing"))
if spacing is not None:
spacing.set(wtag("before"), "0")
elif not has_text:
pPr = p.find(wtag("pPr"))
if pPr is None:
pPr = ET.SubElement(p, wtag("pPr"))
for ind in list(pPr.findall(wtag("ind"))):
pPr.remove(ind)
ET.SubElement(pPr, wtag("ind")).set(wtag("firstLine"), "0")
spacing = pPr.find(wtag("spacing"))
if spacing is not None:
spacing.set(wtag("before"), "0")
# --- 4. Apply paragraph-level typography from reference doc ---
in_references = False
for p in body.findall(wtag("p")):
# Skip paragraphs inside table cells (handled separately)
parent = p.getparent()
if parent is not None and parent.tag == wtag("tc"):
continue
pPr = p.find(wtag("pPr"))
if pPr is None:
pPr = ET.SubElement(p, wtag("pPr"))
p.insert(0, pPr)
pStyle = pPr.find(wtag("pStyle"))
style_id = pStyle.get(wtag("val")) if pStyle is not None else "Normal"
# Detect references section by heading text
if style_id.startswith("Heading") and "参考文献" in _paragraph_text(p):
in_references = True
# Apply heading spacing
hs = heading_spacing.get(style_id)
if hs is not None:
spacing = pPr.find(wtag("spacing"))
if spacing is None:
spacing = ET.SubElement(pPr, wtag("spacing"))
spacing.set(wtag("before"), hs["before"])
spacing.set(wtag("after"), hs["after"])
continue
# Reference entry formatting
if in_references:
text = _paragraph_text(p).strip()
if not text:
# Empty paragraph in refs: remove indent but keep normal spacing
for ind in list(pPr.findall(wtag("ind"))):
pPr.remove(ind)
ET.SubElement(pPr, wtag("ind")).set(wtag("firstLine"), "0")
continue
# Convert to Normal and apply normal pPr
if pStyle is None:
pStyle = ET.SubElement(pPr, wtag("pStyle"))
pStyle.set(wtag("val"), "Normal")
if normal_ppr is not None:
_copy_ppr_children(normal_ppr, pPr)
spacing = pPr.find(wtag("spacing"))
if spacing is None:
spacing = ET.SubElement(pPr, wtag("spacing"))
spacing.set(wtag("after"), "120") # 6pt after each reference
continue
# BodyText / FirstParagraph -> Normal
if style_id in ("BodyText", "FirstParagraph"):
if pStyle is None:
pStyle = ET.SubElement(pPr, wtag("pStyle"))
pStyle.set(wtag("val"), "Normal")
style_id = "Normal"
# Apply Normal paragraph properties from reference doc
if style_id in ("Normal", "FirstParagraph") and normal_ppr is not None:
_copy_ppr_children(normal_ppr, pPr)
# Apply heading spacing
if style_id in heading_spacing:
spacing = pPr.find(wtag("spacing"))
if spacing is None:
spacing = ET.SubElement(pPr, wtag("spacing"))
spacing.set(wtag("before"), heading_spacing[style_id]["before"])
spacing.set(wtag("after"), heading_spacing[style_id]["after"])
# --- 5. Add spacing after tables ---
for tbl in body.findall(wtag("tbl")):
tbl_idx = list(body).index(tbl)
for i in range(tbl_idx + 1, len(list(body))):
next_el = list(body)[i]
if next_el.tag == wtag("p"):
pPr = next_el.find(wtag("pPr"))
if pPr is None:
pPr = ET.SubElement(next_el, wtag("pPr"))
spacing = pPr.find(wtag("spacing"))
if spacing is None:
spacing = ET.SubElement(pPr, wtag("spacing"))
spacing.set(wtag("before"), "120")
break
# --- 6. Inject suppressAutoHyphens into all paragraphs ---
for p in body.findall(wtag("p")):
pPr = p.find(wtag("pPr"))
if pPr is None:
pPr = ET.SubElement(p, wtag("pPr"))
p.insert(0, pPr)
if pPr.find(wtag("suppressAutoHyphens")) is None:
ET.SubElement(pPr, wtag("suppressAutoHyphens"))
tree.write(doc_xml, xml_declaration=True, encoding="UTF-8", pretty_print=False)
# --- 7. Inject HorizontalRule style ---
styles_xml = os.path.join(workdir, "word", "styles.xml")
styles_tree = ET.parse(styles_xml)
styles_root = styles_tree.getroot()
normal_style = None
for style in styles_root.findall(wtag("style")):
if style.get(wtag("styleId")) == "Normal":
normal_style = style
break
if normal_style is not None:
for style in list(styles_root.findall(wtag("style"))):
if style.get(wtag("styleId")) == "HorizontalRule":
styles_root.remove(style)
hr_style = ET.Element(wtag("style"))
hr_style.set(wtag("type"), "paragraph")
hr_style.set(wtag("styleId"), "HorizontalRule")
ET.SubElement(hr_style, wtag("name")).set(wtag("val"), "Horizontal Rule")
ET.SubElement(hr_style, wtag("basedOn")).set(wtag("val"), "Normal")
pPr = ET.SubElement(hr_style, wtag("pPr"))
ET.SubElement(pPr, wtag("ind")).set(wtag("firstLine"), "0")
idx = list(styles_root).index(normal_style)
styles_root.insert(idx + 1, hr_style)
styles_tree.write(styles_xml, xml_declaration=True, encoding="UTF-8", pretty_print=False)
with zipfile.ZipFile(docx_path, "w", zipfile.ZIP_DEFLATED) as zout:
for rd, _, files in os.walk(workdir):
for file in files:
fp = os.path.join(rd, file)
zout.write(fp, os.path.relpath(fp, workdir))
+49 -2
View File
@@ -155,12 +155,59 @@ def preprocess_typst_callouts(md: str) -> str:
return "".join(out) return "".join(out)
def resolve_markdown_image_paths(md_content: str, base_dir: Path) -> str:
"""Resolve relative image paths in Markdown to absolute paths.
Supports Markdown image syntax ``![alt](src)`` and HTML ``<img>`` tags.
Skips URLs, data URIs, and anchor-only references. Missing files are left
unchanged so pandoc/typst can report the original path in error messages.
"""
def _replace_md_image(match: re.Match) -> str:
alt = match.group(1)
src = match.group(2)
if src.startswith(("http://", "https://", "data:", "#")):
return match.group(0)
p = Path(src)
if p.is_absolute():
return match.group(0)
abs_path = (base_dir / p).resolve()
if abs_path.exists():
return f"![{alt}]({abs_path})"
return match.group(0)
md_content = re.sub(r"!\[(.*?)\]\((.*?)\)", _replace_md_image, md_content)
def _replace_html_img(match: re.Match) -> str:
pre = match.group(1)
src = match.group(2)
post = match.group(3)
if src.startswith(("http://", "https://", "data:", "#")):
return match.group(0)
p = Path(src)
if p.is_absolute():
return match.group(0)
abs_path = (base_dir / p).resolve()
if abs_path.exists():
return f"{pre}{abs_path}{post}"
return match.group(0)
md_content = re.sub(
r'(<img[^>]+src=["\'])([^"\']+)(["\'][^>]*>)',
_replace_html_img,
md_content,
flags=re.IGNORECASE,
)
return md_content
def md_to_typ(inputs: list[Path]) -> str: def md_to_typ(inputs: list[Path]) -> str:
"""Convert Markdown files to Typst via pandoc.""" """Convert Markdown files to Typst via pandoc."""
md_content = "" md_content = ""
for inp in inputs: for inp in inputs:
with open(inp, "r", encoding="utf-8") as f: content = inp.read_text(encoding="utf-8")
md_content += f.read() + "\n\n" content = resolve_markdown_image_paths(content, inp.parent)
md_content += content + "\n\n"
md_content = preprocess_typst_callouts(md_content) md_content = preprocess_typst_callouts(md_content)
result = run_command( result = run_command(
["pandoc", "-f", "markdown-citations", "-t", "typst", "-"], ["pandoc", "-f", "markdown-citations", "-t", "typst", "-"],
+404
View File
@@ -0,0 +1,404 @@
"""Default post-processing for md→docx conversion.
This script is loaded dynamically by mytoolkit.convert after pandoc has
produced the docx. The entry point is ``postprocess(docx_path, reference_doc_path)``.
"""
from pathlib import Path
def postprocess(docx_path: Path, reference_doc_path: str | None = None) -> None:
"""Post-process pandoc-generated docx for Chinese academic typography.
Applies the following fixes:
- Center image paragraphs and auto-generate captions (图 1, 图 2, ...)
- Inject Caption style into styles.xml
- Apply paragraph-level typography from reference-doc Normal style
(first-line indent, spacing, line spacing, justification)
- Convert BodyText paragraphs to Normal and apply same typography
- Add heading spacing before/after Heading 1-4
- Add 6pt spacing after reference entries (detected after "参考文献")
- Three-line table borders (1.5pt top/bottom, 0.75pt header separator)
- Center tables on the page
- Remove table styles that override custom borders
- Remove first-line indent inside table cells
- Add 6pt spacing before paragraphs following tables
- Convert '---' horizontal rules to HorizontalRule style (no indent)
- Remove first-line indent from empty paragraphs
- Inject HorizontalRule style into styles.xml
"""
try:
import lxml.etree as ET
except ImportError:
return
import zipfile
import os
import copy
from tempfile import TemporaryDirectory
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
wtag = lambda t: "{" + W + "}" + t
def _paragraph_text(p):
parts = []
for r in p.findall(wtag("r")):
t = r.find(wtag("t"))
if t is not None and t.text:
parts.append(t.text)
return "".join(parts)
def _copy_ppr_children(src_ppr, dst_ppr):
for child in src_ppr:
tag_name = child.tag.split("}")[-1]
# Remove existing same-tag elements
for existing in list(dst_ppr.findall(wtag(tag_name))):
dst_ppr.remove(existing)
dst_ppr.append(copy.deepcopy(child))
with TemporaryDirectory() as workdir:
with zipfile.ZipFile(docx_path, "r") as z:
z.extractall(workdir)
# --- Load reference-doc Normal style paragraph properties ---
normal_ppr = None
if reference_doc_path and Path(reference_doc_path).exists():
try:
with zipfile.ZipFile(reference_doc_path, "r") as z:
styles_content = z.read("word/styles.xml")
ref_styles_root = ET.fromstring(styles_content)
for style in ref_styles_root.findall(wtag("style")):
if style.get(wtag("styleId")) == "Normal":
normal_ppr = style.find(wtag("pPr"))
break
except Exception:
normal_ppr = None
# Heading spacing in twips (1pt = 20 twips)
heading_spacing = {
"Heading1": {"before": "360", "after": "120"},
"Heading2": {"before": "280", "after": "120"},
"Heading3": {"before": "200", "after": "80"},
"Heading4": {"before": "160", "after": "80"},
}
doc_xml = os.path.join(workdir, "word", "document.xml")
tree = ET.parse(doc_xml)
root = tree.getroot()
body = root.find(".//" + wtag("body"))
if body is None:
return
# --- 1. Table borders (three-line) + centering ---
for tbl in body.findall(".//" + wtag("tbl")):
tblPr = tbl.find(wtag("tblPr"))
if tblPr is None:
tblPr = ET.SubElement(tbl, wtag("tblPr"))
tbl.insert(0, tblPr)
# Remove table style so borders are not overridden
for ts in list(tblPr.findall(wtag("tblStyle"))):
tblPr.remove(ts)
# Center the table on the page.
for existing in list(tblPr.findall(wtag("jc"))):
tblPr.remove(existing)
jc = ET.SubElement(tblPr, wtag("jc"))
jc.set(wtag("val"), "center")
for existing in list(tblPr.findall(wtag("tblBorders"))):
tblPr.remove(existing)
borders = ET.SubElement(tblPr, wtag("tblBorders"))
for pos, sz in [("top", "12"), ("bottom", "12")]:
b = ET.SubElement(borders, wtag(pos))
b.set(wtag("val"), "single")
b.set(wtag("sz"), sz)
b.set(wtag("space"), "0")
b.set(wtag("color"), "auto")
for pos in ["left", "right", "insideH", "insideV"]:
b = ET.SubElement(borders, wtag(pos))
b.set(wtag("val"), "none")
b.set(wtag("sz"), "0")
b.set(wtag("space"), "0")
b.set(wtag("color"), "auto")
# Header row bottom border
rows = tbl.findall(wtag("tr"))
if rows:
for tc in rows[0].findall(wtag("tc")):
tcPr = tc.find(wtag("tcPr"))
if tcPr is None:
tcPr = ET.SubElement(tc, wtag("tcPr"))
for existing in list(tcPr.findall(wtag("tcBorders"))):
tcPr.remove(existing)
tcBorders = ET.SubElement(tcPr, wtag("tcBorders"))
for pos in ["top", "left", "right"]:
b = ET.SubElement(tcBorders, wtag(pos))
b.set(wtag("val"), "none")
b.set(wtag("sz"), "0")
b.set(wtag("space"), "0")
b.set(wtag("color"), "auto")
bottom = ET.SubElement(tcBorders, wtag("bottom"))
bottom.set(wtag("val"), "single")
bottom.set(wtag("sz"), "6")
bottom.set(wtag("space"), "0")
bottom.set(wtag("color"), "auto")
# --- 2. Image captions and centering ---
def _set_paragraph_alignment(p, align):
pPr = p.find(wtag("pPr"))
if pPr is None:
pPr = ET.SubElement(p, wtag("pPr"))
p.insert(0, pPr)
for jc in list(pPr.findall(wtag("jc"))):
pPr.remove(jc)
jc = ET.SubElement(pPr, wtag("jc"))
jc.set(wtag("val"), align)
def _has_drawing(p):
return p.find(".//" + wtag("drawing")) is not None
drawing_ns = "http://schemas.openxmlformats.org/drawingml/2006/main"
image_count = 0
# Iterate over a snapshot because we insert caption paragraphs and may remove ImageCaption.
for p in list(body.findall(wtag("p"))):
if not _has_drawing(p):
continue
# Center the image paragraph.
_set_paragraph_alignment(p, "center")
image_count += 1
# Gather alt text: prefer pandoc-generated ImageCaption, fallback to drawing docPr.
alt = ""
p_idx = list(body).index(p)
next_p = body[p_idx + 1] if p_idx + 1 < len(list(body)) else None
if next_p is not None and next_p.tag == wtag("p"):
next_style = next_p.find(".//" + wtag("pStyle"))
if next_style is not None and next_style.get(wtag("val")) == "ImageCaption":
parts = []
for r in next_p.findall(wtag("r")):
t = r.find(wtag("t"))
if t is not None and t.text:
parts.append(t.text)
if parts:
alt = "".join(parts).strip()
body.remove(next_p)
if not alt:
drawing = p.find(".//" + wtag("drawing"))
if drawing is not None:
docPr = drawing.find(f".//{{{drawing_ns}}}docPr")
if docPr is not None:
alt = docPr.get("descr", "").strip()
caption_text = f"{image_count}"
if alt:
caption_text = f"{image_count} {alt}"
# Insert caption paragraph after the image paragraph.
caption_p = ET.Element(wtag("p"))
caption_pPr = ET.SubElement(caption_p, wtag("pPr"))
ET.SubElement(caption_pPr, wtag("pStyle")).set(wtag("val"), "Caption")
_set_paragraph_alignment(caption_p, "center")
ET.SubElement(caption_pPr, wtag("ind")).set(wtag("firstLine"), "0")
spacing = ET.SubElement(caption_pPr, wtag("spacing"))
spacing.set(wtag("before"), "60")
spacing.set(wtag("after"), "120")
r = ET.SubElement(caption_p, wtag("r"))
rPr = ET.SubElement(r, wtag("rPr"))
ET.SubElement(rPr, wtag("sz")).set(wtag("val"), "22")
ET.SubElement(rPr, wtag("szCs")).set(wtag("val"), "22")
t = ET.SubElement(r, wtag("t"))
t.text = caption_text
# Recompute index because we may have removed the next paragraph.
p_idx = list(body).index(p)
body.insert(p_idx + 1, caption_p)
# --- 3. Remove indent in table cells ---
for tc in body.findall(".//" + wtag("tc")):
for p in tc.findall(wtag("p")):
pPr = p.find(wtag("pPr"))
if pPr is None:
pPr = ET.SubElement(p, wtag("pPr"))
for ind in list(pPr.findall(wtag("ind"))):
pPr.remove(ind)
ET.SubElement(pPr, wtag("ind")).set(wtag("firstLine"), "0")
spacing = pPr.find(wtag("spacing"))
if spacing is not None:
spacing.set(wtag("before"), "0")
# --- 4. Horizontal rule + empty paragraphs ---
for p in body.findall(wtag("p")):
p_str = ET.tostring(p, encoding="unicode")
is_hr = "o:hr=" in p_str and "o:hrstd=" in p_str
has_text = False
for r in p.findall(wtag("r")):
t = r.find(wtag("t"))
if t is not None and t.text and t.text.strip():
has_text = True
break
if is_hr:
pPr = p.find(wtag("pPr"))
if pPr is None:
pPr = ET.SubElement(p, wtag("pPr"))
p.insert(0, pPr)
for ps in list(pPr.findall(wtag("pStyle"))):
pPr.remove(ps)
ET.SubElement(pPr, wtag("pStyle")).set(wtag("val"), "HorizontalRule")
for ind in list(pPr.findall(wtag("ind"))):
pPr.remove(ind)
ET.SubElement(pPr, wtag("ind")).set(wtag("firstLine"), "0")
spacing = pPr.find(wtag("spacing"))
if spacing is not None:
spacing.set(wtag("before"), "0")
elif not has_text:
pPr = p.find(wtag("pPr"))
if pPr is None:
pPr = ET.SubElement(p, wtag("pPr"))
for ind in list(pPr.findall(wtag("ind"))):
pPr.remove(ind)
ET.SubElement(pPr, wtag("ind")).set(wtag("firstLine"), "0")
spacing = pPr.find(wtag("spacing"))
if spacing is not None:
spacing.set(wtag("before"), "0")
# --- 5. Apply paragraph-level typography from reference doc ---
in_references = False
for p in body.findall(wtag("p")):
# Skip paragraphs inside table cells (handled separately)
parent = p.getparent()
if parent is not None and parent.tag == wtag("tc"):
continue
pPr = p.find(wtag("pPr"))
if pPr is None:
pPr = ET.SubElement(p, wtag("pPr"))
p.insert(0, pPr)
pStyle = pPr.find(wtag("pStyle"))
style_id = pStyle.get(wtag("val")) if pStyle is not None else "Normal"
# Detect references section by heading text
if style_id.startswith("Heading") and "参考文献" in _paragraph_text(p):
in_references = True
# Apply heading spacing
hs = heading_spacing.get(style_id)
if hs is not None:
spacing = pPr.find(wtag("spacing"))
if spacing is None:
spacing = ET.SubElement(pPr, wtag("spacing"))
spacing.set(wtag("before"), hs["before"])
spacing.set(wtag("after"), hs["after"])
continue
# Reference entry formatting
if in_references:
text = _paragraph_text(p).strip()
if not text:
# Empty paragraph in refs: remove indent but keep normal spacing
for ind in list(pPr.findall(wtag("ind"))):
pPr.remove(ind)
ET.SubElement(pPr, wtag("ind")).set(wtag("firstLine"), "0")
continue
# Convert to Normal and apply normal pPr
if pStyle is None:
pStyle = ET.SubElement(pPr, wtag("pStyle"))
pStyle.set(wtag("val"), "Normal")
if normal_ppr is not None:
_copy_ppr_children(normal_ppr, pPr)
spacing = pPr.find(wtag("spacing"))
if spacing is None:
spacing = ET.SubElement(pPr, wtag("spacing"))
spacing.set(wtag("after"), "120") # 6pt after each reference
continue
# BodyText / FirstParagraph -> Normal
if style_id in ("BodyText", "FirstParagraph"):
if pStyle is None:
pStyle = ET.SubElement(pPr, wtag("pStyle"))
pStyle.set(wtag("val"), "Normal")
style_id = "Normal"
# Apply Normal paragraph properties from reference doc
if style_id in ("Normal", "FirstParagraph") and normal_ppr is not None:
_copy_ppr_children(normal_ppr, pPr)
# Apply heading spacing
if style_id in heading_spacing:
spacing = pPr.find(wtag("spacing"))
if spacing is None:
spacing = ET.SubElement(pPr, wtag("spacing"))
spacing.set(wtag("before"), heading_spacing[style_id]["before"])
spacing.set(wtag("after"), heading_spacing[style_id]["after"])
# --- 6. Add spacing after tables ---
for tbl in body.findall(wtag("tbl")):
tbl_idx = list(body).index(tbl)
for i in range(tbl_idx + 1, len(list(body))):
next_el = list(body)[i]
if next_el.tag == wtag("p"):
pPr = next_el.find(wtag("pPr"))
if pPr is None:
pPr = ET.SubElement(next_el, wtag("pPr"))
spacing = pPr.find(wtag("spacing"))
if spacing is None:
spacing = ET.SubElement(pPr, wtag("spacing"))
spacing.set(wtag("before"), "120")
break
# --- 7. Inject suppressAutoHyphens into all paragraphs ---
for p in body.findall(wtag("p")):
pPr = p.find(wtag("pPr"))
if pPr is None:
pPr = ET.SubElement(p, wtag("pPr"))
p.insert(0, pPr)
if pPr.find(wtag("suppressAutoHyphens")) is None:
ET.SubElement(pPr, wtag("suppressAutoHyphens"))
tree.write(doc_xml, xml_declaration=True, encoding="UTF-8", pretty_print=False)
# --- 8. Inject HorizontalRule and Caption styles ---
styles_xml = os.path.join(workdir, "word", "styles.xml")
styles_tree = ET.parse(styles_xml)
styles_root = styles_tree.getroot()
normal_style = None
for style in styles_root.findall(wtag("style")):
if style.get(wtag("styleId")) == "Normal":
normal_style = style
break
if normal_style is not None:
# Remove existing custom styles to avoid duplicates.
for style in list(styles_root.findall(wtag("style"))):
if style.get(wtag("styleId")) in ("HorizontalRule", "Caption"):
styles_root.remove(style)
# HorizontalRule style
hr_style = ET.Element(wtag("style"))
hr_style.set(wtag("type"), "paragraph")
hr_style.set(wtag("styleId"), "HorizontalRule")
ET.SubElement(hr_style, wtag("name")).set(wtag("val"), "Horizontal Rule")
ET.SubElement(hr_style, wtag("basedOn")).set(wtag("val"), "Normal")
pPr = ET.SubElement(hr_style, wtag("pPr"))
ET.SubElement(pPr, wtag("ind")).set(wtag("firstLine"), "0")
# Caption style: centered, 11pt, no first-line indent
caption_style = ET.Element(wtag("style"))
caption_style.set(wtag("type"), "paragraph")
caption_style.set(wtag("styleId"), "Caption")
ET.SubElement(caption_style, wtag("name")).set(wtag("val"), "Caption")
ET.SubElement(caption_style, wtag("basedOn")).set(wtag("val"), "Normal")
ET.SubElement(caption_style, wtag("qFormat"))
cap_pPr = ET.SubElement(caption_style, wtag("pPr"))
ET.SubElement(cap_pPr, wtag("jc")).set(wtag("val"), "center")
ET.SubElement(cap_pPr, wtag("ind")).set(wtag("firstLine"), "0")
ET.SubElement(cap_pPr, wtag("spacing")).set(wtag("after"), "120")
cap_rPr = ET.SubElement(caption_style, wtag("rPr"))
ET.SubElement(cap_rPr, wtag("sz")).set(wtag("val"), "22")
ET.SubElement(cap_rPr, wtag("szCs")).set(wtag("val"), "22")
idx = list(styles_root).index(normal_style)
styles_root.insert(idx + 1, hr_style)
styles_root.insert(idx + 2, caption_style)
styles_tree.write(styles_xml, xml_declaration=True, encoding="UTF-8", pretty_print=False)
with zipfile.ZipFile(docx_path, "w", zipfile.ZIP_DEFLATED) as zout:
for rd, _, files in os.walk(workdir):
for file in files:
fp = os.path.join(rd, file)
zout.write(fp, os.path.relpath(fp, workdir))
@@ -0,0 +1,13 @@
"""Review post-processing for md→docx conversion.
Currently reuses the default docx post-processor. Override this file when the
review template needs custom behavior.
"""
from importlib.machinery import SourceFileLoader
from pathlib import Path
_default_pp = Path(__file__).parent.parent / "default" / "postprocess.py"
_default_module = SourceFileLoader("default_postprocess", str(_default_pp)).load_module()
postprocess = _default_module.postprocess
+3
View File
@@ -86,6 +86,9 @@
// Body rhythm: spacing > leading; first-line indent = 2em for Chinese paragraphs // Body rhythm: spacing > leading; first-line indent = 2em for Chinese paragraphs
set par(justify: true, leading: 0.78em, spacing: 1.12em, first-line-indent: (amount: 2em, all: true)) set par(justify: true, leading: 0.78em, spacing: 1.12em, first-line-indent: (amount: 2em, all: true))
// Left-align tables on the page, overriding pandoc's default align(center).
show table: it => align(left)[#it]
// List rhythm: spacing ≥ leading (0.78em), looser than line height for readability // List rhythm: spacing ≥ leading (0.78em), looser than line height for readability
set list(indent: 1.2em, body-indent: 0.45em, spacing: 0.90em) set list(indent: 1.2em, body-indent: 0.45em, spacing: 0.90em)
set enum(indent: 1.2em, body-indent: 0.45em, spacing: 0.90em) set enum(indent: 1.2em, body-indent: 0.45em, spacing: 0.90em)