diff --git a/bin/commands/convert.py b/bin/commands/convert.py index a1a77f0..362b2c3 100644 --- a/bin/commands/convert.py +++ b/bin/commands/convert.py @@ -113,6 +113,8 @@ def convert_cmd(inputs, output, template, quality, density, reference_doc, bookm if ref_doc: cmd.extend(["--reference-doc", ref_doc]) run_command(cmd, check=True) + if template == "default": + _fix_docx_postprocess(out_path) click.secho(f"Word document generated: {output}", fg="green") return @@ -204,3 +206,180 @@ def _images_to_pdf(images: list[Path], output: Path) -> None: temp_pdf.unlink() with open(output, "wb") as f: writer.write(f) + + +def _fix_docx_postprocess(docx_path: Path) -> None: + """Post-process pandoc-generated docx for Chinese academic typography. + + Applies the following fixes: + - 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 + from tempfile import TemporaryDirectory + + W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + wtag = lambda t: "{" + W + "}" + t + + with TemporaryDirectory() as workdir: + with zipfile.ZipFile(docx_path, "r") as z: + z.extractall(workdir) + + 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. 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 + + tree.write(doc_xml, xml_declaration=True, encoding="UTF-8", pretty_print=False) + + # --- 5. 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))