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).
This commit is contained in:
2026-07-27 19:47:34 +03:00
parent 23deb7b3f5
commit 64c77d27fc
+139 -71
View File
@@ -2,35 +2,35 @@
**Data:** 2026-07-27 **Data:** 2026-07-27
**Autor:** Sebastian Petrescu **Autor:** Sebastian Petrescu
**Stare:** draft (pending empirical review) **Stare:** draft (pending empirical review, post Stage 1 review fixes)
--- ---
## 1. Obiectiv ## 1. Obiectiv
Construieste `md2doc.py` — un utilitar single-file Python care converteste Construiește `md2doc.py` — un utilitar single-file Python care convertește
Markdown la `.docx` (Microsoft Word OOXML), simetric cu `md2pdf.py` existent. Markdown la `.docx` (Microsoft Word OOXML), simetric cu `md2pdf.py` existent.
In acelasi plan: bug-fixes pentru `md2pdf.py` identificate in timpul În același plan: bug-fixes pentru `md2pdf.py` identificate în timpul
brainstorming-ului. brainstorming-ului.
## 2. Context ## 2. Context
`md2pdf.py` exista, functioneaza (verified: rularea pe `pluxee-todo.md` `md2pdf.py` există, funcționează (verified: rularea pe `pluxee-todo.md`
produce PDF byte-identic cu cel committed), dar are 2 bug-uri: produce PDF byte-identic cu cel committed), dar are 2 bug-uri:
- **B1.** `convert_md_to_pdf()` accepta parametru `footer`, dar argparse nu - **B1.** `convert_md_to_pdf()` acceptă parametru `footer`, dar argparse nu
expune flag-ul `--footer`. Functionalitate moarta in CLI. expune flag-ul `--footer`. Funcționalitate moartă în CLI.
- **B2.** `--forms` ruleaza fara eroare, dar nu produce campuri AcroForm. - **B2.** `--forms` rulează fără eroare, dar nu produce câmpuri AcroForm.
Verificare: PDF-ul rezultat nu contine `/AcroForm` si nici anotari Verificare: PDF-ul rezultat nu conține `/AcroForm` și nici adnotări
`/Widget`. `weasyprint` nu genereaza campuri de formular din `<input>` `/Widget`. `weasyprint` nu generează câmpuri de formular din `<input>`
HTML in mod automat. HTML în mod automat.
Pentru md2doc: niciun work anterior in AW pe conversie md→docx. Research Pentru md2doc: niciun work anterior în AW pe conversie md→docx. Research
extern (web-search-prime) arata 3 abordari posibile: Pandoc (CLI/subprocess), extern (web-search-prime) arată 3 abordări posibile: Pandoc (CLI/subprocess),
pypandoc (wrapper), sau python-docx cu parsare manuala. Am ales a treia pypandoc (wrapper), sau python-docx cu parsare manuală. Am ales a treia
pentru a pastra controlul fin asupra stilurilor si consistenta cu md2pdf.py. pentru a păstra controlul fin asupra stilurilor și consistența cu md2pdf.py.
## 3. Arhitectura ## 3. Arhitectură
### 3.1 Layout repository ### 3.1 Layout repository
@@ -42,7 +42,7 @@ tools/
``` ```
md2doc.py este single-file, simetric cu md2pdf.py. Nu se face refactor spre md2doc.py este single-file, simetric cu md2pdf.py. Nu se face refactor spre
package comun — YAGNI pentru 2 fisiere. package comun — YAGNI pentru 2 fișiere.
### 3.2 Structura md2doc.py ### 3.2 Structura md2doc.py
@@ -65,9 +65,19 @@ STYLES = {
"mono": {...}, "mono": {...},
} }
COMMON = {...} # reguli comune, ca si COMMON_CSS in md2pdf # 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", footer=""): def convert_md_to_doc(md_file, output_file, style="elegant"):
... ...
def main(): def main():
@@ -79,20 +89,28 @@ def main():
``` ```
md text md text
↓ _bulletize() (reutilizat din md2pdf — scos ca util comun) ↓ _bulletize() (copiat din md2pdf.py — YAGNI refactor comun)
markdown.markdown() (extensii: tables, fenced_code, toc, attr_list, nl2br) 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 ↓ BeautifulSoup HTML
↓ walk DOM, emit docx elements ↓ walk DOM, emit docx elements
.docx file .docx file
``` ```
`_bulletize()` e duplicated logic between md2pdf and md2doc. Pentru scope-ul `_bulletize()` și logica BLANK_MARKER sunt duplicate între md2pdf și md2doc
curent (2 fisiere), se copiaza. Daca pe viitor se adauga un al 3-lea converter, pentru scope-ul curent (2 fișiere). Dacă pe viitor se adaugă un al 3-lea
se refactor la un `md_utils.py` comun. 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) ## 4. Mapare stiluri (md → docx)
Fiecare stil din `STYLES` defineste echivalentele Word ale CSS-ului md2pdf. Fiecare stil din `STYLES` definește echivalentele Word ale CSS-ului md2pdf.
Mapping-ul e manual, element cu element. Mapping-ul e manual, element cu element.
| Element MD | md2pdf (CSS) | md2doc (Word) | | Element MD | md2pdf (CSS) | md2doc (Word) |
@@ -102,111 +120,161 @@ Mapping-ul e manual, element cu element.
| p | line-height + color | paragraph format | | p | line-height + color | paragraph format |
| strong | font-weight 700 | run.bold = True | | strong | font-weight 700 | run.bold = True |
| em | italic | run.italic = True | | em | italic | run.italic = True |
| code (inline) | monospace + bg | run.font.name = mono + run highlight | | code (inline) | monospace + bg | run.font.name = mono + run shading (background color) |
| pre | block + border-left | paragraph + indent + shading | | pre | block + border-left | paragraph + indent + shading |
| blockquote | border-left + bg | paragraph left indent + italic + shading | | blockquote | border-left + bg | paragraph left indent + italic + shading |
| table | borders + header bg | docx Table + cell shading + borders | | table | borders + header bg | docx Table + cell shading + borders |
| ul/ol | list markers | List Bullet / List Number paragraph styles | | ul/ol | list markers | **Vezi §4.2 — implementare numerotare XML manuală (H3 verified critical)** |
| a | color + underline | run.color + run.underline | | a | color + underline | run.color + run.underline |
| hr | border-top | paragraph bottom border | | hr | border-top | paragraph bottom border |
| `![alt](url)` | figure/img | **skip cu warning la stderr** (out of scope per §10) |
### 4.1 Ipoteze de verificat (HARD CLAIMS — astea trebuie validate empiric inainte de implementare) ### 4.1 IPOTEZE — verdicturi empirice (Stage 2 reviewer, 2026-07-27)
Urmatoarele afirmatii despre python-docx sunt **ipoteze**, nu fapte, pana Review empiric cu python-docx 1.2.0 rulat contra Script-uri care generează
cand reviewer-ul empiric le rupe sau le confirma cu comenzi reale: .docx și inspectează XML-ul intern.
- **H1.** python-docx poate adauga bottom border pe un paragraph Heading 1 | Ipoteză | Verdict | Mecanism validat |
(necesita manipulare XML — Issue python-docx #105 sugereaza ca nu exista |---|---|---|
API direct). | **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.** python-docx poate aplica shading pe un paragraph (pentru code/blockquote) | **H2.** paragraph shading (code, blockquote) | ✅ SURVIVED | API oficial refuzat; XML route cu `OxmlElement('w:shd')` sub `w:pPr` merge. |
— documentatia oficiala nu listeaza API;StackTrace-ul sugereaza XML | **H3.** liste nested L2/L3 în Word + Google Docs | ❌ **PARTIALLY-REFUTED — CRITICAL** | Vezi §4.2. |
manipulation cu `OxmlElement('w:shd')`. | **H4.** cell shading pe header row tabel | ✅ SURVIVED | `OxmlElement('w:shd')` sub `w:tcPr` merge curat. |
- **H3.** python-docx poate crea liste nested (Level 2, Level 3) care se | **H5.** `styles['List Bullet']` există în Document() nou | ✅ SURVIVED | Există (împreună cu 164 alte stiluri). |
randeaza corect in Word SI Google Docs. Issue #122 si thread-urile | **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. |
Latenate/Reddit sugereaza probleme cunoscute aici.
- **H4.** Cell shading (`w:shd` pe `w:tcPr`) functioneaza pentru background
de header row intr-un tabel.
- **H5.** `style = document.styles['List Bullet']` exista intr-un Document
nou creat de python-docx (sau trebuie adaugat manual).
Pana nu se valideaza H1-H5 cu comenzi reale (run python-docx, deschide output Limitare comună: fără validare vizuală în Word/Google Docs (reviewer-ul
in Word/Pages, verifica vizual), mapping-ul din tabel este plan, nu fapt. 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 ## 5. CLI interface
Identica cu md2pdf.py: md2doc.py expune aceeași interfață de bază ca md2pdf.py:
``` ```
md2doc.py input.md # → input.docx md2doc.py input.md # → input.docx
md2doc.py input.md -o output.docx md2doc.py input.md -o output.docx
md2doc.py docs/ # converteste toate .md din docs/ md2doc.py docs/ # convertește toate .md din docs/
md2doc.py input.md --style mono md2doc.py input.md --style mono
md2doc.py input.md -q # quiet 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. Bug-fixes md2pdf.py (scope-limited)
### 6.1 B1: expunere `--footer` ### 6.1 B1: expunere `--footer`
Adauga argument argparse: Adaugă argument argparse:
```python ```python
parser.add_argument('--footer', help='Custom footer text (right-aligned)') parser.add_argument('--footer', help='Custom footer text (right-aligned)')
``` ```
Si paseaza-l in apelul `convert_md_to_pdf(md_file, pdf_file, args.style, Și pasează-l în apelul `convert_md_to_pdf(md_file, pdf_file, args.style,
forms=args.forms, footer=args.footer)`. forms=args.forms, footer=args.footer)`.
### 6.2 B2: `--forms` nu produce AcroForm ### 6.2 B2: `--forms` nu produce AcroForm — elimină flag-ul
Fapt stabilit (verified): weasyprint 68.1 nu genereaza campuri de formular Fapt stabilit (verified 2x — prima dată în brainstorming, apoi re-verificat
din `<input type="text">` sau `<textarea>` din HTML-ul stringify-uit. Markul în Stage 2 review): weasyprint 68.1 nu generează câmpuri de formular din
HTML e prezent (verified: `markdown.markdown()` produce `<input ...>` corect), `<input type="text">` sau `<textarea>` HTML, indiferent de:
dar weasyprint il randeaza ca text static.
**Ipoteza (de verificat):** weasyprint necesita ca HTML-ul sa fie incarcat - wrapping în `<form>` tag
ca string cu base_url setat, sau tag-urile `<form>` sa fie wrappate explicit, - flag-ul `presentational_hints=True` (ipoteza anterioară — REFUTED empiric)
sau exista un flag documentat `presentational_hints=True`. - dimensiuni explicite în CSS
**Alternativ:** daca weasyprint 68.1 nu suporta forms deloc, atunci flag-ul weasyprint renunță complet la tag-urile `<input>`/`<textarea>` — nici măcar
`--forms` trebuie eliminat din md2pdf (YAGNI) sau documentat ca no-op cu nu le randează ca text static în PDF.
eroare loud.
Inainte de implementare, reviewer-ul empiric verifica care varianta e adevarata. **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 ## 7. Dependencies
**md2doc.py:** **md2doc.py:**
- `python-docx` (NOU — `pip install python-docx`) - `python-docx` (NOU — `pip install python-docx`)
- `markdown` (deja instalat, v3.10.2) - `markdown` (deja instalat, v3.10.2)
- `beautifulsoup4` (trebuie verificat daca e instalat) - `beautifulsoup4` (trebuie verificat dacă e instalat)
- `lxml` (parser pentru BeautifulSoup, mai rapid decat html.parser) - `lxml` (parser pentru BeautifulSoup, mai rapid decât html.parser)
**md2pdf.py:** **md2pdf.py:**
- existente, niciun nou dep. - existente, niciun nou dep.
## 8. Error handling ## 8. Error handling
Fail-loud, niciodata silent. Pe orice eroare de parsare/conversie: Fail-loud, niciodată silent. Pe orice eroare de parsare/conversie:
- print la stderr cu context (file, line if available) - print la stderr cu context (file, line if available)
- sys.exit(1) cu mesaj - sys.exit(1) cu mesaj
- nu se scrie fisier de output partial - 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 ## 9. Testing
- **md2doc:** PT urmeaza pattern-ul md2pdf — nu exista test suite inca. - **md2doc:** Pentru testare urmează pattern-ul md2pdf — nu există test suite încă.
Pentru MVP: convertim `pluxee-todo.md` si un edge-case file, verificam Pentru MVP: convertim `pluxee-todo.md` și un edge-case file, verificăm
ca output .docx se deschide in Word/Pages fara erori si ca structura că output .docx se deschide în Word/Pages fără erori și că structura
(headings, lists, tables, code blocks) e corecta vizual. (headings, lists, tables, code blocks) e corectă vizual.
- **md2pdf bug-fixes:** dupa B1, rulam `md2pdf.py input.md --footer "Test"` - **md2pdf bug-fixes:** după B1, rulăm `md2pdf.py input.md --footer "Test"`
si verificam ca footer-ul apare in PDF. Pentru B2, depinde de verdictul și verificăm că footer-ul apare în PDF. Pentru B2, depinde de verdictul
empiric. empiric.
## 10. Out of scope ## 10. Out of scope
- Refactor md2pdf + md2doc la un package comun (YAGNI pentru 2 fisiere) - Refactor md2pdf + md2doc la un package comun (YAGNI pentru 2 fișiere)
- Stiluri noi peste cele 5 existente - Stiluri noi peste cele 5 existente
- Suport pentru images embedded in markdown (de tratat separat) - Suport pentru images embedded in markdown (de tratat separat)
- Suport pentru syntax highlighting in code blocks (Pygments integration) - Suport pentru syntax highlighting in code blocks (Pygments integration)
- `.doc` (format vechi binar) — doar `.docx` - `.doc` (format vechi binar) — doar `.docx`
- Integrare cu reference.docx templates (pandoc-style) - Integrare cu reference.docx templates (pandoc-style)
- `--footer` și `--forms` pentru md2doc (specifice md2pdf)