feat(convert): auto post-process default docx template
Replace _fix_docx_hr_indent with _fix_docx_postprocess that runs automatically after pandoc when template=default. Fixes include: - Three-line table borders (1.5pt top/bottom, 0.75pt header) - Remove tblStyle to prevent Word from overriding borders - Remove first-line indent inside table cells - Add 6pt spacing before paragraphs following tables - Apply HorizontalRule style to '---' paragraphs (no indent) - Remove indent from empty paragraphs - Inject HorizontalRule style into styles.xml
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user