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."""
+ code_fill = style.get("code_fill", "f7f8fa")
+ text = node.get_text()
+
+ p = doc.add_paragraph()
+ run = p.add_run(text)
+ 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 /