Files
md2pdf/docs/plans/2026-07-27-md2doc.md
T
sebastian 5891ed892c Plan: address Stage 1 re-review feedback
Fixes:
- CRITICAL: Task 2 Step 3 no longer overwrites md2doc.py — extends in place
- MAJOR: COMMON.keep_with_next_headings now wired into _render_heading
- MINOR: added test for keep_with_next assertion
- MINOR: Task 9 note about footer param preservation
- MINOR: _render_pre now preserves newlines via add_break()
- MINOR: Notes section documents fixture location, security, COMMON wiring
2026-07-27 23:51:21 +03:00

61 KiB

md2doc + md2pdf bugfixes Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use aws-subagent-driven-development (recommended) or aws-executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build md2doc.py (Markdown→DOCX converter, python-docx based) and fix two md2pdf.py bugs (--footer dead in CLI, --forms non-functional).

Architecture: Sibling single-file Python utility mirroring md2pdf.py structure (STYLES dict, COMMON rules, _bulletize logic copied). Manual XML manipulation for paragraph borders/shading and numbering. Pipeline: md text → markdown.markdown()BeautifulSoup HTML walk → emit docx elements.

Tech Stack: Python 3.x, python-docx (new dep), markdown (existing), beautifulsoup4 (existing), lxml (new dep).

Research Summary

Existing Knowledge

  • md2pdf.py patterns (existing in /Users/sebastian/projects/tools/md2pdf.py):
    • STYLES dict with 5 stiluri (elegant default, default, dark, mono, report)
    • COMMON_CSS string injectat după stilul ales
    • _bulletize(text) — transformă - în dar nu în code/HTML blocuri
    • BLANK_MARKER injection pentru multi-blank-line preservation
    • convert_md_to_pdf(md_file, output_file, style, forms, footer) — main converter
    • main() — argparse cu input, -o/--output, --style, -q/--quiet, --forms
  • Empirical findings from spec Stage 2 (doc: docs/specs/2026-07-27-md2doc-design.md):**
    • python-docx 1.2 nu are API pentru paragraph borders/shading — merge doar XML
    • paragraph.style = "List Bullet 2/3" NU produce liste imbricate — necesită w:abstractNum multi-level + per-paragraph w:numPr cu ilvl
    • weasyprint 68.1 silently drop <input>/<textarea>--forms nu poate funcționa

File Snapshots

File Key symbols Pattern Lines
md2pdf.py STYLES, COMMON_CSS, _bulletize, convert_md_to_pdf, main Single-file, argparse CLI, dict-based styling 747

Note: md2pdf.py este 747 linii, peste threshold-ul de 300. Pentru acest plan, modificările sunt punctuale (B1 add argument, B2 remove argument + param) — split-ul fișierului ar însemna refactor care depășește scope-ul spec-ului. Se lasă intact, se notează ca datorie tehnică.

Domain Model

  • md2pdf.py (entitate existentă): Markdown → PDF, weasyprint backend
  • md2doc.py (entitate nouă): Markdown → DOCX, python-docx backend
  • Domeniu comun (logică duplicată): _bulletize, BLANK_MARKER handling — intenționat duplicate (YAGNI refactor)

Gap Analysis

  • Create:
    • md2doc.py — single-file converter, estimat ~450-550 linii (STYLES + COMMON + numbering helper + converter + main)
    • Fișierul depășește 300 linii, dar este single-responsibility (MD→DOCX). Split artificial ar crește complexitatea.
  • Modify:
    • md2pdf.py:670-676 — adaugă --footer argument (B1)
    • md2pdf.py:728 — passează footer=args.footer la convert_md_to_pdf (B1)
    • md2pdf.py:675-676 — șterge --forms argument (B2)
    • md2pdf.py:728 — șterge forms=args.forms din apel (B2)
    • md2pdf.py:577,646 — șterge forms param din convert_md_to_pdf și options={'pdf_forms': forms} (B2)
    • md2pdf.py:656-668 (epilog docstring) — actualizează examples
  • Risks:
    • H3 keystone (numerotare XML Route B) a fost verificat doar la nivel XML, nu și vizual în Word. Acesta e keystone-ul planului.
    • python-docx și lxml nu sunt instalate — Task 1 le instalează.

Task 1: Install dependencies and verify python-docx

Files:

  • Modify: system (pip install)

  • Test: tests/test_deps.py

  • Step 1: Write the failing test

Creează tests/test_deps.py:

"""Verify all required dependencies for md2doc are importable."""
import pytest


def test_python_docx_import():
    import docx
    from docx import Document
    from docx.shared import Pt, RGBColor, Cm
    from docx.oxml.ns import qn
    from docx.oxml import OxmlElement


def test_markdown_import():
    import markdown


def test_bs4_import():
    from bs4 import BeautifulSoup


def test_lxml_import():
    from lxml import etree
  • Step 2: Run test to verify it fails
cd /Users/sebastian/projects/tools && pytest tests/test_deps.py -v

Expected: FAIL — ModuleNotFoundError: No module named 'docx' and No module named 'lxml'.

  • Step 3: Install missing deps
pip3 install --user python-docx lxml

Expected: "Successfully installed python-docx-X.X.X lxml-X.X.X".

  • Step 4: Run test to verify it passes
pytest tests/test_deps.py -v

Expected: PASS (4 tests).

  • Step 5: Commit
git add tests/test_deps.py
git commit -m "chore: add deps test for md2doc (python-docx, lxml)"

Task 1.5: Copy _bulletize and BLANK_MARKER helpers from md2pdf.py

Files:

  • Create: md2doc.py (initial, with helpers only)
  • Test: tests/test_bulletize.py

Spec §3.3 mandates copying _bulletize and BLANK_MARKER logic verbatim from md2pdf.py (YAGNI for shared module).

  • Step 1: Write failing test

Creează tests/test_bulletize.py:

"""Tests for _bulletize — verifies behavior copied from md2pdf.py."""
from md2doc import _bulletize


def test_simple_dash_list_becomes_bullets():
    text = "- item A\n- item B\n"
    result = _bulletize(text)
    assert "• item A" in result
    assert "• item B" in result


def test_code_block_dashes_preserved():
    """Dashes inside ``` blocks must NOT be bulletized."""
    text = "```bash\n-r flag\n--verbose\n```\n"
    result = _bulletize(text)
    assert "-r flag" in result
    assert "--verbose" in result


def test_html_block_dashes_preserved():
    """Dashes inside raw HTML blocks (table/div/etc.) must NOT be bulletized."""
    text = "<table>\n- not a list\n</table>\n"
    result = _bulletize(text)
    assert "- not a list" in result


