Empirical audit (Stage 2) found 2 critical spy tests guarding the H3 keystone:
- test_register_multilevel_numbering_creates_abstractNum_with_3_levels
- test_nested_bullet_list_uses_increasing_ilvl
Both passed when register_multilevel_numbering was mutated to 'return 1'
(no XML written). Rewrote both to BEHAVIORAL form: trace numId → w:num →
w:abstractNumId → w:abstractNum, assert THAT abstractNum has ilvl=[0,1,2].
Also: _render_pre now drops trailing empty line (text.split('\n') artifact).
65 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):
STYLESdict with 5 stiluri (elegant default, default, dark, mono, report)COMMON_CSSstring injectat după stilul ales_bulletize(text)— transformă-în•dar nu în code/HTML blocuriBLANK_MARKERinjection pentru multi-blank-line preservationconvert_md_to_pdf(md_file, output_file, style, forms, footer)— main convertermain()— argparse cuinput,-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:abstractNummulti-level + per-paragraphw:numPrcuilvl- weasyprint 68.1 silently drop
<input>/<textarea>—--formsnu 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_MARKERhandling — 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ă--footerargument (B1)md2pdf.py:728— passeazăfooter=args.footerlaconvert_md_to_pdf(B1)md2pdf.py:675-676— șterge--formsargument (B2)md2pdf.py:728— ștergeforms=args.formsdin apel (B2)md2pdf.py:577,646— ștergeformsparam dinconvert_md_to_pdfșioptions={'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șilxmlnu 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:
- Păstrează shebang-ul și docstring-ul existent
- 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
- Adaugă
STYLESșiCOMMONdict-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 aboveconvert_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 ONE abstractNum with 3 w:lvl elements (ilvl 0/1/2).
BEHAVIORAL: traces num_id → w:num → w:abstractNumId → w:abstractNum, then
asserts THAT abstractNum (not all 9 default abstractNums in the template)
has exactly the levels we registered. A `return 1` no-op mutation must FAIL
because no w:num with our id exists.
"""
doc = Document()
num_id = register_multilevel_numbering(doc, levels=3, kind="bullet")
assert isinstance(num_id, int)
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)
# Find w:num matching our num_id
our_num = None
for num_el in root.findall(f"{{{W_NS}}}num"):
if num_el.get(f"{{{W_NS}}}numId") == str(num_id):
our_num = num_el
break
assert our_num is not None, f"No <w:num> with numId={num_id} (function did nothing)"
# Follow to abstractNumId
abstract_ref = our_num.find(f"{{{W_NS}}}abstractNumId")
assert abstract_ref is not None, "missing w:abstractNumId in our w:num"
abstract_id = abstract_ref.get(f"{{{W_NS}}}val")
# Find that abstractNum and verify it has the 3 levels
our_abstract = None
for an in root.findall(f"{{{W_NS}}}abstractNum"):
if an.get(f"{{{W_NS}}}abstractNumId") == abstract_id:
our_abstract = an
break
assert our_abstract is not None, f"No <w:abstractNum> with id={abstract_id}"
lvls = our_abstract.findall(f"{{{W_NS}}}lvl")
ilvls = sorted(int(l.get(f"{{{W_NS}}}ilvl")) for l in lvls)
assert ilvls == [0, 1, 2], f"expected ilvl=[0,1,2] in OUR abstractNum, got {ilvls}"
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_paragraphaboveconvert_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_and_resolves_to_multilevel_abstractNum():
"""BEHAVIORAL: not only must paragraphs have ilvl=[0,1,2], but the numId they
reference must resolve to an abstractNum that actually defines those levels.
A no-op register_multilevel_numbering (return 1) would leave paragraphs
pointing to default-template numId=1 (single-level), so the test must FAIL.
"""
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"])
# Save and read both document.xml AND numbering.xml
tmp = Path(__file__).parent / "_tmp_list_full.docx"
doc.save(tmp)
try:
with zipfile.ZipFile(tmp) as z:
doc_xml = z.read("word/document.xml")
num_xml = z.read("word/numbering.xml")
doc_root = ET.fromstring(doc_xml)
num_root = ET.fromstring(num_xml)
# 1. Paragraphs must have ilvl=[0,1,2]
ilvls = [int(el.get(f"{{{W_NS}}}val"))
for el in doc_root.iter(f"{{{W_NS}}}ilvl")]
assert ilvls == [0, 1, 2], f"expected paragraph ilvl=[0,1,2], got {ilvls}"
# 2. All paragraphs must reference the SAME numId (our registered numbering)
numIds = [el.get(f"{{{W_NS}}}val")
for el in doc_root.iter(f"{{{W_NS}}}numId")]
assert len(set(numIds)) == 1, \
f"expected all paragraphs to share one numId, got {set(numIds)}"
our_num_id = numIds[0]
# 3. That numId must resolve (via w:num → w:abstractNumId) to an abstractNum
# that defines ilvl=0,1,2 — otherwise Word renders flat lists.
our_num = None
for num_el in num_root.findall(f"{{{W_NS}}}num"):
if num_el.get(f"{{{W_NS}}}numId") == our_num_id:
our_num = num_el
break
assert our_num is not None, f"our numId={our_num_id} not found in numbering.xml"
abstract_ref = our_num.find(f"{{{W_NS}}}abstractNumId")
assert abstract_ref is not None
abstract_id = abstract_ref.get(f"{{{W_NS}}}val")
our_abstract = None
for an in num_root.findall(f"{{{W_NS}}}abstractNum"):
if an.get(f"{{{W_NS}}}abstractNumId") == abstract_id:
our_abstract = an
break
assert our_abstract is not None, f"abstractNum {abstract_id} not found"
abstract_ilvls = sorted(int(l.get(f"{{{W_NS}}}ilvl"))
for l in our_abstract.findall(f"{{{W_NS}}}lvl"))
assert abstract_ilvls == [0, 1, 2], \
f"our abstractNum must define ilvl=[0,1,2], got {abstract_ilvls}"
finally:
tmp.unlink()
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). Trailing empty line from markdown's final \\n is dropped.
"""
code_fill = style.get("code_fill", "f7f8fa")
# Get text without collapse — preserves whitespace and indentation
text = node.get_text()
# Drop trailing empty line(s) from markdown's terminal \n — would create
# spurious extra <w:br/> + empty run.
lines = text.split("\n")
while lines and lines[-1] == "":
lines.pop()
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, replaceconvert_md_to_docstub) -
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\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"
Task 8: B1 fix — expose --footer in md2pdf.py CLI
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):
-
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. -
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)')
- 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--formsarg, removeformsparam, removeoptions={'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:
- Șterge aceste linii din argparse:
parser.add_argument('--forms', action='store_true',
help='Generate fillable PDF form fields from <input>/<textarea>')
- Modifică semnătura funcției
convert_md_to_pdfdin:
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:
- Modifică apelul
write_pdfdin:
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,
)
- 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 "")
- 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
.docxXML, 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_numberingproducing 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.pdfproves B1+B2 didn't change default behavior. - Test fixture location:
pluxee-todo.mdlives at repo root, not undertests/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._bulletizefrom Task 1.5 must survive. - COMMON config wired:
keep_with_next_headingsis applied in_render_headingviaparagraph_format.keep_with_next = True. Other COMMON keys (page_break_before_h1,table_cell_valign) are scaffolding for future —page_break_before_h1IS read in_render_headingbut defaults to False, andtable_cell_valignis a TODO (python-docx doesn't expose cell vertical-align via API; needs XML manipulation if needed). - Security note: Tests use stdlib
xml.etree.ElementTreeto parse.docxXML. Since the input is generated by our own code (not external/untrusted), XXE risk is zero. If we ever parse.docxfrom external sources, switch todefusedxml.ElementTree.