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

1946 lines
61 KiB
Markdown

# 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`:
```python
"""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**
```bash
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**
```bash
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**
```bash
pytest tests/test_deps.py -v
```
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
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`:
```python
"""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**
```bash
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`):
```python
#!/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**
```bash
pytest tests/test_bulletize.py -v
```
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
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`:
```python
"""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**
```bash
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`):
```python
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
```
3. Adaugă `STYLES` și `COMMON` dict-uri (codul de mai jos) **după** `_bulletize`:
```python
#!/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**
```bash
pytest tests/test_md2doc_cli.py -v
```
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
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`:
```python
"""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**
```bash
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`:
```python
# ---------------------------------------------------------------------------
# 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**
```bash
pytest tests/test_md2doc_helpers.py -v
```
Expected: PASS (5 tests).
- [ ] **Step 5: Commit**
```bash
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`:
```python
"""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**
```bash
pytest tests/test_md2doc_renderers.py -v
```
Expected: FAIL — `ImportError`.
- [ ] **Step 3: Implement renderers**
Adaugă în `md2doc.py` deasupra `convert_md_to_doc`:
```python
# ---------------------------------------------------------------------------
# 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**
```bash
pytest tests/test_md2doc_renderers.py -v
```
Expected: PASS (5 tests).
- [ ] **Step 5: Commit**
```bash
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`:
```python
"""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**
```bash
pytest tests/test_md2doc_lists.py -v
```
Expected: FAIL — `ImportError`.
- [ ] **Step 3: Implement list renderer**
Adaugă în `md2doc.py`:
```python
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**
```bash
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:
```bash
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**
```bash
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`:
```python
"""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**
```bash
pytest tests/test_md2doc_blocks.py -v
```
Expected: FAIL — `ImportError`.
- [ ] **Step 3: Implement block renderers**
Adaugă în `md2doc.py`:
```python
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**
```bash
pytest tests/test_md2doc_blocks.py -v
```
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
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`:
```python
"""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**
```bash
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`:
```python
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**
```bash
pytest tests/test_md2doc_integration.py -v
```
Expected: PASS (4 tests).
- [ ] **Step 5: Manual visual check**
```bash
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**
```bash
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`:
```python
"""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**
```bash
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:
```python
parser.add_argument('--footer',
help='Custom footer text (right-aligned, appears in @bottom-right)')
```
3. La linia **728** — înlocuiește apelul:
```python
convert_md_to_pdf(md_file, pdf_file, args.style, forms=args.forms)
```
cu:
```python
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**
```bash
pytest tests/test_md2pdf_footer.py -v
```
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
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`:
```python
"""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**
```bash
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:
```python
parser.add_argument('--forms', action='store_true',
help='Generate fillable PDF form fields from <input>/<textarea>')
```
2. Modifică semnătura funcției `convert_md_to_pdf` din:
```python
def convert_md_to_pdf(md_file: Path, output_file: Path, style: str = "report",
forms: bool = False, footer: str = "") -> Path:
```
la:
```python
def convert_md_to_pdf(md_file: Path, output_file: Path, style: str = "report",
footer: str = "") -> Path:
```
3. Modifică apelul `write_pdf` din:
```python
html_doc.write_pdf(
output_file,
font_config=font_config,
options={'pdf_forms': forms} if forms else None,
)
```
la:
```python
html_doc.write_pdf(
output_file,
font_config=font_config,
)
```
4. Modifică apelul din main din:
```python
convert_md_to_pdf(md_file, pdf_file, args.style,
forms=args.forms, footer=args.footer or "")
```
la:
```python
convert_md_to_pdf(md_file, pdf_file, args.style,
footer=args.footer or "")
```
5. 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**
```bash
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**
```bash
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`:
```python
"""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**
```bash
pytest tests/test_md2doc_styles.py -v
```
Expected: PASS.
- [ ] **Step 3: Visual smoke check**
```bash
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**
```bash
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`.