diff --git a/mytoolkit/commands/convert.py b/mytoolkit/commands/convert.py
index 0275556..b67fdb1 100644
--- a/mytoolkit/commands/convert.py
+++ b/mytoolkit/commands/convert.py
@@ -1,12 +1,13 @@
"""Universal format conversion."""
+import tempfile
from pathlib import Path
import click
from pypdf import PdfReader, PdfWriter
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.utils import handle_errors, run_command
@@ -96,8 +97,9 @@ def convert_cmd(inputs, output, template, quality, density, reference_doc, bookm
# --- md → docx ---
if all(p.suffix.lower() == ".md" for p in input_paths) and out_ext == ".docx":
ref_doc = reference_doc
+ template_dir = templates_registry.get_md_to_docx_root() / template
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():
ref_doc = str(ref_path)
else:
@@ -109,12 +111,34 @@ def convert_cmd(inputs, output, template, quality, density, reference_doc, bookm
msg += f" (template={ref_doc})"
click.echo(msg)
return
- cmd = ["pandoc"] + [str(p) for p in input_paths] + ["-o", str(out_path)]
- if ref_doc:
- cmd.extend(["--reference-doc", ref_doc])
- run_command(cmd, check=True)
- _fix_docx_postprocess(out_path, reference_doc_path=ref_doc)
- click.secho(f"Word document generated: {output}", fg="green")
+ # Resolve relative image paths so pandoc can find resources regardless of cwd.
+ resolved_inputs = []
+ try:
+ for p in input_paths:
+ content = p.read_text(encoding="utf-8")
+ 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
# --- md → pdf ---
@@ -207,300 +231,3 @@ def _images_to_pdf(images: list[Path], output: Path) -> None:
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))
diff --git a/mytoolkit/md_to_pdf.py b/mytoolkit/md_to_pdf.py
index d58e888..4045654 100644
--- a/mytoolkit/md_to_pdf.py
+++ b/mytoolkit/md_to_pdf.py
@@ -155,12 +155,59 @@ def preprocess_typst_callouts(md: str) -> str:
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 ```` and HTML ``
`` 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""
+ 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'(
]+src=["\'])([^"\']+)(["\'][^>]*>)',
+ _replace_html_img,
+ md_content,
+ flags=re.IGNORECASE,
+ )
+ return md_content
+
+
def md_to_typ(inputs: list[Path]) -> str:
"""Convert Markdown files to Typst via pandoc."""
md_content = ""
for inp in inputs:
- with open(inp, "r", encoding="utf-8") as f:
- md_content += f.read() + "\n\n"
+ content = inp.read_text(encoding="utf-8")
+ content = resolve_markdown_image_paths(content, inp.parent)
+ md_content += content + "\n\n"
md_content = preprocess_typst_callouts(md_content)
result = run_command(
["pandoc", "-f", "markdown-citations", "-t", "typst", "-"],
diff --git a/templates/md-to-docx/default/postprocess.py b/templates/md-to-docx/default/postprocess.py
new file mode 100644
index 0000000..64b51dd
--- /dev/null
+++ b/templates/md-to-docx/default/postprocess.py
@@ -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))
diff --git a/templates/md-to-docx/review/postprocess.py b/templates/md-to-docx/review/postprocess.py
new file mode 100644
index 0000000..06d9f75
--- /dev/null
+++ b/templates/md-to-docx/review/postprocess.py
@@ -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
diff --git a/templates/md-to-pdf/default/template.typ b/templates/md-to-pdf/default/template.typ
index d17a60c..e73af18 100644
--- a/templates/md-to-pdf/default/template.typ
+++ b/templates/md-to-pdf/default/template.typ
@@ -86,6 +86,9 @@
// 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))
+ // 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
set list(indent: 1.2em, body-indent: 0.45em, spacing: 0.90em)
set enum(indent: 1.2em, body-indent: 0.45em, spacing: 0.90em)