Compare commits
16
Commits
64c77d27fc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
667772784f | ||
|
|
7b51283ae1 | ||
|
|
e8b105a5fb | ||
|
|
5a62572f55 | ||
|
|
21b3a7a77d | ||
|
|
5fd05ba2be | ||
|
|
162485bfb2 | ||
|
|
36b6a16fd8 | ||
|
|
322eaad887 | ||
|
|
e3176a789e | ||
|
|
0b344d3bb8 | ||
|
|
2a2c0ed249 | ||
|
|
bdc7b5e6b2 | ||
|
|
e36e87966d | ||
|
|
5891ed892c | ||
|
|
5f8c2bdbc2 |
+34
-1
@@ -1,3 +1,36 @@
|
|||||||
.venv/
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.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,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
|
# Style templates
|
||||||
STYLES = {
|
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": """
|
"elegant": """
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&display=swap');
|
||||||
@page {
|
@page {
|
||||||
@@ -99,13 +219,14 @@ STYLES = {
|
|||||||
th { font-weight: 400; color: #666; }
|
th { font-weight: 400; color: #666; }
|
||||||
ul, ol {
|
ul, ol {
|
||||||
margin: 0.3em 0;
|
margin: 0.3em 0;
|
||||||
padding-left: 0;
|
padding-left: 1.2em;
|
||||||
list-style-position: inside;
|
list-style-position: outside;
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
}
|
}
|
||||||
ol { list-style-type: decimal; }
|
ol { list-style-type: decimal; }
|
||||||
li {
|
li {
|
||||||
margin: 0.15em 0;
|
margin: 0.15em 0;
|
||||||
|
padding-left: 0.3em;
|
||||||
}
|
}
|
||||||
blockquote {
|
blockquote {
|
||||||
margin: 0.8em 0;
|
margin: 0.8em 0;
|
||||||
@@ -207,13 +328,14 @@ STYLES = {
|
|||||||
}
|
}
|
||||||
ul, ol {
|
ul, ol {
|
||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
padding-left: 0;
|
padding-left: 1.2em;
|
||||||
list-style-position: inside;
|
list-style-position: outside;
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
}
|
}
|
||||||
ol { list-style-type: decimal; }
|
ol { list-style-type: decimal; }
|
||||||
li {
|
li {
|
||||||
margin: 0.2em 0;
|
margin: 0.2em 0;
|
||||||
|
padding-left: 0.3em;
|
||||||
}
|
}
|
||||||
blockquote {
|
blockquote {
|
||||||
border-left: 2px solid #ddd;
|
border-left: 2px solid #ddd;
|
||||||
@@ -283,8 +405,9 @@ STYLES = {
|
|||||||
padding: 0.5em 1em;
|
padding: 0.5em 1em;
|
||||||
margin: 1em 0;
|
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; }
|
ol { list-style-type: decimal; }
|
||||||
|
li { padding-left: 0.3em; }
|
||||||
a { color: #818cf8; }
|
a { color: #818cf8; }
|
||||||
hr { border: none; border-top: 1px solid #374151; margin: 2em 0; }
|
hr { border: none; border-top: 1px solid #374151; margin: 2em 0; }
|
||||||
""",
|
""",
|
||||||
@@ -364,13 +487,14 @@ STYLES = {
|
|||||||
}
|
}
|
||||||
ul, ol {
|
ul, ol {
|
||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
padding-left: 0;
|
padding-left: 1.2em;
|
||||||
list-style-position: inside;
|
list-style-position: outside;
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
}
|
}
|
||||||
ol { list-style-type: decimal; }
|
ol { list-style-type: decimal; }
|
||||||
li {
|
li {
|
||||||
margin: 0.2em 0;
|
margin: 0.2em 0;
|
||||||
|
padding-left: 0.3em;
|
||||||
}
|
}
|
||||||
blockquote {
|
blockquote {
|
||||||
border-left: 3px solid #999;
|
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"""
|
"""Convert a single markdown file to PDF"""
|
||||||
|
|
||||||
with open(md_file, 'r', encoding='utf-8') as f:
|
with open(md_file, 'r', encoding='utf-8') as f:
|
||||||
md_content = f.read()
|
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(
|
html_content = markdown.markdown(
|
||||||
md_content,
|
md_content,
|
||||||
extensions=[
|
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.fenced_code',
|
||||||
'markdown.extensions.codehilite',
|
'markdown.extensions.codehilite',
|
||||||
'markdown.extensions.toc',
|
'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>
|
full_html = f"""<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -427,7 +696,10 @@ def convert_md_to_pdf(md_file: Path, output_file: Path, style: str = "elegant")
|
|||||||
|
|
||||||
font_config = FontConfiguration()
|
font_config = FontConfiguration()
|
||||||
html_doc = HTML(string=full_html)
|
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
|
return output_file
|
||||||
|
|
||||||
@@ -444,8 +716,17 @@ Examples:
|
|||||||
%(prog)s docs/ -o pdf/ Convert directory to different output
|
%(prog)s docs/ -o pdf/ Convert directory to different output
|
||||||
%(prog)s doc.md --style mono Use monospace font
|
%(prog)s doc.md --style mono Use monospace font
|
||||||
%(prog)s doc.md --style dark Use dark theme
|
%(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',
|
parser.add_argument('--style', choices=list(STYLES.keys()), default='elegant',
|
||||||
help='Style template (default: elegant)')
|
help='Style template (default: elegant)')
|
||||||
parser.add_argument('-q', '--quiet', action='store_true', help='Suppress output')
|
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()
|
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)
|
print(f"Converting: {md_file.name} -> {pdf_file.name}...", end=' ', flush=True)
|
||||||
|
|
||||||
pdf_file.parent.mkdir(parents=True, exist_ok=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:
|
if not args.quiet:
|
||||||
size_kb = pdf_file.stat().st_size / 1024
|
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