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 (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 + 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 — 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 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\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 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 = ''
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, '
'
)
soup = BeautifulSoup(html_content, "lxml")
doc = Document()
# Walk top-level elements in
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 /