# md2doc — design spec **Data:** 2026-07-27 **Autor:** Sebastian Petrescu **Stare:** draft (pending empirical review, post Stage 1 review fixes) --- ## 1. Obiectiv Construiește `md2doc.py` — un utilitar single-file Python care convertește Markdown la `.docx` (Microsoft Word OOXML), simetric cu `md2pdf.py` existent. În același plan: bug-fixes pentru `md2pdf.py` identificate în timpul brainstorming-ului. ## 2. Context `md2pdf.py` există, funcționează (verified: rularea pe `pluxee-todo.md` produce PDF byte-identic cu cel committed), dar are 2 bug-uri: - **B1.** `convert_md_to_pdf()` acceptă parametru `footer`, dar argparse nu expune flag-ul `--footer`. Funcționalitate moartă în CLI. - **B2.** `--forms` rulează fără eroare, dar nu produce câmpuri AcroForm. Verificare: PDF-ul rezultat nu conține `/AcroForm` și nici adnotări `/Widget`. `weasyprint` nu generează câmpuri de formular din `` HTML în mod automat. Pentru md2doc: niciun work anterior în AW pe conversie md→docx. Research extern (web-search-prime) arată 3 abordări posibile: Pandoc (CLI/subprocess), pypandoc (wrapper), sau python-docx cu parsare manuală. Am ales a treia pentru a păstra controlul fin asupra stilurilor și consistența cu md2pdf.py. ## 3. Arhitectură ### 3.1 Layout repository ``` tools/ ├── md2pdf.py (existing — bug fixes only, scope-limited) ├── md2doc.py (new — sibling, mirrors md2pdf structure) └── docs/specs/2026-07-27-md2doc-design.md ``` md2doc.py este single-file, simetric cu md2pdf.py. Nu se face refactor spre package comun — YAGNI pentru 2 fișiere. ### 3.2 Structura md2doc.py Pattern duplicat din md2pdf.py: ```python #!/usr/bin/env python3 import argparse, sys from pathlib import Path import markdown from bs4 import BeautifulSoup from docx import Document from docx.shared import Pt, RGBColor, Cm STYLES = { "elegant": {...}, # default "report": {...}, "default": {...}, "dark": {...}, "mono": {...}, } # Reguli comune — echivalentul COMMON_CSS din md2pdf, tradus în Word. # Conține proprietăți care nu țin de un stil anume. Override-uibile per stil: # la inițializare, STYLES[style] face merge peste COMMON (deep merge per key), # deci un stil poate suprascrie orice valoare de aici. COMMON = { "page_break_before_h1": False, # H1 pe pagină nouă (definabil per stil) "keep_with_next": True, # headings nu se despart de următorul paragraf "table_cell_valign": "top", # vertical-align pe celule "orphans": 3, # minimum linii orphan "widows": 3, # minimum linii widow } def convert_md_to_doc(md_file, output_file, style="elegant"): ... def main(): parser = argparse.ArgumentParser(...) ... ``` ### 3.3 Pipeline conversie ``` md text ↓ _bulletize() (copiat din md2pdf.py — YAGNI refactor comun) ↓ BLANK_MARKER injection (copiată din md2pdf — preservă multi-blank-lines) ↓ markdown.markdown() (extensii: tables, fenced_code, toc, attr_list, ↓ md_in_html, nl2br) ↓ replace BLANK_MARKER cu
(same logic ca md2pdf) ↓ BeautifulSoup HTML ↓ walk DOM, emit docx elements .docx file ``` `_bulletize()` și logica BLANK_MARKER sunt duplicate între md2pdf și md2doc pentru scope-ul curent (2 fișiere). Dacă pe viitor se adaugă un al 3-lea converter, se refactor la un `md_utils.py` comun. **Extensii markdown** — md2doc folosește `md_in_html` (pentru a procesa corect markdown embedded în blocuri HTML raw, inclusiv celule de tabel cu formatting). `codehilite` este omis pentru că syntax highlighting cu Pygments e out-of-scope (§10). ## 4. Mapare stiluri (md → docx) Fiecare stil din `STYLES` definește echivalentele Word ale CSS-ului md2pdf. Mapping-ul e manual, element cu element. | Element MD | md2pdf (CSS) | md2doc (Word) | |---|---|---| | h1 | font-size + border-bottom | Heading 1 style + paragraph bottom border | | h2/h3/h4 | font-size + color | Heading 2/3/4 style | | p | line-height + color | paragraph format | | strong | font-weight 700 | run.bold = True | | em | italic | run.italic = True | | code (inline) | monospace + bg | run.font.name = mono + run shading (background color) | | pre | block + border-left | paragraph + indent + shading | | blockquote | border-left + bg | paragraph left indent + italic + shading | | table | borders + header bg | docx Table + cell shading + borders | | ul/ol | list markers | **Vezi §4.2 — implementare numerotare XML manuală (H3 verified critical)** | | a | color + underline | run.color + run.underline | | hr | border-top | paragraph bottom border | | `![alt](url)` | figure/img | **skip cu warning la stderr** (out of scope per §10) | ### 4.1 IPOTEZE — verdicturi empirice (Stage 2 reviewer, 2026-07-27) Review empiric cu python-docx 1.2.0 rulat contra Script-uri care generează .docx și inspectează XML-ul intern. | Ipoteză | Verdict | Mecanism validat | |---|---|---| | **H1.** paragraph bottom border pe Heading 1 | ✅ SURVIVED | API oficial refuzat (`'ParagraphFormat' object has no attribute 'borders'`); XML route cu `OxmlElement('w:pBdr')` + `w:bottom` atașat la `paragraph._p.get_or_add_pPr()` produce XML conform schemei. | | **H2.** paragraph shading (code, blockquote) | ✅ SURVIVED | API oficial refuzat; XML route cu `OxmlElement('w:shd')` sub `w:pPr` merge. | | **H3.** liste nested L2/L3 în Word + Google Docs | ❌ **PARTIALLY-REFUTED — CRITICAL** | Vezi §4.2. | | **H4.** cell shading pe header row tabel | ✅ SURVIVED | `OxmlElement('w:shd')` sub `w:tcPr` merge curat. | | **H5.** `styles['List Bullet']` există în Document() nou | ✅ SURVIVED | Există (împreună cu 164 alte stiluri). | | **B2.** weasyprint 68.1 nu generează AcroForm din ``/`