Converts markdown files to DOCX with optional reference doc styling. Supports front matter stripping and custom output paths. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
264 lines
9.5 KiB
Python
Executable File
264 lines
9.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Convert a Markdown file to a professionally styled Word document (.docx)."""
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
|
|
# --- Colour palette ---
|
|
DARK = (0x1A, 0x1A, 0x2E) # Near-black navy (H1, title)
|
|
ACCENT = (0x1B, 0x4D, 0x89) # Professional blue (H2, H3, links)
|
|
BODY = (0x33, 0x33, 0x33) # Dark grey (body text)
|
|
HEADER_BG = "1B4D89"
|
|
LIGHT_BG = "F2F6FA"
|
|
|
|
|
|
def check_dependencies():
|
|
"""Check that pandoc and python-docx are available. Returns (pandoc_version, docx_version) or exits."""
|
|
pandoc_path = shutil.which("pandoc")
|
|
if not pandoc_path:
|
|
print("Error: pandoc not found. Install with: sudo apt install pandoc", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
result = subprocess.run([pandoc_path, "--version"], capture_output=True, text=True)
|
|
pandoc_version = result.stdout.split("\n")[0].replace("pandoc ", "")
|
|
|
|
try:
|
|
import docx
|
|
docx_version = docx.__version__
|
|
except ImportError:
|
|
print("Error: python-docx not installed. Install with: pip install python-docx", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
return pandoc_version, docx_version
|
|
|
|
|
|
def extract_title(md_path):
|
|
"""Extract the first # heading from a markdown file."""
|
|
with open(md_path, "r") as f:
|
|
for line in f:
|
|
match = re.match(r"^#\s+(.+)$", line.strip())
|
|
if match:
|
|
return match.group(1)
|
|
return os.path.splitext(os.path.basename(md_path))[0]
|
|
|
|
|
|
def create_styled_reference(output_path):
|
|
"""Generate a pandoc reference.docx with professional styling applied."""
|
|
from docx import Document
|
|
from docx.shared import Pt, Cm, RGBColor
|
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
|
|
# Extract pandoc's default reference doc
|
|
subprocess.run(
|
|
["pandoc", "-o", output_path, "--print-default-data-file", "reference.docx"],
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
|
|
doc = Document(output_path)
|
|
|
|
for section in doc.sections:
|
|
section.top_margin = Cm(2.5)
|
|
section.bottom_margin = Cm(2.5)
|
|
section.left_margin = Cm(2.5)
|
|
section.right_margin = Cm(2.5)
|
|
|
|
style_defs = {
|
|
"Title": {"size": Pt(28), "bold": True, "color": DARK, "space_after": Pt(6), "alignment": WD_ALIGN_PARAGRAPH.LEFT},
|
|
"Subtitle": {"size": Pt(14), "bold": False, "color": ACCENT, "space_after": Pt(24)},
|
|
"Heading 1": {"size": Pt(22), "bold": True, "color": DARK, "space_before": Pt(36), "space_after": Pt(12)},
|
|
"Heading 2": {"size": Pt(16), "bold": True, "color": ACCENT, "space_before": Pt(24), "space_after": Pt(8)},
|
|
"Heading 3": {"size": Pt(13), "bold": True, "color": ACCENT, "space_before": Pt(18), "space_after": Pt(6)},
|
|
"Normal": {"size": Pt(11), "bold": False, "color": BODY, "space_after": Pt(8), "line_spacing": Pt(16)},
|
|
"Body Text": {"size": Pt(11), "bold": False, "color": BODY, "space_after": Pt(8), "line_spacing": Pt(16)},
|
|
"First Paragraph": {"size": Pt(11), "bold": False, "color": BODY, "space_after": Pt(8), "line_spacing": Pt(16)},
|
|
"Block Text": {"size": Pt(10), "bold": False, "color": BODY},
|
|
"List Paragraph": {"size": Pt(11), "color": BODY},
|
|
"Compact": {"size": Pt(11), "color": BODY},
|
|
}
|
|
|
|
for style_name, props in style_defs.items():
|
|
try:
|
|
style = doc.styles[style_name]
|
|
except KeyError:
|
|
continue
|
|
|
|
style.font.name = "Calibri"
|
|
if "size" in props:
|
|
style.font.size = props["size"]
|
|
if "bold" in props:
|
|
style.font.bold = props["bold"]
|
|
if "color" in props:
|
|
style.font.color.rgb = RGBColor(*props["color"])
|
|
|
|
pf = style.paragraph_format
|
|
for attr in ("space_before", "space_after", "line_spacing"):
|
|
if attr in props:
|
|
setattr(pf, attr, props[attr])
|
|
if "alignment" in props:
|
|
pf.alignment = props["alignment"]
|
|
|
|
try:
|
|
hl = doc.styles["Hyperlink"]
|
|
hl.font.color.rgb = RGBColor(*ACCENT)
|
|
hl.font.underline = True
|
|
except KeyError:
|
|
pass
|
|
|
|
doc.save(output_path)
|
|
|
|
|
|
def postprocess_docx(docx_path):
|
|
"""Apply table styling, page breaks, and font normalization."""
|
|
from docx import Document
|
|
from docx.shared import Pt, RGBColor
|
|
from docx.enum.table import WD_TABLE_ALIGNMENT
|
|
from docx.oxml.ns import nsdecls, qn
|
|
from docx.oxml import parse_xml
|
|
|
|
doc = Document(docx_path)
|
|
|
|
# Style tables
|
|
for table in doc.tables:
|
|
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
|
|
|
if not table.rows:
|
|
continue
|
|
|
|
# Header row
|
|
for cell in table.rows[0].cells:
|
|
shading = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{HEADER_BG}"/>')
|
|
cell._tc.get_or_add_tcPr().append(shading)
|
|
for paragraph in cell.paragraphs:
|
|
for run in paragraph.runs:
|
|
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
|
|
run.font.bold = True
|
|
run.font.size = Pt(10)
|
|
run.font.name = "Calibri"
|
|
|
|
# Data rows with alternating shading
|
|
for i, row in enumerate(table.rows[1:], 1):
|
|
for cell in row.cells:
|
|
if i % 2 == 0:
|
|
shading = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{LIGHT_BG}"/>')
|
|
cell._tc.get_or_add_tcPr().append(shading)
|
|
for paragraph in cell.paragraphs:
|
|
for run in paragraph.runs:
|
|
run.font.size = Pt(10)
|
|
run.font.name = "Calibri"
|
|
|
|
# Table borders
|
|
tbl_pr = table._tbl.tblPr
|
|
if tbl_pr is None:
|
|
tbl_pr = parse_xml(f'<w:tblPr {nsdecls("w")}/>')
|
|
table._tbl.insert(0, tbl_pr)
|
|
|
|
existing = tbl_pr.find(qn("w:tblBorders"))
|
|
if existing is not None:
|
|
tbl_pr.remove(existing)
|
|
|
|
borders = parse_xml(
|
|
f'<w:tblBorders {nsdecls("w")}>'
|
|
' <w:top w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/>'
|
|
' <w:left w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/>'
|
|
' <w:bottom w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/>'
|
|
' <w:right w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/>'
|
|
' <w:insideH w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/>'
|
|
' <w:insideV w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/>'
|
|
"</w:tblBorders>"
|
|
)
|
|
tbl_pr.append(borders)
|
|
|
|
# Page break before "References" heading
|
|
for para in doc.paragraphs:
|
|
if para.text.strip() == "References" and para.style.name.startswith("Heading"):
|
|
pPr = para._p.get_or_add_pPr()
|
|
pPr.append(parse_xml(f'<w:pageBreakBefore {nsdecls("w")}/>'))
|
|
break
|
|
|
|
# Ensure all runs have Calibri
|
|
for para in doc.paragraphs:
|
|
for run in para.runs:
|
|
if run.font.name is None:
|
|
run.font.name = "Calibri"
|
|
|
|
doc.save(docx_path)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Convert a Markdown file to a professionally styled Word document (.docx).",
|
|
prog="md-to-docx",
|
|
)
|
|
parser.add_argument("input", help="Path to the Markdown file to convert")
|
|
parser.add_argument("-o", "--output", help="Output file path (default: <input>.docx)")
|
|
parser.add_argument("-t", "--title", help="Override document title")
|
|
parser.add_argument("--toc", action="store_true", help="Include a table of contents")
|
|
parser.add_argument("--toc-depth", type=int, default=2, choices=[1, 2, 3], help="TOC depth (default: 2)")
|
|
parser.add_argument("-n", "--dryrun", action="store_true", help="Preview without creating files")
|
|
args = parser.parse_args()
|
|
|
|
# Validate input
|
|
if not os.path.isfile(args.input):
|
|
print(f"Error: file not found: {args.input}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
input_path = os.path.abspath(args.input)
|
|
if args.output:
|
|
output_path = os.path.abspath(args.output)
|
|
else:
|
|
output_path = os.path.splitext(input_path)[0] + ".docx"
|
|
|
|
title = args.title or extract_title(input_path)
|
|
pandoc_version, docx_version = check_dependencies()
|
|
|
|
if args.dryrun:
|
|
print(f"[dryrun] Input: {input_path}")
|
|
print(f"[dryrun] Output: {output_path}")
|
|
print(f"[dryrun] Title: {title}")
|
|
toc_str = f"yes (depth: {args.toc_depth})" if args.toc else "no"
|
|
print(f"[dryrun] TOC: {toc_str}")
|
|
print(f"[dryrun] Dependencies OK: pandoc {pandoc_version}, python-docx {docx_version}")
|
|
print("[dryrun] Would generate styled .docx file")
|
|
return
|
|
|
|
# Create styled reference doc in a temp directory
|
|
tmpdir = tempfile.mkdtemp(prefix="md-to-docx-")
|
|
ref_path = os.path.join(tmpdir, "reference.docx")
|
|
|
|
try:
|
|
create_styled_reference(ref_path)
|
|
|
|
# Build pandoc command
|
|
cmd = [
|
|
"pandoc", input_path,
|
|
"-o", output_path,
|
|
f"--reference-doc={ref_path}",
|
|
"--metadata", f"title={title}",
|
|
"-f", "markdown+pipe_tables+inline_notes",
|
|
"--columns=72",
|
|
]
|
|
if args.toc:
|
|
cmd.extend(["--toc", f"--toc-depth={args.toc_depth}"])
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
print(f"Error: pandoc failed:\n{result.stderr}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
postprocess_docx(output_path)
|
|
print(output_path)
|
|
|
|
finally:
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|