def test_tilde_fence_also_preserved():
    text = "~~~bash\n-x\n~~~\n"
    result = _bulletize(text)
    assert "-x" in result
  • Step 2: Run test to verify it fails
pytest tests/test_bulletize.py -v

Expected: FAIL — md2doc.py doesn't exist yet / can't import _bulletize.

  • Step 3: Create md2doc.py with _bulletize

Creează md2doc.py cu acest conținut (codul este copiat VERBATIM din md2pdf.py:558-574):

#!/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


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)
  • Step 4: Run tests
pytest tests/test_bulletize.py -v

Expected: PASS (4 tests).

  • Step 5: Commit
git add md2doc.py tests/test_bulletize.py
git commit -m "feat(md2doc): copy _bulletize helper from md2pdf (spec §3.3)"

Task 2: md2doc skeleton — argparse + STYLES dict + empty converter

Files:

  • Modify: md2doc.py (extend with imports, STYLES, COMMON, converter stub, main)
  • Test: tests/test_md2doc_cli.py

Note: md2doc.py already exists from Task 1.5 (contains _bulletize only). This task adds the rest.

  • Step 1: Write failing tests for CLI

Creează tests/test_md2doc_cli.py:

"""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():
    # Create a temp .md file to satisfy input validation
    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()
  • Step 2: Run test to verify it fails
pytest tests/test_md2doc_cli.py -v

Expected: FAIL — md2doc.py not found.

  • Step 3: Extend md2doc.py with imports, STYLES, COMMON, converter stub, and main

IMPORTANT: md2doc.py already exists from Task 1.5 and contains _bulletize (and its docstring/imports for re). DO NOT overwrite the file. Instead:

  1. Păstrează shebang-ul și docstring-ul existent
  2. Adaugă următoarele imports la sfârșitul secțiunii de importuri (după from pathlib import Path):
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
  1. Adaugă STYLES și COMMON dict-uri (codul de mai jos) după _bulletize:
#!/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 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()
  • Step 4: Run tests to verify they pass
pytest tests/test_md2doc_cli.py -v

Expected: PASS (4 tests).

  • Step 5: Commit
git add md2doc.py tests/test_md2doc_cli.py
git commit -m "feat(md2doc): add skeleton — argparse, STYLES dict, stub converter"

Task 3: Document helper — borders, shading, numbering XML

Files:

  • Modify: md2doc.py (add helpers above convert_md_to_doc)
  • Test: tests/test_md2doc_helpers.py

Helpers implement the XML routes verified by spec Stage 2 review (H1, H2, H4, H3 Route B).

  • Step 1: Write failing tests

Creează tests/test_md2doc_helpers.py:

"""Tests for XML helper functions — borders, shading, numbering.

