feat(md2doc): add skeleton — argparse, STYLES dict, stub converter
This commit is contained in:
@@ -15,6 +15,52 @@ import argparse
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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:
|
def _bulletize(text: str) -> str:
|
||||||
"""Turn markdown "- " list markers into bullets, skipping code/HTML blocks.
|
"""Turn markdown "- " list markers into bullets, skipping code/HTML blocks.
|
||||||
@@ -36,3 +82,103 @@ def _bulletize(text: str) -> str:
|
|||||||
line = "• " + line[2:]
|
line = "• " + line[2:]
|
||||||
out.append(line)
|
out.append(line)
|
||||||
return "\n".join(out)
|
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