Files
md2pdf/md2doc.py
T

185 lines
6.3 KiB
Python

#!/usr/bin/env python3
"""
Convert markdown files to DOCX (Microsoft Word).
Usage:
md2doc.py input.md # Creates input.docx in same directory
md2doc.py input.md -o output.docx # Specify output file
md2doc.py docs/ # Convert all .md files in directory
md2doc.py docs/ -o docx_output/
md2doc.py input.md --style mono # Use monospace style
Requires: pip install python-docx markdown beautifulsoup4 lxml
"""
import argparse
import sys
from pathlib import Path
import markdown
from bs4 import BeautifulSoup
from docx import Document
from docx.shared import Pt, RGBColor, Cm
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
STYLES = {
"elegant": {
"font": "Helvetica", "size": 11, "heading_color": "1a1a1a",
"link_color": "2c2c2c", "table_header_fill": "e0e0e0",
"code_fill": "f8f8f8", "blockquote_fill": "f8f8f8",
},
"report": {
"font": "Helvetica", "size": 10, "heading_color": "1e3a8a",
"link_color": "1e3a8a", "table_header_fill": "1e3a8a",
"code_fill": "f7f8fa", "blockquote_fill": "f7f8fa",
},
"default": {
"font": "Helvetica", "size": 10, "heading_color": "1a1a1a",
"link_color": "0066cc", "table_header_fill": "f5f5f5",
"code_fill": "f5f5f5", "blockquote_fill": "fafafa",
},
"dark": {
"font": "Helvetica", "size": 11, "heading_color": "818cf8",
"link_color": "818cf8", "table_header_fill": "4f46e5",
"code_fill": "1f2937", "blockquote_fill": "1f2937",
},
"mono": {
"font": "Menlo", "size": 10, "heading_color": "000000",
"link_color": "000000", "table_header_fill": "f0f0f0",
"code_fill": "f5f5f5", "blockquote_fill": "f5f5f5",
},
}
# Common rules applied to all styles via deep-merge.
# Note: python-docx doesn't expose orphans/widows directly via API;
# keep_with_next IS available via paragraph_format.keep_with_next = True.
# page_break_before_h1 is per-style flag (False by default; can be overridden).
COMMON = {
"keep_with_next_headings": True, # applied to H1-H4
"page_break_before_h1": False, # can be overridden per style
"table_cell_valign": "top",
}
def _bulletize(text: str) -> str:
"""Turn markdown "- " list markers into bullets, skipping code/HTML blocks.
Copied verbatim from md2pdf.py per spec §3.3.
"""
import re
out, in_fence, in_html = [], False, False
for line in text.split("\n"):
stripped = line.lstrip()
if stripped.startswith("```") or stripped.startswith("~~~"):
in_fence = not in_fence
elif not in_fence:
if re.match(r"^<(table|div|section|figure)\b", stripped, re.I):
in_html = True
elif re.match(r"^</(table|div|section|figure)>", stripped, re.I):
in_html = False
if not in_fence and not in_html and line.startswith("- "):
line = "• " + line[2:]
out.append(line)
return "\n".join(out)
def convert_md_to_doc(md_file: Path, output_file: Path, style: str = "elegant") -> Path:
"""Convert a single markdown file to DOCX.
Currently a stub — actual conversion logic in Task 4+.
"""
raise NotImplementedError("md2doc conversion not yet implemented")
def main():
parser = argparse.ArgumentParser(
description='Convert Markdown files to DOCX',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s document.md Convert single file (elegant style)
%(prog)s document.md -o report.docx Convert with custom output name
%(prog)s docs/ Convert all .md files in directory
%(prog)s doc.md --style mono Use monospace font
%(prog)s doc.md --style dark Use dark theme
Available styles: elegant (default), default, dark, mono, report
"""
)
parser.add_argument('input', help='Input markdown file or directory')
parser.add_argument('-o', '--output', help='Output DOCX file or directory')
parser.add_argument('--style', choices=list(STYLES.keys()), default='elegant',
help='Style template (default: elegant)')
parser.add_argument('-q', '--quiet', action='store_true', help='Suppress output')
args = parser.parse_args()
input_path = Path(args.input).resolve()
if not input_path.exists():
print(f"Error: '{args.input}' not found", file=sys.stderr)
sys.exit(1)
# Determine files to convert (same logic as md2pdf.py)
if input_path.is_file():
if not input_path.suffix.lower() == '.md':
print(f"Warning: '{input_path.name}' doesn't have .md extension", file=sys.stderr)
files = [input_path]
if args.output:
output_path = Path(args.output).resolve()
if output_path.suffix.lower() == '.docx':
outputs = [output_path]
else:
output_path.mkdir(parents=True, exist_ok=True)
outputs = [output_path / (input_path.stem + '.docx')]
else:
outputs = [input_path.with_suffix('.docx')]
else: # Directory
files = sorted(input_path.glob('**/*.md'))
if not files:
print(f"No .md files found in '{args.input}'", file=sys.stderr)
sys.exit(1)
if args.output:
output_dir = Path(args.output).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
else:
output_dir = input_path
outputs = [output_dir / (f.stem + '.docx') for f in files]
# Convert files
success = 0
errors = 0
for md_file, docx_file in zip(files, outputs):
try:
if not args.quiet:
print(f"Converting: {md_file.name} -> {docx_file.name}...", end=' ', flush=True)
docx_file.parent.mkdir(parents=True, exist_ok=True)
convert_md_to_doc(md_file, docx_file, args.style)
if not args.quiet:
size_kb = docx_file.stat().st_size / 1024
print(f"OK ({size_kb:.1f} KB)")
success += 1
except Exception as e:
if not args.quiet:
print(f"FAILED: {e}")
errors += 1
if not args.quiet and len(files) > 1:
print(f"\nDone: {success} converted, {errors} failed")
sys.exit(0 if errors == 0 else 1)
if __name__ == "__main__":
main()