These prove the EFFECT (XML structure lands in the right place), not the call.
Each test unzips the docx and inspects word/document.xml directly.
"""
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path

import pytest
from docx import Document
from docx.oxml import OxmlElement

# Import helpers under test (will fail until Step 3)
from md2doc import (
    add_paragraph_bottom_border,
    add_paragraph_shading,
    add_cell_shading,
    attach_list_numbering,
    register_multilevel_numbering,
)

W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"


def get_doc_xml(doc):
    """Extract word/document.xml as ElementTree root."""
    # write to temp, unzip, parse
    tmp = Path(__file__).parent / "_tmp_helper_test.docx"
    doc.save(tmp)
    try:
        with zipfile.ZipFile(tmp) as z:
            xml_bytes = z.read("word/document.xml")
        return ET.fromstring(xml_bytes)
    finally:
        tmp.unlink()


def find_all(root, tag):
    return root.findall(f".//{{{W_NS}}}{tag}")


def test_add_paragraph_bottom_border_inserts_pBdr():
    """H1: paragraph bottom border lands inside w:pPr as w:pBdr/w:bottom."""
    doc = Document()
    p = doc.add_paragraph()
    add_paragraph_bottom_border(p, color="000000", size="12")
    root = get_doc_xml(doc)
    pBdr = find_all(root, "pBdr")
    bottom = find_all(root, "bottom")
    assert len(pBdr) >= 1, "w:pBdr missing"
    assert len(bottom) >= 1, "w:bottom missing"
    # Verify attribute values
    b = bottom[0]
    assert b.get(f"{{{W_NS}}}color") == "000000"
    assert b.get(f"{{{W_NS}}}sz") == "12"
    assert b.get(f"{{{W_NS}}}val") == "single"


def test_add_paragraph_shading_inserts_shd():
    """H2: paragraph shading w:shd lands inside w:pPr with correct fill."""
    doc = Document()
    p = doc.add_paragraph()
    add_paragraph_shading(p, fill="D9D9D9")
    root = get_doc_xml(doc)
    shd = find_all(root, "shd")
    assert len(shd) >= 1, "w:shd missing"
    assert shd[0].get(f"{{{W_NS}}}fill") == "D9D9D9"
    assert shd[0].get(f"{{{W_NS}}}val") == "clear"


def test_add_cell_shading_inserts_shd_in_tcPr():
    """H4: cell shading w:shd lands inside w:tcPr."""
    doc = Document()
    table = doc.add_table(rows=2, cols=2)
    cell = table.rows[0].cells[0]
    add_cell_shading(cell, fill="4472C4")
    root = get_doc_xml(doc)
    # find the w:tcPr elements
    tcPr_list = find_all(root, "tcPr")
    shd_in_tc = [tc.find(f"{{{W_NS}}}shd") for tc in tcPr_list
                 if tc.find(f"{{{W_NS}}}shd") is not None]
    assert len(shd_in_tc) >= 1, "w:shd missing from w:tcPr"
    assert shd_in_tc[0].get(f"{{{W_NS}}}fill") == "4472C4"


def test_register_multilevel_numbering_creates_abstractNum_with_3_levels():
    """H3 Route B: register creates abstractNum with 3 w:lvl elements."""
    doc = Document()
    num_id = register_multilevel_numbering(doc, levels=3, kind="bullet")
    assert isinstance(num_id, int)
    # Inspect numbering.xml part
    tmp = Path(__file__).parent / "_tmp_num.docx"
    doc.save(tmp)
    try:
        with zipfile.ZipFile(tmp) as z:
            num_xml = z.read("word/numbering.xml")
        root = ET.fromstring(num_xml)
        lvls = root.findall(f".//{{{W_NS}}}lvl")
        # at least 3 levels in the abstractNum we just registered
        assert len(lvls) >= 3, f"expected >=3 levels, found {len(lvls)}"
    finally:
        tmp.unlink()


def test_attach_list_numbering_sets_numPr_with_ilvl():
    """H3 Route B: paragraph gets w:numPr with w:ilvl and w:numId."""
    doc = Document()
    num_id = register_multilevel_numbering(doc, levels=3, kind="bullet")
    p = doc.add_paragraph("nested item")
    attach_list_numbering(p, num_id=num_id, ilvl=1)
    root = get_doc_xml(doc)
    numPr = find_all(root, "numPr")
    ilvl = find_all(root, "ilvl")
    numId_xml = find_all(root, "numId")
    assert len(numPr) >= 1, "w:numPr missing"
    assert len(ilvl) >= 1, "w:ilvl missing"
    assert ilvl[0].get(f"{{{W_NS}}}val") == "1"
    assert len(numId_xml) >= 1, "w:numId missing"
  • Step 2: Run test to verify it fails
pytest tests/test_md2doc_helpers.py -v

Expected: FAIL — ImportError: cannot import name 'add_paragraph_bottom_border'.

  • Step 3: Implement helpers in md2doc.py

Adaugă deasupra convert_md_to_doc:

# ---------------------------------------------------------------------------
# XML manipulation helpers
# Spec ref: docs/specs/2026-07-27-md2doc-design.md §4.1 — verified empirically
# that python-docx 1.2 has no public API for borders/shading/numbering, so
# we manipulate the OOXML directly via OxmlElement.
# ---------------------------------------------------------------------------


def add_paragraph_bottom_border(paragraph, color: str = "000000", size: str = "6"):
    """Attach a bottom border to a paragraph (e.g., Heading 1 underline).

    Spec §4.1 H1: SURVIVED — verified via XML inspection.
    """
    pPr = paragraph._p.get_or_add_pPr()
    pBdr = pPr.find(qn('w:pBdr'))
    if pBdr is None:
        pBdr = OxmlElement('w:pBdr')
        pPr.append(pBdr)
    bottom = OxmlElement('w:bottom')
    bottom.set(qn('w:val'), 'single')
    bottom.set(qn('w:sz'), size)
    bottom.set(qn('w:space'), '1')
    bottom.set(qn('w:color'), color)
    pBdr.append(bottom)


def add_paragraph_shading(paragraph, fill: str):
    """Apply background shading to a paragraph (code, blockquote).

    Spec §4.1 H2: SURVIVED.
    """
    pPr = paragraph._p.get_or_add_pPr()
    shd = OxmlElement('w:shd')
    shd.set(qn('w:val'), 'clear')
    shd.set(qn('w:color'), 'auto')
    shd.set(qn('w:fill'), fill)
    pPr.append(shd)


def add_cell_shading(cell, fill: str):
    """Apply background shading to a table cell (header row).

    Spec §4.1 H4: SURVIVED. Inserts w:shd inside w:tcPr.
    """
    tcPr = cell._tc.get_or_add_tcPr()
    shd = OxmlElement('w:shd')
    shd.set(qn('w:val'), 'clear')
    shd.set(qn('w:color'), 'auto')
    shd.set(qn('w:fill'), fill)
    tcPr.append(shd)


def register_multilevel_numbering(doc, levels: int = 3, kind: str = "bullet") -> int:
    """Register a multi-level numbering definition in the document.

    Returns the numId to reference from paragraphs.

    Spec §4.1 H3 Route B: SURVIVED at XML level. Mandatory because style-name
    approach ('List Bullet 2') produces flat lists in Word (single-level
    abstractNums in default template).

    Args:
        doc: python-docx Document
        levels: number of nesting levels to define (1=flat, 3=three levels deep)
        kind: 'bullet' or 'decimal' for numbered lists
    """
    numbering = doc.part.numbering_part.element

    # Find a free abstractNumId
    existing_abstract_ids = [
        int(an.get(qn('w:abstractNumId')))
        for an in numbering.findall(qn('w:abstractNum'))
    ]
    abstract_num_id = max(existing_abstract_ids, default=-1) + 1

    abstract_num = OxmlElement('w:abstractNum')
    abstract_num.set(qn('w:abstractNumId'), str(abstract_num_id))

    # Multi-level type
    multi_level = OxmlElement('w:multiLevelType')
    multi_level.set(qn('w:val'), 'hybridMultilevel')
    abstract_num.append(multi_level)

    for ilvl in range(levels):
        lvl = OxmlElement('w:lvl')
        lvl.set(qn('w:ilvl'), str(ilvl))
        lvl.set(qn('w:tplc'), '0409000F' if kind == "bullet" else '04070001')

        start = OxmlElement('w:start')
        start.set(qn('w:val'), '1')
        lvl.append(start)

        numFmt = OxmlElement('w:numFmt')
        numFmt.set(qn('w:val'), 'bullet' if kind == "bullet" else 'decimal')
        lvl.append(numFmt)

        lvl_text = OxmlElement('w:lvlText')
        lvl_text.set(qn('w:val'), '•' if kind == "bullet" else f'%{ilvl + 1}.')
        lvl.append(lvl_text)

        lvl_jc = OxmlElement('w:lvlJc')
        lvl_jc.set(qn('w:val'), 'left')
        lvl.append(lvl_jc)

        # Indentation increases per level
        pPr = OxmlElement('w:pPr')
        ind = OxmlElement('w:ind')
        ind.set(qn('w:left'), str(720 * (ilvl + 1)))
        ind.set(qn('w:hanging'), '360')
        pPr.append(ind)
        lvl.append(pPr)

        abstract_num.append(lvl)

    # Insert at beginning of numbering element (before any w:num)
    numbering.insert(0, abstract_num)

    # Find a free numId
    existing_num_ids = [
        int(n.get(qn('w:numId')))
        for n in numbering.findall(qn('w:num'))
    ]
    num_id = max(existing_num_ids, default=0) + 1

    num = OxmlElement('w:num')
    num.set(qn('w:numId'), str(num_id))
    abstract_ref = OxmlElement('w:abstractNumId')
    abstract_ref.set(qn('w:val'), str(abstract_num_id))
    num.append(abstract_ref)
    numbering.append(num)

    return num_id


def attach_list_numbering(paragraph, num_id: int, ilvl: int):
    """Attach numbering to a paragraph at the given indent level.

    Spec §4.1 H3 Route B. Must be paired with register_multilevel_numbering.
    """
    pPr = paragraph._p.get_or_add_pPr()
    # Remove existing numPr if any
    existing = pPr.find(qn('w:numPr'))
    if existing is not None:
        pPr.remove(existing)
    numPr = OxmlElement('w:numPr')
    ilvl_el = OxmlElement('w:ilvl')
    ilvl_el.set(qn('w:val'), str(ilvl))
    numId_el = OxmlElement('w:numId')
    numId_el.set(qn('w:val'), str(num_id))
    numPr.append(ilvl_el)
    numPr.append(numId_el)
    pPr.append(numPr)
  • Step 4: Run tests to verify they pass
pytest tests/test_md2doc_helpers.py -v

Expected: PASS (5 tests).

  • Step 5: Commit
git add md2doc.py tests/test_md2doc_helpers.py
git commit -m "feat(md2doc): XML helpers — borders, shading, multi-level numbering"

Task 4: Heading + paragraph renderer

Files:

  • Modify: md2doc.py (add _render_heading, _render_paragraph above convert_md_to_doc)

  • Test: tests/test_md2doc_renderers.py

  • Step 1: Write failing tests

Creează tests/test_md2doc_renderers.py:

"""Tests for heading and paragraph renderers — verify the EFFECT (text + style land)."""
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path

from docx import Document
from bs4 import BeautifulSoup

from md2doc import STYLES, _render_heading, _render_paragraph

W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"


def get_doc_xml(doc):
    tmp = Path(__file__).parent / "_tmp_render.docx"
    doc.save(tmp)
    try:
        with zipfile.ZipFile(tmp) as z:
            return ET.fromstring(z.read("word/document.xml"))
    finally:
        tmp.unlink()


def test_render_h1_uses_heading1_style_and_text():
    doc = Document()
    style = STYLES["elegant"]
    node = BeautifulSoup("<h1>My Title</h1>", "html.parser").find("h1")
    _render_heading(doc, node, level=1, style=style)
    root = get_doc_xml(doc)
    # Should have a paragraph with pStyle = Heading1
    pStyles = [p.text for p in root.iter(f"{{{W_NS}}}pStyle")]
    assert "Heading1" in pStyles
    # Text content should be present somewhere
    texts = [t.text for t in root.iter(f"{{{W_NS}}}t")]
    assert "My Title" in texts


def test_render_h1_has_bottom_border():
    doc = Document()
    style = STYLES["elegant"]
    node = BeautifulSoup("<h1>Title</h1>", "html.parser").find("h1")
    _render_heading(doc, node, level=1, style=style)
    root = get_doc_xml(doc)
    bottoms = list(root.iter(f"{{{W_NS}}}bottom"))
    assert len(bottoms) >= 1, "h1 should have bottom border"


def test_render_h1_applies_style_heading_color():
    """Heading color should come from STYLES[style]['heading_color']."""
    doc = Document()
    # Use 'report' style which has a distinct heading_color (1e3a8a)
    style = STYLES["report"]
    node = BeautifulSoup("<h1>Title</h1>", "html.parser").find("h1")
    _render_heading(doc, node, level=1, style=style)
    root = get_doc_xml(doc)
    # Look for w:color element in run properties
    colors = [c.get(f"{{{W_NS}}}val") for c in root.iter(f"{{{W_NS}}}color")]
    assert "1e3a8a" in colors, f"expected heading color 1e3a8a from 'report' style, got {colors}"


def test_render_heading_applies_keep_with_next():
    """COMMON['keep_with_next_headings'] should set paragraph_format.keep_with_next=True."""
    doc = Document()
    style = STYLES["elegant"]
    node = BeautifulSoup("<h2>Section</h2>", "html.parser").find("h2")
    _render_heading(doc, node, level=2, style=style)
    root = get_doc_xml(doc)
    # Look for w:keepNext inside w:pPr
    keep_next = list(root.iter(f"{{{W_NS}}}keepNext"))
    assert len(keep_next) >= 1, "expected w:keepNext for heading (COMMON.keep_with_next_headings=True)"


def test_render_h2_no_bottom_border():
    """Only h1 gets border; h2/h3/h4 don't."""
    doc = Document()
    style = STYLES["elegant"]
    node = BeautifulSoup("<h2>Subtitle</h2>", "html.parser").find("h2")
    _render_heading(doc, node, level=2, style=style)
    root = get_doc_xml(doc)
    bottoms = list(root.iter(f"{{{W_NS}}}bottom"))
    assert len(bottoms) == 0


