185 lines
6.3 KiB
Python
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()
|