Files
mytoolkit/bin/commands/convert.py
T
Zhengshou Lai fc4dab1629 feat(convert): apply reference-doc typography in docx post-processing
Extend _fix_docx_postprocess to copy Normal style paragraph properties
from the reference doc, convert BodyText to Normal, add heading spacing
before/after, and format reference entries with hanging-style spacing.
2026-06-09 14:38:30 +08:00

507 lines
21 KiB
Python

"""Universal format conversion."""
from pathlib import Path
import click
from pypdf import PdfReader, PdfWriter
from PIL import Image
from bin.md_to_pdf import build_pdf
from bin import templates_registry
from bin.utils import handle_errors, run_command
@click.command("convert")
@click.argument("inputs", nargs=-1, required=True)
@click.option("-o", "--output", required=True, help="Output file path")
@click.option(
"-t",
"--template",
type=click.Choice(["default", "cv", "textbook", "manual", "review"]),
default="default",
help="Template for md→pdf / md→docx",
)
@click.option(
"-q",
"--quality",
type=click.Choice(["screen", "ebook", "printer", "prepress"]),
default="screen",
help="PDF compression quality",
)
@click.option("-d", "--density", default="300", help="DPI for PDF→TIFF")
@click.option("--reference-doc", help="Reference docx template for md→docx")
@click.option(
"--bookmark-depth",
type=int,
default=1,
help="PDF bookmark expansion depth (md→pdf only). 1=top-level only, 2=expand to second level, etc.",
)
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
@handle_errors
def convert_cmd(inputs, output, template, quality, density, reference_doc, bookmark_depth, dry_run):
"""Universal format conversion. Auto-detects from file extensions.
\b
Examples:
mytoolkit convert doc.md -o doc.pdf
mytoolkit convert doc.md -o doc.pdf -t cv
mytoolkit convert doc.md -o doc.docx
mytoolkit convert doc.md -o doc.docx -t review
mytoolkit convert *.jpg -o album.pdf
mytoolkit convert fig.eps -o fig.pdf
mytoolkit convert scan.tiff -o scan.pdf
mytoolkit convert scan.pdf -o scan.tiff
mytoolkit convert video.avi -o video.mp4
"""
out_path = Path(output)
out_ext = out_path.suffix.lower()
# Resolve input globs
input_paths = []
for pattern in inputs:
p = Path(pattern)
if p.exists():
input_paths.append(p)
else:
# Try glob
matches = list(Path(".").glob(pattern))
input_paths.extend(sorted(matches))
if not input_paths:
click.secho("No input files found", fg="red", err=True)
raise SystemExit(1)
# --- pdf → pdf (compress) ---
if len(input_paths) == 1 and input_paths[0].suffix.lower() == ".pdf" and out_ext == ".pdf":
if dry_run:
click.echo(f"Would compress {input_paths[0]} to {output} (quality={quality})")
return
run_command(
[
"gs",
"-sDEVICE=pdfwrite",
"-dNOPAUSE",
"-dQUIET",
"-dBATCH",
f"-dPDFSETTINGS=/{quality}",
"-dCompatibilityLevel=1.4",
f"-sOutputFile={out_path}",
str(input_paths[0]),
],
check=True,
)
click.secho(f"Compressed PDF: {output}", fg="green")
return
# --- md → docx ---
if all(p.suffix.lower() == ".md" for p in input_paths) and out_ext == ".docx":
ref_doc = reference_doc
if not ref_doc:
ref_path = templates_registry.get_md_to_docx_root() / template / "template.docx"
if ref_path.exists():
ref_doc = str(ref_path)
else:
click.secho(f"Docx template not found for '{template}': {ref_path}", fg="red", err=True)
raise SystemExit(1)
if dry_run:
msg = f"Would convert {len(input_paths)} markdown file(s) to {output}"
if ref_doc:
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")
return
# --- md → pdf ---
if all(p.suffix.lower() == ".md" for p in input_paths) and out_ext == ".pdf":
if template == "review":
click.secho("Template 'review' is only available for md→docx", fg="red", err=True)
raise SystemExit(1)
if dry_run:
click.echo(f"Would convert {len(input_paths)} markdown file(s) to {output}")
return
build_pdf(template, input_paths, out_path, bookmark_depth=bookmark_depth)
click.secho(f"PDF generated: {output}", fg="green")
return
# --- images → pdf ---
img_exts = (".jpg", ".jpeg", ".png", ".tif", ".tiff")
if all(p.suffix.lower() in img_exts for p in input_paths) and out_ext == ".pdf":
if dry_run:
click.echo(f"Would convert {len(input_paths)} image(s) to {output}")
return
_images_to_pdf(input_paths, out_path)
click.secho(f"PDF generated: {output}", fg="green")
return
# --- eps → pdf ---
if len(input_paths) == 1 and input_paths[0].suffix.lower() in (".eps", ".ps") and out_ext == ".pdf":
if dry_run:
click.echo(f"Would convert {input_paths[0]} to {output}")
return
run_command(["epstopdf", str(input_paths[0]), "--outfile", str(out_path)], check=True)
click.secho(f"PDF generated: {output}", fg="green")
return
# --- pdf → tiff ---
if len(input_paths) == 1 and input_paths[0].suffix.lower() == ".pdf" and out_ext in (".tiff", ".tif"):
if dry_run:
click.echo(f"Would convert {input_paths[0]} to {output}")
return
run_command(
["convert", "-density", density, str(input_paths[0]), "-compress", "zip", str(out_path)],
check=False,
)
click.secho(f"TIFF generated: {output}", fg="green")
return
# --- avi → mp4 ---
if len(input_paths) == 1 and input_paths[0].suffix.lower() == ".avi" and out_ext == ".mp4":
if dry_run:
click.echo(f"Would convert {input_paths[0]} to {output}")
return
run_command(
["ffmpeg", "-i", str(input_paths[0]), "-c:v", "libx264", "-c:a", "aac", str(out_path)],
check=False,
)
click.secho(f"MP4 generated: {output}", fg="green")
return
# --- bib → md ---
if len(input_paths) == 1 and input_paths[0].suffix.lower() == ".bib" and out_ext == ".md":
if dry_run:
click.echo(f"Would convert {input_paths[0]} to {output}")
return
content = input_paths[0].read_text()
md_content = f"# Bibliography\n\n```bibtex\n{content}\n```\n"
out_path.write_text(md_content)
click.secho(f"Markdown generated: {output}", fg="green")
return
click.secho(
f"Unsupported conversion: {[p.suffix for p in input_paths]}{out_ext}",
fg="red",
err=True,
)
raise SystemExit(1)
def _images_to_pdf(images: list[Path], output: Path) -> None:
"""Merge images into a single PDF."""
writer = PdfWriter()
for img_path in images:
img = Image.open(img_path)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
temp_pdf = img_path.with_suffix(".temp.pdf")
img.save(temp_pdf, "PDF", resolution=150.0)
reader = PdfReader(str(temp_pdf))
writer.add_page(reader.pages[0])
temp_pdf.unlink()
with open(output, "wb") as f:
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))