def test_render_paragraph_emits_text():
    doc = Document()
    style = STYLES["elegant"]
    node = BeautifulSoup("<p>Hello world</p>", "html.parser").find("p")
    _render_paragraph(doc, node, style=style)
    root = get_doc_xml(doc)
    texts = [t.text for t in root.iter(f"{{{W_NS}}}t")]
    assert "Hello world" in texts


def test_render_paragraph_handles_bold_inline():
    doc = Document()
    style = STYLES["elegant"]
    node = BeautifulSoup("<p>This is <strong>bold</strong> text</p>", "html.parser").find("p")
    _render_paragraph(doc, node, style=style)
    root = get_doc_xml(doc)
    # The "bold" run should be wrapped in w:b
    b_el = list(root.iter(f"{{{W_NS}}}b"))
    assert len(b_el) >= 1


def test_render_paragraph_handles_inline_code_with_shading():
    """Inline <code> should have monospace font AND run-level shading."""
    doc = Document()
    style = STYLES["elegant"]
    node = BeautifulSoup("<p>See <code>foo()</code> here</p>", "html.parser").find("p")
    _render_paragraph(doc, node, style=style)
    root = get_doc_xml(doc)
    # Inline code shading = w:shd inside w:rPr (run-level, not paragraph-level)
    rPr_list = list(root.iter(f"{{{W_NS}}}rPr"))
    shd_in_runs = [r.find(f"{{{W_NS}}}shd") for r in rPr_list
                   if r.find(f"{{{W_NS}}}shd") is not None]
    assert len(shd_in_runs) >= 1, "expected w:shd inside a w:rPr for inline code"
  • Step 2: Run test to verify it fails
pytest tests/test_md2doc_renderers.py -v

Expected: FAIL — ImportError.

  • Step 3: Implement renderers

Adaugă în md2doc.py deasupra convert_md_to_doc:

# ---------------------------------------------------------------------------
# Element renderers (walk BeautifulSoup DOM → emit docx elements)
# ---------------------------------------------------------------------------


def _render_heading(doc, node, level: int, style: dict):
    """Render <h1>-<h4> 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 _add_run_shading(run, fill: str):
    """Apply background shading to a run (for inline <code> 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_paragraph(doc, node, style: dict):
    """Render <p> as a Word paragraph, preserving inline formatting (strong, em, code, a).

    Also handles <img> children — prints stderr warning and skips (spec §10).
    Note: `_warn_skip_image` is defined in Task 7. The function is module-scoped,
    so it will be available at call-time even though it's defined later in the file.
    """
    p = doc.add_paragraph()
    code_fill = style.get("code_fill", "f5f5f5")

    def _add_runs(element, parent_run=None):
        for child in element.children:
            if isinstance(child, str):
                # Plain text
                if parent_run is not None:
                    parent_run.add_text(child)
                else:
                    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)
  • Step 4: Run tests to verify they pass
pytest tests/test_md2doc_renderers.py -v

