From 75bb7dec3253da996e5f8588788bbe7afecf14e3 Mon Sep 17 00:00:00 2001 From: Zhengshou Lai Date: Sun, 28 Jun 2026 11:27:14 +0800 Subject: [PATCH] feat(convert): template-driven docx postprocess + pdf table left align MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- mytoolkit/commands/convert.py | 337 ++-------------- mytoolkit/md_to_pdf.py | 51 ++- templates/md-to-docx/default/postprocess.py | 404 ++++++++++++++++++++ templates/md-to-docx/review/postprocess.py | 13 + templates/md-to-pdf/default/template.typ | 3 + 5 files changed, 501 insertions(+), 307 deletions(-) create mode 100644 templates/md-to-docx/default/postprocess.py create mode 100644 templates/md-to-docx/review/postprocess.py 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 ``![alt](src)`` 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"![{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'(]+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)