#!/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 = '' 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, '
') 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""" {html_content} """ 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()