Expected: PASS (5 tests).

  • Step 5: Commit
git add md2doc.py tests/test_md2doc_renderers.py
git commit -m "feat(md2doc): heading and paragraph renderers with inline formatting"

Task 5: List renderer using numbering helper

Files:

  • Modify: md2doc.py (add _render_list)

  • Test: tests/test_md2doc_lists.py

  • Step 1: Write failing test

Creează tests/test_md2doc_lists.py:

"""Tests for list rendering — verifies nested levels use correct ilvl."""
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path

from docx import Document
from bs4 import BeautifulSoup

from md2doc import STYLES, _render_list

W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"


def get_doc_xml(doc):
    tmp = Path(__file__).parent / "_tmp_list.docx"
    doc.save(tmp)
    try:
        with zipfile.ZipFile(tmp) as z:
            return ET.fromstring(z.read("word/document.xml"))
    finally:
        tmp.unlink()


def test_flat_bullet_list_produces_3_items_at_ilvl_0():
    html = """
    <ul>
      <li>Item A</li>
      <li>Item B</li>
      <li>Item C</li>
    </ul>
    """
    doc = Document()
    node = BeautifulSoup(html, "html.parser").find("ul")
    _render_list(doc, node, style=STYLES["elegant"])
    root = get_doc_xml(doc)
    ilvls = [int(el.get(f"{{{W_NS}}}val")) for el in root.iter(f"{{{W_NS}}}ilvl")]
    assert ilvls == [0, 0, 0], f"expected all ilvl=0, got {ilvls}"


def test_nested_bullet_list_uses_increasing_ilvl():
    html = """
    <ul>
      <li>Top
        <ul>
          <li>Nested 1
            <ul>
              <li>Deep nested</li>
            </ul>
          </li>
        </ul>
      </li>
    </ul>
    """
    doc = Document()
    node = BeautifulSoup(html, "html.parser").find("ul")
    _render_list(doc, node, style=STYLES["elegant"])
    root = get_doc_xml(doc)
    ilvls = [int(el.get(f"{{{W_NS}}}val")) for el in root.iter(f"{{{W_NS}}}ilvl")]
    # Expect ilvl 0, 1, 2 across the nesting
    assert ilvls == [0, 1, 2], f"expected 0,1,2 nesting, got {ilvls}"


def test_numbered_list_uses_decimal_format():
    html = """
    <ol>
      <li>First</li>
      <li>Second</li>
    </ol>
    """
    doc = Document()
    node = BeautifulSoup(html, "html.parser").find("ol")
    _render_list(doc, node, style=STYLES["elegant"])
    root = get_doc_xml(doc)
    # numbering.xml should have a numFmt = decimal
    tmp = Path(__file__).parent / "_tmp_num.docx"
    doc.save(tmp)
    try:
        with zipfile.ZipFile(tmp) as z:
            num_xml = z.read("word/numbering.xml")
        num_root = ET.fromstring(num_xml)
        fmts = [el.get(f"{{{W_NS}}}val") for el in num_root.iter(f"{{{W_NS}}}numFmt")]
        assert "decimal" in fmts
    finally:
        tmp.unlink()
  • Step 2: Run test to verify it fails
pytest tests/test_md2doc_lists.py -v

Expected: FAIL — ImportError.

  • Step 3: Implement list renderer

Adaugă în md2doc.py:

