From e3176a789e4b7602ebf2b7c83a61ea8148e90654 Mon Sep 17 00:00:00 2001 From: Sebastian Petrescu Date: Tue, 28 Jul 2026 10:22:20 +0300 Subject: [PATCH] =?UTF-8?q?feat(md2doc):=20add=20skeleton=20=E2=80=94=20ar?= =?UTF-8?q?gparse,=20STYLES=20dict,=20stub=20converter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- md2doc.py | 146 +++++++++++++++++++++++++++++++++++++++ tests/test_md2doc_cli.py | 42 +++++++++++ 2 files changed, 188 insertions(+) create mode 100644 tests/test_md2doc_cli.py diff --git a/md2doc.py b/md2doc.py index 1935cea..361952a 100644 --- a/md2doc.py +++ b/md2doc.py @@ -15,6 +15,52 @@ 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. @@ -36,3 +82,103 @@ def _bulletize(text: str) -> str: 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() diff --git a/tests/test_md2doc_cli.py b/tests/test_md2doc_cli.py new file mode 100644 index 0000000..0555858 --- /dev/null +++ b/tests/test_md2doc_cli.py @@ -0,0 +1,42 @@ +"""CLI tests for md2doc.py — argparse interface, no actual conversion.""" +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).parent.parent + + +def run_cli(*args): + return subprocess.run( + [sys.executable, "md2doc.py", *args], + cwd=REPO, capture_output=True, text=True + ) + + +def test_no_args_shows_usage(): + r = run_cli() + assert r.returncode != 0 + assert "usage:" in r.stderr.lower() + + +def test_nonexistent_input_exits_1(): + r = run_cli("nonexistent.md") + assert r.returncode == 1 + assert "not found" in r.stderr.lower() + + +def test_style_choices_listed_in_help(): + r = run_cli("--help") + assert r.returncode == 0 + for s in ("elegant", "default", "dark", "mono", "report"): + assert s in r.stdout + + +def test_invalid_style_rejected(): + tmp = REPO / "tests" / "tmp_test.md" + tmp.write_text("# test\n") + try: + r = run_cli(str(tmp), "--style", "nonexistent") + assert r.returncode != 0 + finally: + tmp.unlink()