Stage 1 (textual): 12 issues found, all fixed (re-ran clean on major axes)
Stage 2 (empirical): 6 claims tested with real python-docx code
- H1, H2, H4, H5: SURVIVED (XML manipulation routes verified)
- H3: PARTIALLY-REFUTED — CRITICAL. List Bullet 2/3 style names exist
but produce flat lists. Spec now mandates manual numbering XML.
- B2: claim SURVIVED, presentational_hints hypothesis REFUTED.
Decision: eliminate --forms from md2pdf (YAGNI).
12 KiB
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ă parametrufooter, dar argparse nu expune flag-ul--footer. Funcționalitate moartă în CLI. - B2.
--formsrulează fără eroare, dar nu produce câmpuri AcroForm. Verificare: PDF-ul rezultat nu conține/AcroFormși nici adnotări/Widget.weasyprintnu generează câmpuri de formular din<input>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:
#!/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 <div> (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 |
 |
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 <input>/<textarea> |
✅ SURVIVED (claim originală) / ❌ REFUTED (ipoteza presentational_hints=True) |
presentational_hints=True nu ajută. weasyprint renunță complet la tag-urile <input>/<textarea> — nici măcar nu le randează ca text static. |
Limitare comună: fără validare vizuală în Word/Google Docs (reviewer-ul
rulează headless). Pentru H3 route B, autorul trebuie să deschidă manual
scratchpad/h3_route_b.docx în Word și să verifice vizual înainte de
implementare (vezi §4.2).
4.2 Implementare liste (H3 CRITICAL — design schimbat)
Review-ul empiric a infirmat mapping-ul original "List Bullet 2/3" pentru liste nested. Situația reală:
- Stilurile
'List Bullet 2'/'List Bullet 3'EXISTĂ în Document() nou, DAR fiecare referențiază unnumIddiferit, care se rezolvă la unabstractNumcu un singur nivel (doarilvl=0). - Rezultat: Word randează 3 liste plat neimbricate, nu listă ierarhică.
- Din cele 9 abstractNums din template-ul default python-docx, 0 sunt multi-level.
Implementare corectă (mandatorie): md2doc.py trebuie să construiască propriul numbering definition XML:
- La inițializare document, injectează un
w:abstractNumcu 3 sau mai multe elemente<w:lvl w:ilvl="0">,<w:lvl w:ilvl="1">,<w:lvl w:ilvl="2">(pentru bullet sau decimal sau mixt, în funcție de stil). - Injectează un
w:numcare referențiază acelabstractNumId. - Pentru fiecare paragraph de listă, atașează
w:numPrcuw:ilvlexplicit (0 pentru top-level, 1 pentru nested, 2 pentru double-nested) șiw:numIdcare referențiază numbering-ul custom.
Pattern verificat în XML de reviewer — output-ul parsează corect prin
python-docx și produce OOXML conform schemei. Verificare vizuală umană
încă necesară (deschide scratchpad/h3_route_b.docx în Word) înainte de
implementare.
Cost estimat: ~50-80 linii cod pentru helper-ul de numbering, pe lângă restul converter-ului. Intră în scope-ul implementării.
5. CLI interface
md2doc.py expune aceeași interfață de bază ca md2pdf.py:
md2doc.py input.md # → input.docx
md2doc.py input.md -o output.docx
md2doc.py docs/ # convertește toate .md din docs/
md2doc.py input.md --style mono
md2doc.py input.md -q # quiet
Diferențe față de md2pdf.py (intenționate):
- md2doc nu are
--footer(footer-ele PDF sunt specifice paginării — în docx se adaugă prin Word's header/footer API diferit, out of scope aici) - md2doc nu are
--forms(câmpuri de formular AcroForm sunt PDF-specific)
După B1 (md2pdf primește --footer functional), diferența CLI între cele 2
tool-uri devine explicită și intenționată — nu "identice", ci "simetrice pe
overlap-ul comun".
6. Bug-fixes md2pdf.py (scope-limited)
6.1 B1: expunere --footer
Adaugă argument argparse:
parser.add_argument('--footer', help='Custom footer text (right-aligned)')
Și pasează-l în apelul convert_md_to_pdf(md_file, pdf_file, args.style, forms=args.forms, footer=args.footer).
6.2 B2: --forms nu produce AcroForm — elimină flag-ul
Fapt stabilit (verified 2x — prima dată în brainstorming, apoi re-verificat
în Stage 2 review): weasyprint 68.1 nu generează câmpuri de formular din
<input type="text"> sau <textarea> HTML, indiferent de:
- wrapping în
<form>tag - flag-ul
presentational_hints=True(ipoteza anterioară — REFUTED empiric) - dimensiuni explicite în CSS
weasyprint renunță complet la tag-urile <input>/<textarea> — nici măcar
nu le randează ca text static în PDF.
Decizie: flag-ul --forms se elimină din md2pdf.py (YAGNI —
funcționalitatea nu a funcționat niciodată și nu există cale simplă s-o
reparăm fără schimbare library). Removing includes:
- Șterge argumentul
--formsdin argparse - Șterge parametrul
formsdin semnăturaconvert_md_to_pdf() - Șterge parametrul
options={'pdf_forms': forms}din apelulwrite_pdf()
Documentația din argparse epilog se actualizează corespunzător.
7. Dependencies
md2doc.py:
python-docx(NOU —pip install python-docx)markdown(deja instalat, v3.10.2)beautifulsoup4(trebuie verificat dacă e instalat)lxml(parser pentru BeautifulSoup, mai rapid decât html.parser)
md2pdf.py:
- existente, niciun nou dep.
8. Error handling
Fail-loud, niciodată silent. Pe orice eroare de parsare/conversie:
- print la stderr cu context (file, line if available)
- sys.exit(1) cu mesaj
- nu se scrie fișier de output partial
Comportament specific elementelor out-of-scope (ex. images): md2doc ridică warning la stderr (continuă execuția), nu eșuează. Eșecul e rezervat pentru erorile care împiedică output-ul (parsare, scriere disk). Diferența: skip+warn = "am procesat dar am omis ceva"; fail = "nu pot produce output".
9. Testing
- md2doc: Pentru testare urmează pattern-ul md2pdf — nu există test suite încă.
Pentru MVP: convertim
pluxee-todo.mdși un edge-case file, verificăm că output .docx se deschide în Word/Pages fără erori și că structura (headings, lists, tables, code blocks) e corectă vizual. - md2pdf bug-fixes: după B1, rulăm
md2pdf.py input.md --footer "Test"și verificăm că footer-ul apare în PDF. Pentru B2, depinde de verdictul empiric.
10. Out of scope
- Refactor md2pdf + md2doc la un package comun (YAGNI pentru 2 fișiere)
- Stiluri noi peste cele 5 existente
- Suport pentru images embedded in markdown (de tratat separat)
- Suport pentru syntax highlighting in code blocks (Pygments integration)
.doc(format vechi binar) — doar.docx- Integrare cu reference.docx templates (pandoc-style)
--footerși--formspentru md2doc (specifice md2pdf)