def _render_list(doc, node, style: dict, level: int = 0, num_state: dict = None):
    """Render <ul>/<ol> 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=5, 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)
        # Extract nested lists separately so they recurse after the parent item
        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)
  • Step 4: Run tests
pytest tests/test_md2doc_lists.py -v

Expected: PASS (3 tests).

  • Step 4.5: H3 keystone manual checkpoint (mandatory before commit)

Spec §4.2 mandates visual verification of nested lists in Word — XML-level tests cannot prove visual nesting. Before committing, generate a test docx and open it manually:

python3 -c "
from pathlib import Path
from docx import Document
import sys; sys.path.insert(0, '.')
from md2doc import register_multilevel_numbering, attach_list_numbering
doc = Document()
num_id = register_multilevel_numbering(doc, levels=3, kind='bullet')
for ilvl, text in enumerate(['Top', 'Nested 1', 'Deep nested']):
    p = doc.add_paragraph(text)
    attach_list_numbering(p, num_id=num_id, ilvl=ilvl)
doc.save('/tmp/h3_keystone_check.docx')
print('Generated /tmp/h3_keystone_check.docx — open in Word/Pages')
"
open /tmp/h3_keystone_check.docx

Verify visually: 3 levels of bullets, each indented further than the previous. If nesting is visually flat or broken, STOP and revisit register_multilevel_numbering before continuing. Spec §4.2 is the reference.

  • Step 5: Commit
git add md2doc.py tests/test_md2doc_lists.py
git commit -m "feat(md2doc): recursive list renderer with manual numbering"

Task 6: Table, code block, blockquote, hr renderers

Files:

  • Modify: md2doc.py (add _render_table, _render_pre, _render_blockquote, _render_hr)

  • Test: tests/test_md2doc_blocks.py

  • Step 1: Write failing tests

Creează tests/test_md2doc_blocks.py:

"""Tests for block-level element renderers."""
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path

from docx import Document
from bs4 import BeautifulSoup

from md2doc import STYLES, _render_table, _render_pre, _render_blockquote, _render_hr

W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"


def get_doc_xml(doc):
    tmp = Path(__file__).parent / "_tmp_blocks.docx"
    doc.save(tmp)
    try:
        with zipfile.ZipFile(tmp) as z:
            return ET.fromstring(z.read("word/document.xml"))
    finally:
        tmp.unlink()


def test_table_renders_with_header_shading():
    html = """
    <table>
      <thead><tr><th>Name</th><th>Value</th></tr></thead>
      <tbody>
        <tr><td>A</td><td>1</td></tr>
      </tbody>
    </table>
    """
    doc = Document()
    node = BeautifulSoup(html, "html.parser").find("table")
    _render_table(doc, node, style=STYLES["elegant"])
    root = get_doc_xml(doc)
    # Table should have 2 rows (1 header + 1 body)
    trs = list(root.iter(f"{{{W_NS}}}tr"))
    assert len(trs) == 2
    # Header row cells should have shading
    shds = list(root.iter(f"{{{W_NS}}}shd"))
    assert len(shds) >= 2  # at least 2 header cells


def test_pre_renders_with_shading():
    html = '<pre><code>print("hello")</code></pre>'
    doc = Document()
    node = BeautifulSoup(html, "html.parser").find("pre")
    _render_pre(doc, node, style=STYLES["elegant"])
    root = get_doc_xml(doc)
    shds = list(root.iter(f"{{{W_NS}}}shd"))
    assert len(shds) >= 1
    # Code text should be present
    texts = [t.text for t in root.iter(f"{{{W_NS}}}t")]
    assert any('print' in t for t in texts)


def test_blockquote_renders_with_indent_and_italic():
    html = "<blockquote>A quoted passage</blockquote>"
    doc = Document()
    node = BeautifulSoup(html, "html.parser").find("blockquote")
    _render_blockquote(doc, node, style=STYLES["elegant"])
    root = get_doc_xml(doc)
    # Italic should be applied
    italic_el = list(root.iter(f"{{{W_NS}}}i"))
    assert len(italic_el) >= 1
    # Indent should be set
    indents = list(root.iter(f"{{{W_NS}}}ind"))
    assert len(indents) >= 1


def test_hr_renders_as_paragraph_with_bottom_border():
    html = "<hr/>"
    doc = Document()
    node = BeautifulSoup(html, "html.parser").find("hr")
    _render_hr(doc, node, style=STYLES["elegant"])
    root = get_doc_xml(doc)
    bottoms = list(root.iter(f"{{{W_NS}}}bottom"))
    assert len(bottoms) >= 1
  • Step 2: Run test to verify it fails
pytest tests/test_md2doc_blocks.py -v

Expected: FAIL — ImportError.

  • Step 3: Implement block renderers

Adaugă în md2doc.py:

def _render_table(doc, node, style: dict):
    """Render <table> with header row shading."""
    header_fill = style.get("table_header_fill", "1e3a8a")

    rows = node.find_all("tr")
    if not rows:
        return

    # Determine column count
    cols = max(len(row.find_all(["th", "td"])) for row in rows)
    table = doc.add_table(rows=len(rows), cols=cols)
    table.style = 'Table Grid'

    for i, row in enumerate(rows):
        cells = row.find_all(["th", "td"])
        is_header = row.parent.name == "thead" or any(c.name == "th" for c in cells)
        for j, cell in enumerate(cells):
            if j >= cols:
                break
            tc = table.rows[i].cells[j]
            tc.text = cell.get_text(strip=True)
            if is_header:
                add_cell_shading(tc, fill=header_fill)


def _render_pre(doc, node, style: dict):
    """Render <pre> (code block) — shaded background + monospace font.

    Preserves newlines via add_break() between lines (Word ignores literal \\n in runs).
    Leading whitespace on each line is preserved as literal spaces (monospace font
    keeps alignment).
    """
    code_fill = style.get("code_fill", "f7f8fa")
    # Get text without collapse — preserves whitespace and indentation
    text = node.get_text()
    lines = text.split("\n")

    p = doc.add_paragraph()
    for i, line in enumerate(lines):
        if i > 0:
            p.add_run().add_break()
        run = p.add_run(line)
        run.font.name = "Menlo"
        run.font.size = Pt(8)
    add_paragraph_shading(p, fill=code_fill)


def _render_blockquote(doc, node, style: dict):
    """Render <blockquote> — left indent + italic + shading."""
    quote_fill = style.get("blockquote_fill", "f7f8fa")
    text = node.get_text(strip=True)

    p = doc.add_paragraph()
    p.paragraph_format.left_indent = Cm(1)
    run = p.add_run(text)
    run.italic = True
    add_paragraph_shading(p, fill=quote_fill)


def _render_hr(doc, node, style: dict):
    """Render <hr> as empty paragraph with bottom border."""
    p = doc.add_paragraph()
    add_paragraph_bottom_border(p, color="d8d8d8", size="6")
  • Step 4: Run tests
pytest tests/test_md2doc_blocks.py -v

Expected: PASS (4 tests).

  • Step 5: Commit
git add md2doc.py tests/test_md2doc_blocks.py
git commit -m "feat(md2doc): table, pre, blockquote, hr renderers"

Task 7: Image skip-with-warning + converter orchestration

Files:

  • Modify: md2doc.py (add _warn_skip_image, replace convert_md_to_doc stub)

  • Test: tests/test_md2doc_integration.py

  • Step 1: Write failing tests

Creează tests/test_md2doc_integration.py:

"""Integration test — full md → docx conversion on pluxee-todo.md fixture."""
import subprocess
import sys
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path

REPO = Path(__file__).parent.parent
FIXTURE = REPO / "pluxee-todo.md"
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"


def test_convert_pluxee_todo_creates_valid_docx(tmp_path):
    out = tmp_path / "pluxee.docx"
    r = subprocess.run(
        [sys.executable, "md2doc.py", str(FIXTURE), "-o", str(out)],
        cwd=REPO, capture_output=True, text=True
    )
    assert r.returncode == 0, f"Conversion failed: {r.stderr}"
    assert out.exists()
    assert out.stat().st_size > 0


def test_converted_docx_has_headings_and_paragraphs(tmp_path):
    out = tmp_path / "pluxee.docx"
    subprocess.run(
        [sys.executable, "md2doc.py", str(FIXTURE), "-o", str(out), "-q"],
        cwd=REPO, capture_output=True, text=True
    )
    with zipfile.ZipFile(out) as z:
        root = ET.fromstring(z.read("word/document.xml"))
    # pluxee-todo.md has multiple ## headings and paragraphs
    headings = list(root.iter(f"{{{W_NS}}}pStyle"))
    heading_vals = [h.get(f"{{{W_NS}}}val") for h in headings]
    assert any(v.startswith("Heading") for v in heading_vals), \
        f"expected at least one Heading style, got {heading_vals}"


def test_image_in_markdown_warns_to_stderr(tmp_path):
    md_with_image = tmp_path / "img.md"
    md_with_image.write_text("# Test\n\n![alt text](https://example.com/x.png)\n")
    out = tmp_path / "img.docx"
    r = subprocess.run(
        [sys.executable, "md2doc.py", str(md_with_image), "-o", str(out)],
        cwd=REPO, capture_output=True, text=True
    )
    assert r.returncode == 0  # warning, not error
    assert "image" in r.stderr.lower() or "skip" in r.stderr.lower()


def test_directory_mode_converts_all_md(tmp_path):
    # Create 2 markdown files
    (tmp_path / "a.md").write_text("# A\n")
    (tmp_path / "b.md").write_text("# B\n")
    out_dir = tmp_path / "out"
    r = subprocess.run(
        [sys.executable, "md2doc.py", str(tmp_path), "-o", str(out_dir)],
        cwd=REPO, capture_output=True, text=True
    )
    assert r.returncode == 0
    assert (out_dir / "a.docx").exists()
    assert (out_dir / "b.docx").exists()
  • Step 2: Run test to verify it fails
pytest tests/test_md2doc_integration.py -v

Expected: FAIL — NotImplementedError from stub.

  • Step 3: Implement converter

Adaugă warning helper și înlocuiește stub-ul convert_md_to_doc:

def _warn_skip_image(node):
    """Warn to stderr when an <img> is encountered (out of scope per spec §10)."""
    src = node.get("src", "(no src)")
    print(f"Warning: image skipped (out of scope): {src}", file=sys.stderr)


def convert_md_to_doc(md_file: Path, output_file: Path, style: str = "elegant") -> Path:
    """Convert a single markdown file to DOCX."""
    import re

    style_cfg = STYLES.get(style, STYLES["elegant"])

    with open(md_file, 'r', encoding='utf-8') as f:
        md_content = f.read()

    # Reuse _bulletize and BLANK_MARKER logic from md2pdf (copied, not imported)
    md_content = _bulletize(md_content)

    BLANK_MARKER = '<!--blank-line-->'
    md_content = re.sub(
        r'\n{3,}',
        lambda m: '\n\n' + (BLANK_MARKER + '\n\n') * (len(m.group(0)) - 2),
        md_content
    )

    html_content = markdown.markdown(
        md_content,
        extensions=[
            'markdown.extensions.tables',
            'markdown.extensions.fenced_code',
            'markdown.extensions.toc',
            'markdown.extensions.attr_list',
            'markdown.extensions.md_in_html',
            'markdown.extensions.nl2br',
        ]
    )
    html_content = html_content.replace(
        BLANK_MARKER, '<div style="height: 0.8em;"></div>'
    )

    soup = BeautifulSoup(html_content, "lxml")
    doc = Document()

    # Walk top-level elements in <body>
    body = soup.find("body") or soup
    for element in body.children:
        if not hasattr(element, "name") or element.name is None:
            continue
        name = element.name

        if name in ("h1", "h2", "h3", "h4"):
            _render_heading(doc, element, level=int(name[1]), style=style_cfg)
        elif name == "p":
            _render_paragraph(doc, element, style=style_cfg)
        elif name in ("ul", "ol"):
            _render_list(doc, element, style=style_cfg)
        elif name == "table":
            _render_table(doc, element, style=style_cfg)
        elif name == "pre":
            _render_pre(doc, element, style=style_cfg)
        elif name == "blockquote":
            _render_blockquote(doc, element, style=style_cfg)
        elif name == "hr":
            _render_hr(doc, element, style=style_cfg)
        elif name == "img":
            _warn_skip_image(element)
        elif name == "figure":
            # Check for inner img
            img = element.find("img")
            if img:
                _warn_skip_image(img)
            else:
                # Render figure text content as paragraph
                _render_paragraph(doc, element, style=style_cfg)
        # else: silently skip unknown elements (divs, spans, etc.)

    doc.save(output_file)
    return output_file
  • Step 4: Run tests
pytest tests/test_md2doc_integration.py -v

Expected: PASS (4 tests).

  • Step 5: Manual visual check
python3 md2doc.py pluxee-todo.md -o /tmp/pluxee-todo.docx
open /tmp/pluxee-todo.docx

Inspect visually in Word/Pages:

  • Headings styled + H1 has bottom border

  • Bullet lists are nested where markdown had nesting

  • Tables have header row shading

  • Code blocks have shading

  • No images (should have stderr warnings)

  • Step 6: Commit

git add md2doc.py tests/test_md2doc_integration.py
git commit -m "feat(md2doc): full converter — walk BeautifulSoup DOM, emit docx elements"

Files:

  • Modify: md2pdf.py (lines around 670-676 for argparse, 728 for convert call)

  • Test: tests/test_md2pdf_footer.py

  • Step 1: Write failing test

Creează tests/test_md2pdf_footer.py:

"""B1 fix: --footer flag should be accepted and passed to convert_md_to_pdf."""
import subprocess
import sys
from pathlib import Path

REPO = Path(__file__).parent.parent


def test_footer_flag_accepted():
    """--footer should be in --help output."""
    r = subprocess.run(
        [sys.executable, "md2pdf.py", "--help"],
        cwd=REPO, capture_output=True, text=True
    )
    assert "--footer" in r.stdout


def test_footer_produces_pdf(tmp_path):
    """End-to-end: --footer flag should produce a PDF successfully."""
    md = tmp_path / "test.md"
    md.write_text("# Test\nHello\n")
    out = tmp_path / "out.pdf"
    r = subprocess.run(
        [sys.executable, "md2pdf.py", str(md), "-o", str(out), "--footer", "Confidential v1.0"],
        cwd=REPO, capture_output=True, text=True
    )
    assert r.returncode == 0, f"stderr: {r.stderr}"
    assert out.exists()
  • Step 2: Run test to verify it fails
pytest tests/test_md2pdf_footer.py -v

Expected: FAIL — --footer not in help output.

  • Step 3: Modify md2pdf.py argparse

În md2pdf.py (curent 747 linii):

  1. La linia 674-676 — șterge cele 2 linii cu --forms (B2 le va face redundant; se suprapune cu Task 9, darTask 8 poate ține pasul doar cu adăugarea --footer). Alternativ, dacă Task 9 se face înainte, sări peste acest sub-punct.

  2. La linia 676 (după parser.add_argument('-q', '--quiet', ..., help='Suppress output')) — adaugă imediat sub:

    parser.add_argument('--footer',
                        help='Custom footer text (right-aligned, appears in @bottom-right)')
  1. La linia 728 — înlocuiește apelul:
            convert_md_to_pdf(md_file, pdf_file, args.style, forms=args.forms)

cu:

            convert_md_to_pdf(md_file, pdf_file, args.style,
                              forms=args.forms, footer=args.footer or "")

(Notă: forms=args.forms rămâne până când Task 9 șterge --forms.)

  • Step 4: Run tests
pytest tests/test_md2pdf_footer.py -v

Expected: PASS (2 tests).

  • Step 5: Commit
git add md2pdf.py tests/test_md2pdf_footer.py
git commit -m "fix(md2pdf): expose --footer in CLI (was dead code)"

Task 9: B2 fix — remove --forms from md2pdf.py

Files:

  • Modify: md2pdf.py (remove --forms arg, remove forms param, remove options={'pdf_forms': forms})

  • Test: tests/test_md2pdf_no_forms.py

  • Step 1: Write failing test (characterization — proves forms are gone)

Creează tests/test_md2pdf_no_forms.py:

"""B2 fix: --forms is removed. Verified empirically that weasyprint 68.1 silently
drops <input>/<textarea> HTML tags, so the flag never worked anyway."""
import subprocess
import sys
from pathlib import Path

REPO = Path(__file__).parent.parent


def test_forms_flag_not_in_help():
    r = subprocess.run(
        [sys.executable, "md2pdf.py", "--help"],
        cwd=REPO, capture_output=True, text=True
    )
    assert "--forms" not in r.stdout, "--forms should be removed"


def test_forms_flag_rejected():
    md = Path(__file__).parent / "_tmp.md"
    md.write_text("# test\n")
    try:
        r = subprocess.run(
            [sys.executable, "md2pdf.py", str(md), "--forms"],
            cwd=REPO, capture_output=True, text=True
        )
        assert r.returncode != 0
        assert "unrecognized arguments" in r.stderr or "error" in r.stderr.lower()
    finally:
        md.unlink()
  • Step 2: Run test to verify it fails
pytest tests/test_md2pdf_no_forms.py -v

Expected: FAIL — --forms is still in help.

  • Step 3: Remove forms from md2pdf.py

În md2pdf.py:

  1. Șterge aceste linii din argparse:
    parser.add_argument('--forms', action='store_true',
                        help='Generate fillable PDF form fields from <input>/<textarea>')
  1. Modifică semnătura funcției convert_md_to_pdf din:
def convert_md_to_pdf(md_file: Path, output_file: Path, style: str = "report",
                      forms: bool = False, footer: str = "") -> Path:

la:

def convert_md_to_pdf(md_file: Path, output_file: Path, style: str = "report",
                      footer: str = "") -> Path:
  1. Modifică apelul write_pdf din:
    html_doc.write_pdf(
        output_file,
        font_config=font_config,
        options={'pdf_forms': forms} if forms else None,
    )

la:

    html_doc.write_pdf(
        output_file,
        font_config=font_config,
    )
  1. Modifică apelul din main din:
            convert_md_to_pdf(md_file, pdf_file, args.style,
                              forms=args.forms, footer=args.footer or "")

la:

            convert_md_to_pdf(md_file, pdf_file, args.style,
                              footer=args.footer or "")
  1. Actualizează epilog-ul argparse ca să elimini linia cu --forms:
  %(prog)s contract.md --forms            Fillable PDF form fields from <input>/<textarea>

devine (șterge linia).

  • Step 4: Run tests + verify regression on existing PDF still works
pytest tests/test_md2pdf_no_forms.py tests/test_md2pdf_footer.py -v
# Verify existing functionality unchanged
python3 md2pdf.py pluxee-todo.md -o /tmp/pluxee-after.pdf
diff <(xxd pluxee-todo.pdf) <(xxd /tmp/pluxee-after.pdf) | head -5

Expected: PASS (all tests). PDF byte-identical (dacă --footer nu e folosit, output-ul trebuie să fie la fel ca înainte).

Notă siguranță: Task 9 schimbă semnătura convert_md_to_pdf din (md_file, output_file, style, forms=False, footer="") în (md_file, output_file, style, footer=""). footer rămâne param keyword-only și compatible cu apelul din Task 8 (convert_md_to_pdf(md_file, pdf_file, args.style, footer=args.footer or "")). Dacă Task 9 se execută înaintea Task 8, înlocuiește step-ul Task 8.3 cu versiunea fără forms= (vezi Task 9 Step 3).

  • Step 5: Commit
git add md2pdf.py tests/test_md2pdf_no_forms.py
git commit -m "fix(md2pdf): remove --forms flag (weasyprint 68.1 silently drops <input>/<textarea> HTML)"

Task 10: Style smoke test (characterization)

Note: This is a characterization / smoke test, not strict TDD. The functionality is already built in Tasks 1-9 — this task verifies all 5 styles produce valid output, plus offers a visual checkpoint before merging.

Files:

  • Test: tests/test_md2doc_styles.py

  • Step 1: Write style smoke tests

Creează tests/test_md2doc_styles.py:

"""Smoke test — all 5 styles produce valid DOCX from same input."""
import subprocess
import sys
from pathlib import Path

REPO = Path(__file__).parent.parent


def test_all_styles_produce_valid_docx(tmp_path):
    fixture = REPO / "pluxee-todo.md"
    for style in ("elegant", "report", "default", "dark", "mono"):
        out = tmp_path / f"pluxee-{style}.docx"
        r = subprocess.run(
            [sys.executable, "md2doc.py", str(fixture), "-o", str(out), "--style", style, "-q"],
            cwd=REPO, capture_output=True, text=True
        )
        assert r.returncode == 0, f"style={style} failed: stderr={r.stderr}"
        assert out.exists()
        assert out.stat().st_size > 1000, f"style={style} produced too-small file"
  • Step 2: Run test
pytest tests/test_md2doc_styles.py -v

Expected: PASS.

  • Step 3: Visual smoke check
for s in elegant report default dark mono; do
    python3 md2doc.py pluxee-todo.md -o /tmp/pluxee-$s.docx --style $s -q
done
ls -la /tmp/pluxee-*.docx
# Open each in Word/Pages to verify visual correctness
  • Step 4: Commit
git add tests/test_md2doc_styles.py
git commit -m "test(md2doc): style smoke test for all 5 styles"

Notes for executor

  • Test discipline (spy-test trap): All helper tests inspect the resulting .docx XML, not mock-call assertions. If a test passes by accident (e.g., the XML is correct for the wrong reason), the visual smoke test in Task 7 Step 5 is the backstop.
  • H3 keystone: The list renderer (Task 5) depends on register_multilevel_numbering producing XML that Word renders visually as nested lists. XML-level tests cannot prove visual nesting — the manual visual check in Task 5 Step 4.5 is the actual break-test for H3 (reinforced by end-to-end check in Task 7 Step 5).
  • md2pdf.py file size: 747 lines (over 300 threshold). Modifications here are surgical (B1: +2 lines, B2: -5 lines). Refactor is out of scope per spec §10.
  • Regression safety for md2pdf bugfixes: After Task 9, the byte-identical check on pluxee-todo.pdf proves B1+B2 didn't change default behavior.
  • Test fixture location: pluxee-todo.md lives at repo root, not under tests/fixtures/. Acceptable for a single-file project (no test framework isolation needed). If a tests/fixtures/ convention emerges later, move it then.
  • Task 2 Step 3 critical: Do NOT overwrite md2doc.py — extend it in place. _bulletize from Task 1.5 must survive.
  • COMMON config wired: keep_with_next_headings is applied in _render_heading via paragraph_format.keep_with_next = True. Other COMMON keys (page_break_before_h1, table_cell_valign) are scaffolding for future — page_break_before_h1 IS read in _render_heading but defaults to False, and table_cell_valign is a TODO (python-docx doesn't expose cell vertical-align via API; needs XML manipulation if needed).
  • Security note: Tests use stdlib xml.etree.ElementTree to parse .docx XML. Since the input is generated by our own code (not external/untrusted), XXE risk is zero. If we ever parse .docx from external sources, switch to defusedxml.ElementTree.