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:
|
if ref_doc:
|
||||||
cmd.extend(["--reference-doc", ref_doc])
|
cmd.extend(["--reference-doc", ref_doc])
|
||||||
run_command(cmd, check=True)
|
run_command(cmd, check=True)
|
||||||
|
if template == "default":
|
||||||
|
_fix_docx_postprocess(out_path)
|
||||||
click.secho(f"Word document generated: {output}", fg="green")
|
click.secho(f"Word document generated: {output}", fg="green")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -204,3 +206,180 @@ def _images_to_pdf(images: list[Path], output: Path) -> None:
|
|||||||
temp_pdf.unlink()
|
temp_pdf.unlink()
|
||||||
with open(output, "wb") as f:
|
with open(output, "wb") as f:
|
||||||
writer.write(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