Files
md2pdf/docs/specs/2026-07-27-md2doc-design.md
sebastian 64c77d27fc Update spec after Stage 1 + Stage 2 review
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).
2026-07-27 19:47:34 +03:00

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ă 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 <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
![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 <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ă un numId diferit, care se rezolvă la un abstractNum cu un singur nivel (doar ilvl=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:

  1. La inițializare document, injectează un w:abstractNum cu 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).
  2. Injectează un w:num care referențiază acel abstractNumId.
  3. Pentru fiecare paragraph de listă, atașează w:numPr cu w:ilvl explicit (0 pentru top-level, 1 pentru nested, 2 pentru double-nested) și w:numId care 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)

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 --forms din argparse
  • Șterge parametrul forms din semnătura convert_md_to_pdf()
  • Șterge parametrul options={'pdf_forms': forms} din apelul write_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 --forms pentru md2doc (specifice md2pdf)