Compare commits
18
Commits
630660adb2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
667772784f | ||
|
|
7b51283ae1 | ||
|
|
e8b105a5fb | ||
|
|
5a62572f55 | ||
|
|
21b3a7a77d | ||
|
|
5fd05ba2be | ||
|
|
162485bfb2 | ||
|
|
36b6a16fd8 | ||
|
|
322eaad887 | ||
|
|
e3176a789e | ||
|
|
0b344d3bb8 | ||
|
|
2a2c0ed249 | ||
|
|
bdc7b5e6b2 | ||
|
|
e36e87966d | ||
|
|
5891ed892c | ||
|
|
5f8c2bdbc2 | ||
|
|
64c77d27fc | ||
|
|
23deb7b3f5 |
+34
-1
@@ -1,3 +1,36 @@
|
||||
.venv/
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
firebase-debug.log
|
||||
|
||||
# Backups
|
||||
*.bak
|
||||
sailo-claude-backup/
|
||||
|
||||
# AW local state (PostgreSQL is source of truth)
|
||||
.aw/
|
||||
|
||||
# Local databases
|
||||
vectors.db
|
||||
*.db
|
||||
|
||||
# Generated test artifacts
|
||||
tests/_tmp_*.docx
|
||||
tests/_tmp_*.pdf
|
||||
|
||||
# Foreign trees copied from other projects — do NOT commit
|
||||
superpowers/
|
||||
agents/
|
||||
scripts/
|
||||
docs/ARCHITECTURE_PROPOSAL.md
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# tools — Project Configuration
|
||||
|
||||
## Project Info
|
||||
- **Name**: tools
|
||||
- **Type**: Single-file Python utility
|
||||
- **Main file**: `md2pdf.py` — Markdown to PDF converter
|
||||
- **Language**: Python 3.x
|
||||
- **Dependencies**: reportlab, markdown, beautifulsoup4, Pygments
|
||||
|
||||
## Project-Specific Notes
|
||||
- Source files are in root directory (single-file project)
|
||||
- No test suite yet
|
||||
- No build step required
|
||||
- PDF styles: elegant (default), mono
|
||||
|
||||
## Global Configuration
|
||||
All coding principles, swarm orchestration, and agent configuration are inherited from `~/.claude/CLAUDE.md`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,280 @@
|
||||
# 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:
|
||||
|
||||
```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 <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ă 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)
|
||||
|
||||
### 6.1 B1: expunere `--footer`
|
||||
|
||||
Adaugă argument argparse:
|
||||
|
||||
```python
|
||||
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)
|
||||
@@ -0,0 +1,711 @@
|
||||
#!/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 _bulletize(text: str) -> str:
|
||||
"""Turn markdown list markers into bullets, skipping code/HTML blocks.
|
||||
|
||||
Supports GFM task lists:
|
||||
- [ ] → ☐ (empty checkbox, U+2610)
|
||||
- [x] → ☑ (checked checkbox, U+2611)
|
||||
- [X] → ☑
|
||||
- → • (regular bullet)
|
||||
|
||||
Copied from md2pdf.py (kept in sync).
|
||||
"""
|
||||
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:
|
||||
# GFM task list — checked
|
||||
if line.startswith("- [x] ") or line.startswith("- [X] "):
|
||||
line = "☑ " + line[6:]
|
||||
# GFM task list — unchecked
|
||||
elif line.startswith("- [ ] "):
|
||||
line = "☐ " + line[6:]
|
||||
# Regular bullet
|
||||
elif line.startswith("- "):
|
||||
line = "• " + line[2:]
|
||||
out.append(line)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
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 = 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)
|
||||
|
||||
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)
|
||||
|
||||
numbering.insert(0, abstract_num)
|
||||
|
||||
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()
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Element renderers (walk BeautifulSoup DOM → emit docx elements)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _warn_skip_image(node):
|
||||
"""Warn to stderr when an <img> is encountered (out of scope per spec §10).
|
||||
|
||||
Defined early so _render_paragraph (used in Task 4) and convert_md_to_doc
|
||||
(Task 7) can both reference it without forward-declaration issues.
|
||||
"""
|
||||
src = node.get("src", "(no src)")
|
||||
print(f"Warning: image skipped (out of scope): {src}", file=sys.stderr)
|
||||
|
||||
|
||||
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_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 _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).
|
||||
"""
|
||||
p = doc.add_paragraph()
|
||||
code_fill = style.get("code_fill", "f5f5f5")
|
||||
|
||||
def _add_runs(element):
|
||||
for child in element.children:
|
||||
if isinstance(child, str):
|
||||
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)
|
||||
|
||||
|
||||
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=3, 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)
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
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). Trailing empty line from markdown's final \\n is dropped.
|
||||
"""
|
||||
code_fill = style.get("code_fill", "f7f8fa")
|
||||
text = node.get_text()
|
||||
lines = text.split("\n")
|
||||
while lines and lines[-1] == "":
|
||||
lines.pop()
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def _set_section_text(section, location: str, text: str, template: str):
|
||||
"""Apply footer/header text to a docx Section.
|
||||
|
||||
location: 'footer' or 'header'.
|
||||
text: literal text (may be empty — clears the field).
|
||||
template: original template with {page}/{pages} placeholders (used to detect
|
||||
whether to inject field codes).
|
||||
|
||||
For docx, page numbering uses Word field codes:
|
||||
{page} → PAGE field
|
||||
{pages} → NUMPAGES field
|
||||
|
||||
Other text is literal.
|
||||
"""
|
||||
import re
|
||||
if location == 'footer':
|
||||
target = section.footer
|
||||
else:
|
||||
target = section.header
|
||||
|
||||
# Clear default empty paragraph content (python-docx auto-creates one)
|
||||
if not target.paragraphs:
|
||||
target.add_paragraph()
|
||||
p = target.paragraphs[0]
|
||||
# Center-align the footer/header paragraph
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
# Clear existing runs
|
||||
for run in list(p.runs):
|
||||
run.text = ""
|
||||
|
||||
# Split template on placeholders
|
||||
parts = re.split(r'(\{page\}|\{pages\})', template)
|
||||
for part in parts:
|
||||
if part == '{page}':
|
||||
# Insert PAGE field code
|
||||
_add_field_code(p, 'PAGE')
|
||||
elif part == '{pages}':
|
||||
_add_field_code(p, 'NUMPAGES')
|
||||
elif part:
|
||||
p.add_run(part)
|
||||
|
||||
|
||||
def _add_field_code(paragraph, field: str):
|
||||
"""Insert a Word field code (PAGE, NUMPAGES) into a paragraph via XML."""
|
||||
run = paragraph.add_run()
|
||||
# Build the field structure: <w:fldChar begin/> <w:instrText>PAGE</w:instrText> <w:fldChar end/>
|
||||
fld_begin = OxmlElement('w:fldChar')
|
||||
fld_begin.set(qn('w:fldCharType'), 'begin')
|
||||
run._r.append(fld_begin)
|
||||
|
||||
instr_run = paragraph.add_run()
|
||||
instr = OxmlElement('w:instrText')
|
||||
instr.set(qn('xml:space'), 'preserve')
|
||||
instr.text = f' {field} '
|
||||
instr_run._r.append(instr)
|
||||
|
||||
sep_run = paragraph.add_run()
|
||||
fld_sep = OxmlElement('w:fldChar')
|
||||
fld_sep.set(qn('w:fldCharType'), 'separate')
|
||||
sep_run._r.append(fld_sep)
|
||||
|
||||
# Placeholder for the rendered value (Word will compute on open)
|
||||
placeholder_run = paragraph.add_run("?")
|
||||
# Mark as dirty so Word recalculates
|
||||
fld_dirty = OxmlElement('w:fldChar')
|
||||
fld_dirty.set(qn('w:fldCharType'), 'end')
|
||||
placeholder_run._r.append(fld_dirty)
|
||||
|
||||
# Set small font for footer/header runs
|
||||
for r in [run, instr_run, sep_run, placeholder_run]:
|
||||
r.font.size = Pt(7.5)
|
||||
r.font.color.rgb = RGBColor.from_string("999999")
|
||||
|
||||
|
||||
def convert_md_to_doc(md_file: Path, output_file: Path, style: str = "elegant",
|
||||
footer: str = "", header: str = "") -> 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":
|
||||
img = element.find("img")
|
||||
if img:
|
||||
_warn_skip_image(img)
|
||||
else:
|
||||
_render_paragraph(doc, element, style=style_cfg)
|
||||
# else: silently skip unknown elements (divs, spans, etc.)
|
||||
|
||||
# Apply footer/header to all sections (typically just one)
|
||||
if footer or header:
|
||||
for section in doc.sections:
|
||||
if footer:
|
||||
_set_section_text(section, 'footer', footer, footer)
|
||||
if header:
|
||||
_set_section_text(section, 'header', header, header)
|
||||
|
||||
doc.save(output_file)
|
||||
return output_file
|
||||
|
||||
|
||||
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
|
||||
%(prog)s contract.md --footer "Confidential" Add footer (static text)
|
||||
%(prog)s doc.md --header "Project X" Add header (static text)
|
||||
%(prog)s doc.md --footer "Page {page}" Add footer with page counter
|
||||
%(prog)s doc.md --footer "Page {page} of {pages}" Page counter with total
|
||||
|
||||
Footer/Header placeholders (only when --footer/--header is used):
|
||||
{page} current page number (e.g. "3")
|
||||
{pages} total page count (e.g. "12")
|
||||
Literal text is rendered as-is. Mix freely: "Page {page} of {pages} — Confidential"
|
||||
|
||||
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')
|
||||
parser.add_argument('--footer',
|
||||
help='Custom footer text (center-aligned). '
|
||||
'Supports {page} and {pages} placeholders: '
|
||||
'"Page {page} of {pages}"')
|
||||
parser.add_argument('--header',
|
||||
help='Custom header text (center-aligned). '
|
||||
'Supports {page} and {pages} placeholders: '
|
||||
'"Section X — page {page}"')
|
||||
|
||||
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,
|
||||
footer=args.footer or "",
|
||||
header=args.header or "")
|
||||
|
||||
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()
|
||||
@@ -26,6 +26,126 @@ from weasyprint.text.fonts import FontConfiguration
|
||||
|
||||
# Style templates
|
||||
STYLES = {
|
||||
"report": """
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2cm 1.8cm 2cm 1.8cm;
|
||||
@bottom-center {
|
||||
content: counter(page);
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 8.5pt;
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
body {
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 9.5pt;
|
||||
line-height: 1.5;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
h1 {
|
||||
font-size: 17pt;
|
||||
color: #1e3a8a;
|
||||
border-bottom: 2.5px solid #1e3a8a;
|
||||
padding-bottom: 5px;
|
||||
margin-top: 26px;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
h1:first-of-type { margin-top: 0; }
|
||||
h2 {
|
||||
font-size: 13pt;
|
||||
color: #1e3a8a;
|
||||
margin-top: 20px;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
h3 {
|
||||
font-size: 11pt;
|
||||
color: #333;
|
||||
margin-top: 15px;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
h4 {
|
||||
font-size: 10pt;
|
||||
color: #444;
|
||||
margin-top: 12px;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
p { margin: 0.5em 0; }
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 11px 0;
|
||||
font-size: 8.5pt;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
th {
|
||||
background: #1e3a8a;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
padding: 5px 7px;
|
||||
font-weight: 600;
|
||||
}
|
||||
td {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding: 5px 7px;
|
||||
vertical-align: top;
|
||||
}
|
||||
tr:nth-child(even) td { background: #f7f8fa; }
|
||||
/* tabele fără antet (perechi etichetă/valoare) */
|
||||
table thead tr:has(th:empty) { display: none; }
|
||||
code {
|
||||
font-family: "SF Mono", Menlo, monospace;
|
||||
font-size: 8pt;
|
||||
background: #f0f2f5;
|
||||
padding: 1px 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
pre {
|
||||
background: #f7f8fa;
|
||||
border-left: 3px solid #1e3a8a;
|
||||
padding: 0.8em;
|
||||
font-size: 8pt;
|
||||
overflow-x: auto;
|
||||
}
|
||||
pre code { background: none; padding: 0; }
|
||||
blockquote {
|
||||
border-left: 3px solid #1e3a8a;
|
||||
margin: 11px 0;
|
||||
padding: 5px 12px;
|
||||
background: #f7f8fa;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
ul, ol {
|
||||
margin: 0.4em 0;
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
li { margin: 0.15em 0; }
|
||||
a {
|
||||
color: #1e3a8a;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid #aab;
|
||||
}
|
||||
strong { color: #111; font-weight: 700; }
|
||||
em { font-style: italic; }
|
||||
hr { border: none; border-top: 1px solid #d8d8d8; margin: 20px 0; }
|
||||
figure {
|
||||
margin: 14px 0;
|
||||
page-break-inside: avoid;
|
||||
text-align: center;
|
||||
}
|
||||
figure img {
|
||||
width: 100%;
|
||||
max-height: 21cm;
|
||||
object-fit: contain;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
figcaption {
|
||||
font-size: 8pt;
|
||||
color: #555;
|
||||
margin-top: 5px;
|
||||
font-style: italic;
|
||||
}
|
||||
""",
|
||||
"elegant": """
|
||||
@import url('https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&display=swap');
|
||||
@page {
|
||||
@@ -99,13 +219,14 @@ STYLES = {
|
||||
th { font-weight: 400; color: #666; }
|
||||
ul, ol {
|
||||
margin: 0.3em 0;
|
||||
padding-left: 0;
|
||||
list-style-position: inside;
|
||||
padding-left: 1.2em;
|
||||
list-style-position: outside;
|
||||
list-style-type: disc;
|
||||
}
|
||||
ol { list-style-type: decimal; }
|
||||
li {
|
||||
margin: 0.15em 0;
|
||||
padding-left: 0.3em;
|
||||
}
|
||||
blockquote {
|
||||
margin: 0.8em 0;
|
||||
@@ -207,13 +328,14 @@ STYLES = {
|
||||
}
|
||||
ul, ol {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 0;
|
||||
list-style-position: inside;
|
||||
padding-left: 1.2em;
|
||||
list-style-position: outside;
|
||||
list-style-type: disc;
|
||||
}
|
||||
ol { list-style-type: decimal; }
|
||||
li {
|
||||
margin: 0.2em 0;
|
||||
padding-left: 0.3em;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 2px solid #ddd;
|
||||
@@ -283,8 +405,9 @@ STYLES = {
|
||||
padding: 0.5em 1em;
|
||||
margin: 1em 0;
|
||||
}
|
||||
ul, ol { padding-left: 0; list-style-position: inside; list-style-type: disc; }
|
||||
ul, ol { padding-left: 1.2em; list-style-position: outside; list-style-type: disc; }
|
||||
ol { list-style-type: decimal; }
|
||||
li { padding-left: 0.3em; }
|
||||
a { color: #818cf8; }
|
||||
hr { border: none; border-top: 1px solid #374151; margin: 2em 0; }
|
||||
""",
|
||||
@@ -364,13 +487,14 @@ STYLES = {
|
||||
}
|
||||
ul, ol {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 0;
|
||||
list-style-position: inside;
|
||||
padding-left: 1.2em;
|
||||
list-style-position: outside;
|
||||
list-style-type: disc;
|
||||
}
|
||||
ol { list-style-type: decimal; }
|
||||
li {
|
||||
margin: 0.2em 0;
|
||||
padding-left: 0.3em;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 3px solid #999;
|
||||
@@ -395,12 +519,96 @@ STYLES = {
|
||||
}
|
||||
|
||||
|
||||
def convert_md_to_pdf(md_file: Path, output_file: Path, style: str = "elegant") -> Path:
|
||||
COMMON_CSS = """
|
||||
/* ═══ reguli comune (injectate automat) ═══ */
|
||||
/* NU seta table-layout global: ar suprascrie style="table-layout:fixed"
|
||||
scris inline pe un tabel anume (specificitate egală, ultima regulă câștigă).
|
||||
Tabelele care au nevoie de lățimi exacte pe coloane folosesc
|
||||
style="table-layout:fixed" sau class="fixed". */
|
||||
table.fixed { table-layout: fixed; }
|
||||
td, th { vertical-align: top; }
|
||||
tr, td, th { page-break-inside: avoid; }
|
||||
thead { display: table-header-group; }
|
||||
h1, h2, h3, h4 { page-break-after: avoid; break-after: avoid; }
|
||||
p { orphans: 3; widows: 3; }
|
||||
pre, blockquote { page-break-inside: avoid; }
|
||||
.page-break { page-break-before: always; }
|
||||
|
||||
/* ═══ câmpuri completabile (--forms) ═══ */
|
||||
input[type="text"], textarea, input, select {
|
||||
appearance: auto;
|
||||
font-family: inherit; /* altfel câmpurile PDF cad pe Helvetica */
|
||||
border: none;
|
||||
border-bottom: 1px solid #666;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
padding: 0 2px;
|
||||
min-width: 4em;
|
||||
}
|
||||
td input[type="text"], th input[type="text"] {
|
||||
border-bottom: none;
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _bulletize(text: str) -> str:
|
||||
"""Turn markdown list markers into bullets, skipping code/HTML blocks.
|
||||
|
||||
Supports GFM task lists:
|
||||
- [ ] \u2192 \u2610 (empty checkbox, U+2610)
|
||||
- [x] \u2192 \u2611 (checked checkbox, U+2611)
|
||||
- [X] \u2192 \u2611
|
||||
- \u2192 \u2022 (regular bullet)
|
||||
"""
|
||||
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:
|
||||
# GFM task list \u2014 checked
|
||||
if line.startswith("- [x] ") or line.startswith("- [X] "):
|
||||
line = "\u2611 " + line[6:]
|
||||
# GFM task list \u2014 unchecked
|
||||
elif line.startswith("- [ ] "):
|
||||
line = "\u2610 " + line[6:]
|
||||
# Regular bullet
|
||||
elif line.startswith("- "):
|
||||
line = "\u2022 " + line[2:]
|
||||
out.append(line)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def convert_md_to_pdf(md_file: Path, output_file: Path, style: str = "report",
|
||||
footer: str = "", header: str = "") -> Path:
|
||||
"""Convert a single markdown file to PDF"""
|
||||
|
||||
with open(md_file, 'r', encoding='utf-8') as f:
|
||||
md_content = f.read()
|
||||
|
||||
import re
|
||||
|
||||
# Convert dash lists to bullet char, but NEVER inside fenced code blocks
|
||||
# or raw HTML blocks — there a leading "- " is literal content (shell
|
||||
# flags, YAML, diffs), not a list marker.
|
||||
md_content = _bulletize(md_content)
|
||||
|
||||
# Preserve multiple blank lines: 3+ consecutive newlines get a marker comment
|
||||
# that survives markdown processing without affecting layout (floats, divs, etc.)
|
||||
# Markers are replaced with spacing divs AFTER markdown→HTML conversion.
|
||||
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=[
|
||||
@@ -408,11 +616,72 @@ def convert_md_to_pdf(md_file: Path, output_file: Path, style: str = "elegant")
|
||||
'markdown.extensions.fenced_code',
|
||||
'markdown.extensions.codehilite',
|
||||
'markdown.extensions.toc',
|
||||
'markdown.extensions.nl2br'
|
||||
'markdown.extensions.attr_list',
|
||||
'markdown.extensions.md_in_html',
|
||||
'markdown.extensions.nl2br',
|
||||
]
|
||||
)
|
||||
|
||||
css = STYLES.get(style, STYLES["elegant"])
|
||||
# Replace markers with visible spacing
|
||||
html_content = html_content.replace(BLANK_MARKER, '<div style="height: 0.8em;"></div>')
|
||||
|
||||
css = STYLES.get(style, STYLES["report"]) + COMMON_CSS
|
||||
|
||||
# Footer/Header custom: injectat în @bottom-right / @top-right al paginii.
|
||||
# Substituții template:
|
||||
# {page} → counter(page) — numărul paginii curente
|
||||
# {pages} → counter(pages) — numărul total de pagini
|
||||
# Exemple:
|
||||
# --footer "Page {page}" → "Page 3"
|
||||
# --footer "Page {page} of {pages}" → "Page 3 of 12"
|
||||
# --footer "Confidential" → text static, fără counter
|
||||
# --header "{page}" → doar numărul de pagină
|
||||
def _render_template(text: str) -> str:
|
||||
"""Convert {page}/{pages} placeholders to CSS counter() expressions.
|
||||
|
||||
Returns a CSS content: value (string parts quoted, counters unquoted).
|
||||
Escapes backslash and double-quote in literal text portions.
|
||||
"""
|
||||
import re
|
||||
# Split on placeholders, keeping them
|
||||
parts = re.split(r'(\{page\}|\{pages\})', text)
|
||||
rendered = []
|
||||
for part in parts:
|
||||
if part == '{page}':
|
||||
rendered.append('counter(page)')
|
||||
elif part == '{pages}':
|
||||
rendered.append('counter(pages)')
|
||||
elif part:
|
||||
# Literal text — escape for CSS string
|
||||
escaped = part.replace('\\', '\\\\').replace('"', '\\"')
|
||||
rendered.append(f'"{escaped}"')
|
||||
return ' '.join(rendered) if rendered else '""'
|
||||
|
||||
if footer:
|
||||
footer_css = _render_template(footer)
|
||||
css += f"""
|
||||
@page {{
|
||||
@bottom-center {{
|
||||
content: {footer_css};
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 7.5pt;
|
||||
color: #999;
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
if header:
|
||||
header_css = _render_template(header)
|
||||
css += f"""
|
||||
@page {{
|
||||
@top-center {{
|
||||
content: {header_css};
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 7.5pt;
|
||||
color: #999;
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
full_html = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -427,7 +696,10 @@ def convert_md_to_pdf(md_file: Path, output_file: Path, style: str = "elegant")
|
||||
|
||||
font_config = FontConfiguration()
|
||||
html_doc = HTML(string=full_html)
|
||||
html_doc.write_pdf(output_file, font_config=font_config)
|
||||
html_doc.write_pdf(
|
||||
output_file,
|
||||
font_config=font_config,
|
||||
)
|
||||
|
||||
return output_file
|
||||
|
||||
@@ -444,8 +716,17 @@ Examples:
|
||||
%(prog)s docs/ -o pdf/ Convert directory to different output
|
||||
%(prog)s doc.md --style mono Use monospace font
|
||||
%(prog)s doc.md --style dark Use dark theme
|
||||
%(prog)s contract.md --footer "Confidential" Add footer (static text)
|
||||
%(prog)s doc.md --header "Project X" Add header (static text)
|
||||
%(prog)s doc.md --footer "Page {page}" Add footer with page counter
|
||||
%(prog)s doc.md --footer "Page {page} of {pages}" Page counter with total
|
||||
|
||||
Available styles: elegant (default), default, dark, mono
|
||||
Footer/Header placeholders (only when --footer/--header is used):
|
||||
{page} current page number (e.g. "3")
|
||||
{pages} total page count (e.g. "12")
|
||||
Literal text is rendered as-is. Mix freely: "Page {page} of {pages} — Confidential"
|
||||
|
||||
Available styles: elegant (default), default, dark, mono, report
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -454,6 +735,14 @@ Available styles: elegant (default), default, dark, mono
|
||||
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')
|
||||
parser.add_argument('--footer',
|
||||
help='Custom footer text (center-aligned, @bottom-center). '
|
||||
'Supports {page} and {pages} placeholders: '
|
||||
'"Page {page} of {pages}"')
|
||||
parser.add_argument('--header',
|
||||
help='Custom header text (center-aligned, @top-center). '
|
||||
'Supports {page} and {pages} placeholders: '
|
||||
'"Section X — page {page}"')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -505,7 +794,9 @@ Available styles: elegant (default), default, dark, mono
|
||||
print(f"Converting: {md_file.name} -> {pdf_file.name}...", end=' ', flush=True)
|
||||
|
||||
pdf_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
convert_md_to_pdf(md_file, pdf_file, args.style)
|
||||
convert_md_to_pdf(md_file, pdf_file, args.style,
|
||||
footer=args.footer or "",
|
||||
header=args.header or "")
|
||||
|
||||
if not args.quiet:
|
||||
size_kb = pdf_file.stat().st_size / 1024
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# To‑do list pentru plata cu cardurile profesorilor (Pluxee / Prima de carieră)
|
||||
|
||||
Împărțit în:
|
||||
- **A. Client (deținător website)**
|
||||
- **B. Tu (partea tehnică)**
|
||||
|
||||
---
|
||||
|
||||
## A. Client – ce are de făcut
|
||||
|
||||
1. **Clarifică programul**
|
||||
- [ ] Confirmă că se lucrează cu cardurile Pluxee pentru „Prima de carieră didactică / profesională".
|
||||
- [ ] Verifică la inspectorat / finanțator dacă este impus un anumit procesator (ex. „BT eComm / EuPlătesc", „Netopia" etc.).
|
||||
|
||||
2. **Devino comerciant afiliat Pluxee**
|
||||
- [ ] Completează formularul pentru comercianți Pluxee:
|
||||
- https://www.pluxee.ro/beneficii-comercianti/
|
||||
- [ ] Specifică clar că vrei să accepți carduri pentru **Prima de carieră didactică / profesională**.
|
||||
- [ ] Semnează contractul și confirmă că firma este în rețeaua de „locații afiliate".
|
||||
|
||||
3. **Alege procesatorul de plăți**
|
||||
- [ ] Cere oferte comerciale de la minimum 2–3 procesatori:
|
||||
- NETOPIA Payments – https://netopia-payments.com
|
||||
- EuPlătesc – https://www.euplatesc.ro/
|
||||
- PayU România – https://romania.payu.com/oferta-companii/
|
||||
- (opțional) PlatiOnline – https://plati.online/parteneri/
|
||||
- (opțional) BT eCommerce – https://www.bancatransilvania.ro/companii/solutii-de-plata
|
||||
- [ ] Negociază:
|
||||
- comision carduri standard,
|
||||
- comision carduri de beneficii (Pluxee).
|
||||
- [ ] Semnează contractul cu procesatorul ales.
|
||||
|
||||
4. **Predă către developer tot ce e necesar**
|
||||
- [ ] Date firmă (CUI, denumire, adresă, email contact).
|
||||
- [ ] Acces la contul de comerciant al procesatorului.
|
||||
- [ ] Credențiale tehnice (Merchant ID, API key / signature etc.).
|
||||
- [ ] Documentația oficială / linkul de „Developers" al procesatorului.
|
||||
- [ ] Decizia clară: **„Acesta este procesatorul pe care îl folosim"**.
|
||||
|
||||
---
|
||||
|
||||
## B. Tu (developer) – ce ai de făcut
|
||||
|
||||
1. **Clarificări inițiale**
|
||||
- [ ] Confirmă cu clientul:
|
||||
- ce procesator a ales,
|
||||
- dacă există restricții impuse de inspectorat / finanțator.
|
||||
- [ ] Verifică că firma este sau va fi **afiliată Pluxee** (altfel nu are sens integrarea pentru aceste carduri).
|
||||
|
||||
2. **Documentație procesator**
|
||||
- [ ] Ia linkul oficial de documentație:
|
||||
- NETOPIA – secțiunea „Developers" (Payment API / redirect).
|
||||
- EuPlătesc – ghid integrator din contul de comerciant.
|
||||
- PayU – https://developers.payu.com/europe/
|
||||
- PlatiOnline – https://wiki.plati.online/index.php/PlatiOnline_API_-_software_developers
|
||||
- [ ] Notează cerințele lor minime (ce URL‑uri trebuie să expui, ce parametri trebuie trimiși, cum se validează semnătura).
|
||||
|
||||
3. **Integrare la nivel de produs (high‑level)**
|
||||
- [ ] Decide împreună cu clientul:
|
||||
- cum se numește metoda de plată în checkout (ex. „Plată online cu cardul (inclusiv card profesor)").
|
||||
- în ce punct al flow‑ului se pornește plata (final de checkout).
|
||||
- [ ] Planifică:
|
||||
- URL pentru inițiere plată,
|
||||
- URL pentru return (success/error),
|
||||
- URL pentru notificare server‑to‑server (IPN / webhook).
|
||||
|
||||
4. **Testare & go‑live**
|
||||
- [ ] Configurează mediul de **test/sandbox** al procesatorului.
|
||||
- [ ] Fă tranzacții de test (carduri de test).
|
||||
- [ ] După mutarea în producție:
|
||||
- [ ] Fă 1–2 tranzacții reale cu un card Pluxee (sume mici).
|
||||
- [ ] Confirmă cu clientul că:
|
||||
- plățile apar în rapoartele procesatorului,
|
||||
- plățile apar și în interfața Pluxee (dacă e cazul),
|
||||
- site‑ul activează corect comenzile.
|
||||
|
||||
5. **Monitorizare inițială**
|
||||
- [ ] Monitorizează primele zile:
|
||||
- erori de plată,
|
||||
- rata de succes a tranzacțiilor,
|
||||
- eventuale refuzuri specifice cardurilor de beneficii,
|
||||
- [ ] Ajustează împreună cu procesatorul dacă apar probleme de configurare (MCC, limite etc.).
|
||||
|
||||
---
|
||||
Binary file not shown.
@@ -0,0 +1,46 @@
|
||||
"""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
|
||||
|
||||
|
||||
def test_unchecked_task_list_becomes_empty_checkbox():
|
||||
text = "- [ ] todo item\n"
|
||||
result = _bulletize(text)
|
||||
assert "☐ todo item" in result
|
||||
assert "[ ]" not in result
|
||||
|
||||
|
||||
def test_checked_task_list_becomes_checked_checkbox():
|
||||
text = "- [x] done item\n- [X] also done\n"
|
||||
result = _bulletize(text)
|
||||
assert "☑ done item" in result
|
||||
assert "☑ also done" in result
|
||||
assert "[x]" not in result
|
||||
assert "[X]" not in result
|
||||
@@ -0,0 +1,22 @@
|
||||
"""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
|
||||
@@ -0,0 +1,74 @@
|
||||
"""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)
|
||||
trs = list(root.iter(f"{{{W_NS}}}tr"))
|
||||
assert len(trs) == 2
|
||||
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
|
||||
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_el = list(root.iter(f"{{{W_NS}}}i"))
|
||||
assert len(italic_el) >= 1
|
||||
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
|
||||
@@ -0,0 +1,42 @@
|
||||
"""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():
|
||||
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()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""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
|
||||
|
||||
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):
|
||||
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"
|
||||
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)
|
||||
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 ONE abstractNum with 3 w:lvl elements (ilvl 0/1/2).
|
||||
|
||||
BEHAVIORAL: traces num_id → w:num → w:abstractNumId → w:abstractNum, then
|
||||
asserts THAT abstractNum (not all 9 default abstractNums in the template)
|
||||
has exactly the levels we registered. A `return 1` no-op mutation must FAIL.
|
||||
"""
|
||||
doc = Document()
|
||||
num_id = register_multilevel_numbering(doc, levels=3, kind="bullet")
|
||||
assert isinstance(num_id, int)
|
||||
|
||||
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)
|
||||
|
||||
our_num = None
|
||||
for num_el in root.findall(f"{{{W_NS}}}num"):
|
||||
if num_el.get(f"{{{W_NS}}}numId") == str(num_id):
|
||||
our_num = num_el
|
||||
break
|
||||
assert our_num is not None, f"No <w:num> with numId={num_id} (function did nothing)"
|
||||
|
||||
abstract_ref = our_num.find(f"{{{W_NS}}}abstractNumId")
|
||||
assert abstract_ref is not None, "missing w:abstractNumId in our w:num"
|
||||
abstract_id = abstract_ref.get(f"{{{W_NS}}}val")
|
||||
|
||||
our_abstract = None
|
||||
for an in root.findall(f"{{{W_NS}}}abstractNum"):
|
||||
if an.get(f"{{{W_NS}}}abstractNumId") == abstract_id:
|
||||
our_abstract = an
|
||||
break
|
||||
assert our_abstract is not None, f"No <w:abstractNum> with id={abstract_id}"
|
||||
|
||||
lvls = our_abstract.findall(f"{{{W_NS}}}lvl")
|
||||
ilvls = sorted(int(l.get(f"{{{W_NS}}}ilvl")) for l in lvls)
|
||||
assert ilvls == [0, 1, 2], f"expected ilvl=[0,1,2] in OUR abstractNum, got {ilvls}"
|
||||
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"
|
||||
@@ -0,0 +1,60 @@
|
||||
"""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"))
|
||||
headings = list(root.iter(f"{{{W_NS}}}pStyle"))
|
||||
heading_vals = [h.get(f"{{{W_NS}}}val") for h in headings]
|
||||
assert any(v and 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):
|
||||
(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()
|
||||
@@ -0,0 +1,130 @@
|
||||
"""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_and_resolves_to_multilevel_abstractNum():
|
||||
"""BEHAVIORAL: paragraphs have ilvl=[0,1,2], AND the numId they reference
|
||||
resolves to an abstractNum that actually defines those levels. A no-op
|
||||
register_multilevel_numbering (return 1) would leave paragraphs pointing to
|
||||
default-template numId=1 (single-level), so the test must FAIL.
|
||||
"""
|
||||
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"])
|
||||
|
||||
tmp = Path(__file__).parent / "_tmp_list_full.docx"
|
||||
doc.save(tmp)
|
||||
try:
|
||||
with zipfile.ZipFile(tmp) as z:
|
||||
doc_xml = z.read("word/document.xml")
|
||||
num_xml = z.read("word/numbering.xml")
|
||||
doc_root = ET.fromstring(doc_xml)
|
||||
num_root = ET.fromstring(num_xml)
|
||||
|
||||
# 1. Paragraphs must have ilvl=[0,1,2]
|
||||
ilvls = [int(el.get(f"{{{W_NS}}}val"))
|
||||
for el in doc_root.iter(f"{{{W_NS}}}ilvl")]
|
||||
assert ilvls == [0, 1, 2], f"expected paragraph ilvl=[0,1,2], got {ilvls}"
|
||||
|
||||
# 2. All paragraphs must reference the SAME numId
|
||||
numIds = [el.get(f"{{{W_NS}}}val")
|
||||
for el in doc_root.iter(f"{{{W_NS}}}numId")]
|
||||
assert len(set(numIds)) == 1, \
|
||||
f"expected all paragraphs to share one numId, got {set(numIds)}"
|
||||
our_num_id = numIds[0]
|
||||
|
||||
# 3. numId resolves to abstractNum that defines ilvl=0,1,2
|
||||
our_num = None
|
||||
for num_el in num_root.findall(f"{{{W_NS}}}num"):
|
||||
if num_el.get(f"{{{W_NS}}}numId") == our_num_id:
|
||||
our_num = num_el
|
||||
break
|
||||
assert our_num is not None, f"our numId={our_num_id} not found in numbering.xml"
|
||||
|
||||
abstract_ref = our_num.find(f"{{{W_NS}}}abstractNumId")
|
||||
assert abstract_ref is not None
|
||||
abstract_id = abstract_ref.get(f"{{{W_NS}}}val")
|
||||
|
||||
our_abstract = None
|
||||
for an in num_root.findall(f"{{{W_NS}}}abstractNum"):
|
||||
if an.get(f"{{{W_NS}}}abstractNumId") == abstract_id:
|
||||
our_abstract = an
|
||||
break
|
||||
assert our_abstract is not None, f"abstractNum {abstract_id} not found"
|
||||
|
||||
abstract_ilvls = sorted(int(l.get(f"{{{W_NS}}}ilvl"))
|
||||
for l in our_abstract.findall(f"{{{W_NS}}}lvl"))
|
||||
assert abstract_ilvls == [0, 1, 2], \
|
||||
f"our abstractNum must define ilvl=[0,1,2], got {abstract_ilvls}"
|
||||
finally:
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
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"])
|
||||
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()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""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)
|
||||
# pStyle is an attribute, not text content
|
||||
pStyle_vals = [p.get(f"{{{W_NS}}}val") for p in root.iter(f"{{{W_NS}}}pStyle")]
|
||||
assert "Heading1" in pStyle_vals
|
||||
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'] (case-insensitive)."""
|
||||
doc = Document()
|
||||
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)
|
||||
colors = [c.get(f"{{{W_NS}}}val").lower() 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)
|
||||
keep_next = list(root.iter(f"{{{W_NS}}}keepNext"))
|
||||
assert len(keep_next) >= 1, "expected w:keepNext for heading"
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
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"
|
||||
@@ -0,0 +1,19 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user