Compare commits

..
16 Commits
Author SHA1 Message Date
sebastian 667772784f chore: bring working tree to HEAD — gitignore + CLAUDE.md + fixtures
- .gitignore: extended with noise (.DS_Store, *.log, .aw/, *.db, *.bak,
  sailo-claude-backup/, superpowers/, agents/, scripts/, foreign docs)
- CLAUDE.md: project-specific config (was untracked)
- pluxee-todo.md + pluxee-todo.pdf: fixtures and sample output
2026-07-28 12:00:38 +03:00
sebastian 7b51283ae1 chore: extend .gitignore + add missing integration test
- .gitignore: add .DS_Store, *.log, *.bak, sailo-claude-backup/, .aw/, *.db,
  pytest_cache, test artifacts
- tests/test_md2doc_integration.py: missed in Task 8 commit
2026-07-28 11:05:14 +03:00
sebastian e8b105a5fb test(md2doc): style smoke test for all 5 styles 2026-07-28 11:03:38 +03:00
sebastian 5a62572f55 feat: --footer/--header with {page}/{pages} placeholders, center-aligned
md2pdf.py:
- Expose --footer and --header (was dead code before)
- Remove --forms flag (B2 fix — weasyprint 68.1 silently drops <input>)
- Substitution support: {page} → counter(page), {pages} → counter(pages)
- Center-aligned via @bottom-center / @top-center

md2doc.py:
- Add --footer and --header support via Word field codes (PAGE, NUMPAGES)
- Center-aligned paragraph in footer/header
- Same {page}/{pages} substitution API as md2pdf

Both tools:
- GFM task list support: - [ ] → ☐, - [x]/- [X] → ☑ (synced _bulletize)
- Help text explains placeholder syntax with examples
2026-07-28 11:02:23 +03:00
sebastian 21b3a7a77d feat(md2doc): table, pre, blockquote, hr renderers 2026-07-28 10:27:02 +03:00
sebastian 5fd05ba2be feat(md2doc): recursive list renderer with manual numbering 2026-07-28 10:26:17 +03:00
sebastian 162485bfb2 test(md2doc): fix pStyle attribute + case-insensitive color compare 2026-07-28 10:24:55 +03:00
sebastian 36b6a16fd8 feat(md2doc): heading and paragraph renderers with inline formatting 2026-07-28 10:24:12 +03:00
sebastian 322eaad887 feat(md2doc): XML helpers — borders, shading, multi-level numbering 2026-07-28 10:23:18 +03:00
sebastian e3176a789e feat(md2doc): add skeleton — argparse, STYLES dict, stub converter 2026-07-28 10:22:20 +03:00
sebastian 0b344d3bb8 feat(md2doc): copy _bulletize helper from md2pdf (spec §3.3) 2026-07-28 10:21:33 +03:00
sebastian 2a2c0ed249 chore: add deps test for md2doc (python-docx, lxml) 2026-07-28 10:21:00 +03:00
sebastian bdc7b5e6b2 Plan: fix Task 5 levels=5 → levels=3 (align with test assertion [0,1,2])
Re-audit found regression: Task 5 Step 3 registered 5 levels but test asserted
[0,1,2] in the abstractNum. Test was BEHAVIORAL (correctly failed on no-op
mutation) but also failed on clean code. Aligning to levels=3 (consistent
with Task 3, more realistic for typical markdown nesting).
2026-07-28 00:03:55 +03:00
sebastian e36e87966d Plan: fix H3 spy tests + _render_pre trailing empty
Empirical audit (Stage 2) found 2 critical spy tests guarding the H3 keystone:
- test_register_multilevel_numbering_creates_abstractNum_with_3_levels
- test_nested_bullet_list_uses_increasing_ilvl

Both passed when register_multilevel_numbering was mutated to 'return 1'
(no XML written). Rewrote both to BEHAVIORAL form: trace numId → w:num →
w:abstractNumId → w:abstractNum, assert THAT abstractNum has ilvl=[0,1,2].

Also: _render_pre now drops trailing empty line (text.split('\n') artifact).
2026-07-28 00:00:07 +03:00
sebastian 5891ed892c Plan: address Stage 1 re-review feedback
Fixes:
- CRITICAL: Task 2 Step 3 no longer overwrites md2doc.py — extends in place
- MAJOR: COMMON.keep_with_next_headings now wired into _render_heading
- MINOR: added test for keep_with_next assertion
- MINOR: Task 9 note about footer param preservation
- MINOR: _render_pre now preserves newlines via add_break()
- MINOR: Notes section documents fixture location, security, COMMON wiring
2026-07-27 23:51:21 +03:00
sebastian 5f8c2bdbc2 Plan: address Stage 1 reviewer feedback
Fixes:
- Critical: add Task 1.5 (_bulletize copy with complete code)
- Major: enrich STYLES dict (5 styles now visually distinct)
- Major: implement COMMON dict partial (keep_with_next on headings)
- Major: inline code now gets run-level shading (spec §4)
- Minor: heading color assertion added
- Minor: Task 5 Step 4.5 H3 keystone manual checkpoint (spec §4.2)
- Minor: Task 8 exact line numbers from Research Summary
- Minor: Task 10 renamed to characterization test
2026-07-27 23:46:45 +03:00
16 changed files with 3824 additions and 20 deletions
+34 -1
View File
@@ -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
+17
View File
@@ -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
+711
View File
@@ -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()
+310 -19
View File
@@ -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
@@ -438,14 +710,23 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s document.md Convert single file (elegant style)
%(prog)s document.md -o report.pdf Convert with custom output name
%(prog)s docs/ Convert all .md files in directory
%(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 document.md Convert single file (elegant style)
%(prog)s document.md -o report.pdf Convert with custom output name
%(prog)s docs/ Convert all .md files in directory
%(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
+84
View File
@@ -0,0 +1,84 @@
# Todo 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 23 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 URLuri trebuie să expui, ce parametri trebuie trimiși, cum se validează semnătura).
3. **Integrare la nivel de produs (highlevel)**
- [ ] 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 flowului se pornește plata (final de checkout).
- [ ] Planifică:
- URL pentru inițiere plată,
- URL pentru return (success/error),
- URL pentru notificare servertoserver (IPN / webhook).
4. **Testare & golive**
- [ ] Configurează mediul de **test/sandbox** al procesatorului.
- [ ] Fă tranzacții de test (carduri de test).
- [ ] După mutarea în producție:
- [ ] Fă 12 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),
- siteul 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.).
---
BIN
View File
Binary file not shown.
+46
View File
@@ -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
+22
View File
@@ -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
+74
View File
@@ -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
+42
View File
@@ -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()
+138
View File
@@ -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"
+60
View File
@@ -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![alt text](https://example.com/x.png)\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()
+130
View File
@@ -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()
+110
View File
@@ -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"
+19
View File
@@ -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"