feat(md2doc): add skeleton — argparse, STYLES dict, stub converter
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user