Files
md2pdf/md2pdf.py
T
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

819 lines
25 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Convert markdown files to PDF
Usage:
# Single file
md2pdf.py input.md # Creates input.pdf in same directory
md2pdf.py input.md -o output.pdf # Specify output file
# Directory (all .md files)
md2pdf.py docs/ # Creates PDFs in docs/
md2pdf.py docs/ -o pdf_output/ # Creates PDFs in pdf_output/
# With custom style
md2pdf.py input.md --style mono # Use monospace style
Requires: pip install markdown weasyprint
"""
import argparse
import sys
from pathlib import Path
import markdown
from weasyprint import HTML
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 {
size: A4;
margin: 1.8cm 2cm;
}
body {
font-family: 'Lato', 'Helvetica Neue', Helvetica, sans-serif;
font-weight: 300;
font-size: 9.5pt;
line-height: 1.55;
color: #2c2c2c;
}
h1 {
font-weight: 300;
font-size: 22pt;
color: #1a1a1a;
margin: 0 0 0.3em 0;
letter-spacing: 0.5pt;
}
h2 {
font-weight: 400;
font-size: 11pt;
color: #444;
margin: 1.2em 0 0.4em 0;
padding-bottom: 0.2em;
border-bottom: 1px solid #e0e0e0;
text-transform: uppercase;
letter-spacing: 1pt;
}
h3 {
font-weight: 400;
font-size: 10pt;
color: #333;
margin: 0.9em 0 0.3em 0;
}
h4 {
font-weight: 400;
font-size: 9.5pt;
color: #555;
margin: 0.7em 0 0.2em 0;
}
p {
margin: 0.4em 0;
}
code {
font-family: 'SF Mono', Menlo, monospace;
font-size: 8.5pt;
background: #f8f8f8;
padding: 1px 4px;
border-radius: 2px;
}
pre {
background: #f8f8f8;
padding: 0.8em;
font-size: 8pt;
border-left: 2px solid #ddd;
}
pre code { background: none; padding: 0; }
table {
width: 100%;
border-collapse: collapse;
font-size: 9pt;
margin: 0.8em 0;
}
th, td {
padding: 0.4em;
text-align: left;
border-bottom: 1px solid #eee;
}
th { font-weight: 400; color: #666; }
ul, ol {
margin: 0.3em 0;
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;
padding-left: 1em;
border-left: 2px solid #ccc;
color: #666;
font-style: italic;
}
a {
color: #2c2c2c;
text-decoration: none;
border-bottom: 1px solid #ccc;
}
strong { font-weight: 400; }
em { font-style: italic; }
hr {
border: none;
border-top: 1px solid #e5e5e5;
margin: 1.2em 0;
}
""",
"default": """
@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600&display=swap');
@page {
size: A4;
margin: 2cm;
}
body {
font-family: 'Open Sans', -apple-system, BlinkMacSystemFont, sans-serif;
font-weight: 300;
font-size: 10pt;
line-height: 1.6;
color: #333;
}
h1, h2, h3, h4 {
font-weight: 400;
color: #1a1a1a;
}
h1 {
font-size: 18pt;
margin-top: 0;
margin-bottom: 0.8em;
padding-bottom: 0.4em;
border-bottom: 1px solid #ddd;
}
h2 {
font-size: 13pt;
margin-top: 1.3em;
margin-bottom: 0.5em;
color: #333;
}
h3 {
font-size: 11pt;
margin-top: 1em;
margin-bottom: 0.4em;
}
h4 {
font-size: 10pt;
margin-top: 0.8em;
}
code {
background-color: #f5f5f5;
padding: 2px 5px;
border-radius: 3px;
font-family: 'SF Mono', Menlo, Monaco, monospace;
font-size: 9pt;
}
pre {
background-color: #2d2d2d;
color: #f5f5f5;
padding: 1em;
border-radius: 4px;
overflow-x: auto;
font-size: 8.5pt;
line-height: 1.5;
}
pre code {
background-color: transparent;
color: inherit;
padding: 0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
font-size: 9pt;
}
th {
background-color: #f5f5f5;
color: #333;
padding: 0.5em;
text-align: left;
font-weight: 400;
border-bottom: 1px solid #ddd;
}
td {
border-bottom: 1px solid #eee;
padding: 0.5em;
}
ul, ol {
margin: 0.5em 0;
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;
margin: 1em 0;
padding: 0.5em 1em;
color: #666;
font-style: italic;
}
a {
color: #0066cc;
text-decoration: none;
}
strong {
font-weight: 600;
}
hr {
border: none;
border-top: 1px solid #eee;
margin: 1.5em 0;
}
""",
"dark": """
@page {
size: A4;
margin: 2cm;
background: #1a1a2e;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
font-size: 11pt;
line-height: 1.6;
color: #e4e4e7;
background: #1a1a2e;
}
h1 {
color: #818cf8;
font-size: 24pt;
border-bottom: 2px solid #818cf8;
padding-bottom: 0.5em;
}
h2 { color: #a5b4fc; font-size: 18pt; margin-top: 1.5em; }
h3 { color: #c7d2fe; font-size: 14pt; margin-top: 1.2em; }
h4 { color: #e0e7ff; font-size: 12pt; margin-top: 1em; }
code {
background-color: #374151;
color: #fbbf24;
padding: 2px 6px;
border-radius: 3px;
font-family: 'SF Mono', Menlo, monospace;
font-size: 10pt;
}
pre {
background-color: #0f0f1a;
color: #e4e4e7;
padding: 1em;
border-radius: 5px;
font-size: 9pt;
}
pre code { background: none; color: inherit; padding: 0; }
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
th { background: #4f46e5; color: white; padding: 0.5em; }
td { border: 1px solid #4b5563; padding: 0.5em; }
tr:nth-child(even) { background: #1f2937; }
blockquote {
border-left: 4px solid #818cf8;
background: #1f2937;
padding: 0.5em 1em;
margin: 1em 0;
}
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; }
""",
"mono": """
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500&display=swap');
@page {
size: A4;
margin: 2cm;
}
body {
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', Consolas, monospace;
font-weight: 300;
font-size: 9pt;
line-height: 1.6;
color: #1a1a1a;
}
h1, h2, h3, h4 {
font-weight: 500;
color: #000;
}
h1 {
font-size: 16pt;
margin-top: 0;
margin-bottom: 0.8em;
padding-bottom: 0.3em;
border-bottom: 2px solid #000;
}
h2 {
font-size: 12pt;
margin-top: 1.5em;
margin-bottom: 0.5em;
text-transform: uppercase;
letter-spacing: 1pt;
}
h3 {
font-size: 10pt;
margin-top: 1.2em;
margin-bottom: 0.4em;
}
h4 {
font-size: 9pt;
margin-top: 1em;
}
p {
margin: 0.5em 0;
}
code {
background-color: #f0f0f0;
padding: 1px 4px;
border-radius: 2px;
}
pre {
background-color: #f5f5f5;
padding: 1em;
border: 1px solid #ddd;
font-size: 8.5pt;
line-height: 1.5;
}
pre code {
background: none;
padding: 0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
font-size: 8.5pt;
}
th, td {
border: 1px solid #ccc;
padding: 0.4em;
text-align: left;
}
th {
background: #f0f0f0;
font-weight: 500;
}
ul, ol {
margin: 0.5em 0;
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;
margin: 1em 0;
padding: 0.5em 1em;
color: #555;
font-style: italic;
}
a {
color: #000;
text-decoration: underline;
}
strong {
font-weight: 500;
}
hr {
border: none;
border-top: 1px solid #ccc;
margin: 1.5em 0;
}
"""
}
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=[
'markdown.extensions.tables',
'markdown.extensions.fenced_code',
'markdown.extensions.codehilite',
'markdown.extensions.toc',
'markdown.extensions.attr_list',
'markdown.extensions.md_in_html',
'markdown.extensions.nl2br',
]
)
# 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>
<head>
<meta charset="UTF-8">
<style>{css}</style>
</head>
<body>
{html_content}
</body>
</html>"""
font_config = FontConfiguration()
html_doc = HTML(string=full_html)
html_doc.write_pdf(
output_file,
font_config=font_config,
)
return output_file
def main():
parser = argparse.ArgumentParser(
description='Convert Markdown files to PDF',
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 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 PDF 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, @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()
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
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]
# Output handling for single file
if args.output:
output_path = Path(args.output).resolve()
if output_path.suffix.lower() == '.pdf':
outputs = [output_path]
else:
output_path.mkdir(parents=True, exist_ok=True)
outputs = [output_path / (input_path.stem + '.pdf')]
else:
outputs = [input_path.with_suffix('.pdf')]
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)
# Output handling for directory
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 + '.pdf') for f in files]
# Convert files
success = 0
errors = 0
for md_file, pdf_file in zip(files, outputs):
try:
if not args.quiet:
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,
footer=args.footer or "",
header=args.header or "")
if not args.quiet:
size_kb = pdf_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()