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
1946 lines
61 KiB
Markdown
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\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`.
|