highlight).
Inserts w:shd inside w:rPr.
"""
rPr = run._r.get_or_add_rPr()
shd = OxmlElement('w:shd')
shd.set(qn('w:val'), 'clear')
shd.set(qn('w:color'), 'auto')
shd.set(qn('w:fill'), fill)
rPr.append(shd)
def _render_heading(doc, node, level: int, style: dict):
"""Render - as Word Heading paragraphs.
H1 also gets a bottom paragraph border (per md2pdf pattern).
Applies COMMON['keep_with_next_headings'] so headings don't orphan at page bottom.
Applies COMMON['page_break_before_h1'] if True (style-overridable).
"""
text = node.get_text(strip=True)
p = doc.add_heading(level=level)
p.add_run(text)
# Apply style-specific color
color_hex = style.get("heading_color", "000000")
for run in p.runs:
run.font.color.rgb = RGBColor.from_string(color_hex)
# Wire COMMON config: keep heading with next paragraph
if COMMON.get("keep_with_next_headings", True):
p.paragraph_format.keep_with_next = True
# H1 page-break-before if style overrides COMMON default
if level == 1 and style.get("page_break_before_h1", COMMON.get("page_break_before_h1", False)):
p.paragraph_format.page_break_before = True
if level == 1:
add_paragraph_bottom_border(p, color=color_hex, size="12")
def _render_paragraph(doc, node, style: dict):
"""Render
as a Word paragraph, preserving inline formatting (strong, em, code, a).
Also handles
children — prints stderr warning and skips (spec §10).
"""
p = doc.add_paragraph()
code_fill = style.get("code_fill", "f5f5f5")
def _add_runs(element):
for child in element.children:
if isinstance(child, str):
p.add_run(child)
elif child.name == "strong" or child.name == "b":
r = p.add_run(child.get_text())
r.bold = True
elif child.name == "em" or child.name == "i":
r = p.add_run(child.get_text())
r.italic = True
elif child.name == "code":
r = p.add_run(child.get_text())
r.font.name = "Menlo"
_add_run_shading(r, fill=code_fill)
elif child.name == "a":
r = p.add_run(child.get_text())
r.underline = True
r.font.color.rgb = RGBColor.from_string(style.get("link_color", "0000EE"))
elif child.name == "img":
_warn_skip_image(child)
else:
# Unknown inline — recurse
_add_runs(child)
_add_runs(node)
def _render_list(doc, node, style: dict, level: int = 0, num_state: dict = None):
"""Render
/ recursively. Uses register_multilevel_numbering + attach_list_numbering.
`num_state` carries the registered numId across recursion (one numbering definition
per top-level list, all children share it).
Spec §4.2: mandatory manual numbering — List Bullet 2/3 styles don't work.
"""
is_ordered = node.name == "ol"
kind = "decimal" if is_ordered else "bullet"
# Top-level call: register fresh numbering definition
if num_state is None:
num_id = register_multilevel_numbering(doc, levels=3, kind=kind)
num_state = {"num_id": num_id}
for li in node.find_all("li", recursive=False):
# The li's direct text (excluding nested lists)
nested_lists = []
for child in li.children:
if hasattr(child, "name") and child.name in ("ul", "ol"):
nested_lists.append(child)
# Get text content of the li (excluding nested list text)
text_parts = []
for child in li.children:
if hasattr(child, "name") and child.name in ("ul", "ol"):
continue
if isinstance(child, str):
text_parts.append(child.strip())
else:
text_parts.append(child.get_text(strip=True))
text = " ".join(p for p in text_parts if p)
p = doc.add_paragraph(text)
attach_list_numbering(p, num_id=num_state["num_id"], ilvl=level)
# Recurse into nested lists
for nested in nested_lists:
_render_list(doc, nested, style=style, level=level + 1, num_state=num_state)
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()