|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +build_pdf.py -- Fully automated Markdown -> HTML -> PDF pipeline v3.0 |
| 4 | +
|
| 5 | +Reusable CLI tool for DeepChat agent threads. Converts any Markdown file |
| 6 | +(with optional YAML frontmatter) to a professional A4 PDF via HTML intermediate. |
| 7 | +
|
| 8 | +Pipeline: |
| 9 | + 1. Parse YAML frontmatter -> styled author block |
| 10 | + 2. Markdown -> HTML (code highlighting, tables, lists, math) |
| 11 | + 3. MathJax 3 CDN for LaTeX rendering (unless --no-math) |
| 12 | + 4. Edge/Chrome headless -> PDF with rendered JavaScript/MathJax |
| 13 | +
|
| 14 | +Usage: |
| 15 | + python build_pdf.py --input paper.md |
| 16 | + python build_pdf.py --input paper.md --output out.pdf |
| 17 | + python build_pdf.py --input paper.md --css custom.css --html-only |
| 18 | +""" |
| 19 | + |
| 20 | +import argparse |
| 21 | +import datetime |
| 22 | +import os |
| 23 | +import re |
| 24 | +import subprocess |
| 25 | +import sys |
| 26 | +import tempfile |
| 27 | + |
| 28 | + |
| 29 | +# --------------------------------------------------------------------------- |
| 30 | +# Embedded default CSS -- self-contained, no external CSS dependency |
| 31 | +# Use --css to override with a custom stylesheet file. |
| 32 | +# --------------------------------------------------------------------------- |
| 33 | +EMBEDDED_CSS = r"""/* build_pdf.css v3.0 -- Embedded academic PDF stylesheet */ |
| 34 | +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); |
| 35 | +:root{--text:#1a1a1a;--muted:#555;--border:#d0d0d0;--bg:#fafafa;--accent:#007acc;--title:24pt;--h1:18pt;--h2:15pt;--body:10.5pt} |
| 36 | +*{box-sizing:border-box;margin:0;padding:0} |
| 37 | +body{font-family:"Inter","Segoe UI","Helvetica Neue",Arial,sans-serif;font-size:var(--body);line-height:1.6;color:var(--text);max-width:6.2in;margin:0 auto;padding:0;text-align:left;background:white} |
| 38 | +h1.title{font-size:var(--title);font-weight:700;text-align:left;margin:0.8in 0 0.1in 0;line-height:1.1;color:var(--text);border-bottom:3px solid #000;padding-bottom:0.1in} |
| 39 | +h1{font-size:var(--h1);font-weight:600;margin:0.6in 0 0.3in 0;line-height:1.2;color:var(--text);border-bottom:2px solid #333;padding-bottom:0.1in;page-break-before:always;page-break-after:avoid} |
| 40 | +h2{font-size:var(--h2);font-weight:600;margin:1em 0 0.5em 0;color:var(--text);border-bottom:1px solid #ccc;padding-bottom:0.2em} |
| 41 | +h3{font-size:13pt;font-weight:600;margin:0.9em 0 0.4em 0;color:#333} |
| 42 | +p{margin:0 0 0.8em 0;line-height:1.6} |
| 43 | +.author-block{margin:0.2in 0 0.3in 0;padding:0.15in 0.2in;background:#f9f9f9;border-left:3px solid #000;border-radius:0 4px 4px 0;font-size:9.5pt;line-height:1.5} |
| 44 | +.author-block p{margin:0}.author-block strong{font-weight:600;color:var(--text)} |
| 45 | +.author-block a{color:var(--accent);text-decoration:none;border-bottom:1px dotted var(--accent)} |
| 46 | +.author-block .abstract-label{font-weight:700;display:block;margin-top:0.3em;font-size:10pt} |
| 47 | +.author-block .abstract-text{margin-top:0.2em;font-style:normal;color:var(--muted)} |
| 48 | +ul,ol{margin:0.8em 0;padding-left:2em}li{margin-bottom:0.4em;line-height:1.5} |
| 49 | +ol{counter-reset:item;list-style-type:none}ol>li{counter-increment:item;position:relative;padding-left:2em} |
| 50 | +ol>li::before{content:counter(item)".";font-weight:600;position:absolute;left:0;width:1.5em;text-align:right} |
| 51 | +code{font-family:"JetBrains Mono",Consolas,monospace;font-size:0.85em;background:#f9f9f9;padding:0.1em 0.3em;border-radius:2px;border:1px solid #e0e0e0;color:#d63384} |
| 52 | +pre{font-family:"JetBrains Mono",Consolas,monospace;background:#f9f9f9;padding:0.8em;margin:0.9em 0;border-radius:3px;overflow-x:auto;border:1px solid #e0e0e0;font-size:0.8em;line-height:1.5;border-left:3px solid #000;white-space:pre-wrap;word-break:break-word} |
| 53 | +pre code{background:none;padding:0;border:none;color:inherit;font-size:inherit} |
| 54 | +table{width:100%;border-collapse:collapse;margin:1.2em 0;font-size:0.95em;box-shadow:0 2px 4px rgba(0,0,0,0.05);border-radius:4px;overflow:hidden} |
| 55 | +th{background:#f0f0f0;font-weight:600;padding:0.5em 0.6em;text-align:left;border:1px solid var(--border);border-top:2px solid #000} |
| 56 | +td{background:#fff;padding:0.5em 0.6em;text-align:left;border:1px solid var(--border)} |
| 57 | +tr:nth-child(even) td{background:#fafafa}tbody tr td:first-child{font-weight:600} |
| 58 | +blockquote{border-left:3px solid #000;margin:1.2em 0;padding:0.5em 0 0.5em 0.9em;background:var(--bg);color:var(--muted);border-radius:0 2px 2px 0} |
| 59 | +a{color:var(--accent);text-decoration:none;border-bottom:1px dotted var(--accent)} |
| 60 | +mjx-container{overflow-x:auto;max-width:100%}mjx-container[display="true"]{display:block;margin:0.8em 0;text-align:left} |
| 61 | +.c{color:#6a737d;font-style:italic}.k{color:#d73a49;font-weight:bold}.s{color:#032f62}.mi{color:#005cc5}.nf{color:#6f42c1}.nc{color:#6f42c1;font-weight:bold} |
| 62 | +@media print{@page{size:A4;margin:0.75in 0.8in 0.75in 1.2in;@bottom-right{content:"Page "counter(page);font-family:"Inter",sans-serif;font-size:8pt;color:#666;padding:0.2in 0.4in 0 0}}body{padding:0;max-width:100%}.author-block{border:1px solid #000;border-left:3px solid #000}} |
| 63 | +""" |
| 64 | + |
| 65 | +MATHJAX_SCRIPT = """MathJax={tex:{inlineMath:[['$','$']],displayMath:[['$$','$$']],processEscapes:true,tags:'ams'},startup:{ready(){MathJax.startup.defaultReady();const n=document.querySelectorAll('mjx-container').length;console.log('MathJax ready: '+n+' expressions rendered')}}}""" |
| 66 | + |
| 67 | +MATHJAX_CDN = '<script async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"></script>' |
| 68 | + |
| 69 | + |
| 70 | +# --------------------------------------------------------------------------- |
| 71 | +# Utility functions |
| 72 | +# --------------------------------------------------------------------------- |
| 73 | + |
| 74 | +def find_browser(): |
| 75 | + """Locate an installed Edge or Chrome browser for headless PDF rendering.""" |
| 76 | + for p in [ |
| 77 | + r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", |
| 78 | + r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", |
| 79 | + r"C:\Program Files\Google\Chrome\Application\chrome.exe", |
| 80 | + ]: |
| 81 | + if os.path.exists(p): |
| 82 | + return p |
| 83 | + return None |
| 84 | + |
| 85 | + |
| 86 | +# CSS preset directory (relative to this script) |
| 87 | +CSS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "css") |
| 88 | + |
| 89 | +# Named style presets map to css/<name>.css files |
| 90 | +STYLE_PRESETS = { |
| 91 | + "academic": os.path.join(CSS_DIR, "academic.css"), |
| 92 | + "modern": os.path.join(CSS_DIR, "modern.css"), |
| 93 | + "minimal": os.path.join(CSS_DIR, "minimal.css"), |
| 94 | +} |
| 95 | + |
| 96 | + |
| 97 | +def resolve_css(style=None, css_path=None): |
| 98 | + """Resolve CSS content from style preset, CSS file, or embedded default. |
| 99 | +
|
| 100 | + Priority: --css overrides --style overrides embedded default. |
| 101 | + Returns (css_content, source_label) tuple. |
| 102 | + """ |
| 103 | + # 1. Explicit CSS file (highest priority) |
| 104 | + if css_path: |
| 105 | + if not os.path.exists(css_path): |
| 106 | + raise FileNotFoundError(f"CSS file not found: {css_path}") |
| 107 | + with open(css_path, "r", encoding="utf-8") as f: |
| 108 | + return f.read(), css_path |
| 109 | + |
| 110 | + # 2. Named style preset |
| 111 | + if style and style in STYLE_PRESETS: |
| 112 | + preset_path = STYLE_PRESETS[style] |
| 113 | + if not os.path.exists(preset_path): |
| 114 | + print(f"WARNING: Style preset '{style}' not found at {preset_path}, falling back to embedded.", file=sys.stderr) |
| 115 | + else: |
| 116 | + with open(preset_path, "r", encoding="utf-8") as f: |
| 117 | + return f.read(), f"{style} (preset: {preset_path})" |
| 118 | + |
| 119 | + # 3. Embedded default (fallback) |
| 120 | + return EMBEDDED_CSS, "(embedded default)" |
| 121 | + |
| 122 | + |
| 123 | +def parse_frontmatter(text): |
| 124 | + """Extract YAML frontmatter dict and body from markdown text.""" |
| 125 | + meta, body = {}, text |
| 126 | + if not text.startswith("---"): |
| 127 | + return meta, body |
| 128 | + parts = text.split("---", 2) |
| 129 | + if len(parts) < 3: |
| 130 | + return meta, body |
| 131 | + body = parts[2] |
| 132 | + for line in parts[1].strip().split("\n"): |
| 133 | + if ":" not in line: |
| 134 | + continue |
| 135 | + k, _, v = line.partition(":") |
| 136 | + k = k.strip().strip('"') |
| 137 | + v = v.strip().strip('"').strip("'").strip() |
| 138 | + if v.startswith("[") and v.endswith("]"): |
| 139 | + v = [x.strip().strip('"') for x in v[1:-1].split(",")] |
| 140 | + meta[k] = v |
| 141 | + return meta, body |
| 142 | + |
| 143 | + |
| 144 | +def build_author_block(meta): |
| 145 | + """Build an HTML author+metadata block from frontmatter dict.""" |
| 146 | + a = meta.get("authors", meta.get("author", "")) |
| 147 | + o = meta.get("orcid", "0009-0002-4317-5604") |
| 148 | + d = meta.get("doi", "") |
| 149 | + dt = meta.get("date", datetime.date.today().isoformat()) |
| 150 | + ab = meta.get("abstract", "") |
| 151 | + lines = [] |
| 152 | + if a: |
| 153 | + lines.append(f"<p><strong>Author:</strong> {a}</p>") |
| 154 | + lines.append( |
| 155 | + f'<p><strong>ORCID:</strong> <a href="https://orcid.org/{o}">{o}</a></p>' |
| 156 | + ) |
| 157 | + if d and "zenodo" in d: |
| 158 | + lines.append( |
| 159 | + f'<p><strong>DOI:</strong> <a href="https://doi.org/{d}">{d}</a></p>' |
| 160 | + ) |
| 161 | + lines.append(f"<p><strong>Date:</strong> {dt}</p>") |
| 162 | + if ab: |
| 163 | + lines.append( |
| 164 | + f'<p class="abstract-label">Abstract</p><p class="abstract-text">{ab}</p>' |
| 165 | + ) |
| 166 | + return f'<div class="author-block">\n{"".join(lines)}\n</div>' |
| 167 | + |
| 168 | + |
| 169 | +# --------------------------------------------------------------------------- |
| 170 | +# Pipeline steps |
| 171 | +# --------------------------------------------------------------------------- |
| 172 | + |
| 173 | +def build_html(input_path, output_html, css_content, use_math=True, title_override=None): |
| 174 | + """Convert Markdown to standalone HTML with embedded CSS and MathJax. |
| 175 | +
|
| 176 | + Args: |
| 177 | + input_path: Path to the Markdown (.md) source file. |
| 178 | + output_html: Path to write the generated HTML file. |
| 179 | + css_content: CSS string to embed (from file or default). |
| 180 | + use_math: Include MathJax CDN and config (default True). |
| 181 | + title_override: Override title from frontmatter (optional). |
| 182 | +
|
| 183 | + Returns: |
| 184 | + (title, html_size) tuple. |
| 185 | + """ |
| 186 | + import markdown |
| 187 | + |
| 188 | + with open(input_path, "r", encoding="utf-8") as f: |
| 189 | + raw = f.read() |
| 190 | + |
| 191 | + meta, body = parse_frontmatter(raw) |
| 192 | + author_html = build_author_block(meta) |
| 193 | + |
| 194 | + md = markdown.Markdown(extensions=["extra", "codehilite", "tables", "fenced_code"]) |
| 195 | + html_body = md.convert(body) |
| 196 | + |
| 197 | + # Strip internal metadata lines that should not appear in publication PDFs |
| 198 | + html_body = re.sub(r"<p><strong>Version:</strong>.*?</p>", "", html_body) |
| 199 | + html_body = re.sub(r"<p><strong>Status:</strong>.*?</p>", "", html_body) |
| 200 | + |
| 201 | + title = title_override or meta.get("title", "Untitled") |
| 202 | + |
| 203 | + # Build HTML -- use string concat to avoid JS/CSS brace conflicts with f-strings |
| 204 | + parts = [ |
| 205 | + '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>', |
| 206 | + title, |
| 207 | + "</title><style>", |
| 208 | + css_content, |
| 209 | + "</style>", |
| 210 | + ] |
| 211 | + if use_math: |
| 212 | + parts.append("<script>") |
| 213 | + parts.append(MATHJAX_SCRIPT) |
| 214 | + parts.append("</script>") |
| 215 | + parts.append(MATHJAX_CDN) |
| 216 | + parts.append("</head><body>") |
| 217 | + parts.append(f'<h1 class="title">{title}</h1>') |
| 218 | + parts.append(author_html) |
| 219 | + parts.append(html_body) |
| 220 | + parts.append("</body></html>") |
| 221 | + |
| 222 | + html = "".join(parts) |
| 223 | + |
| 224 | + with open(output_html, "w", encoding="utf-8") as f: |
| 225 | + f.write(html) |
| 226 | + |
| 227 | + size = os.path.getsize(output_html) |
| 228 | + return title, size |
| 229 | + |
| 230 | + |
| 231 | +def html_to_pdf(html_path, pdf_path, browser_path): |
| 232 | + """Convert HTML to PDF via headless browser (Edge/Chrome). |
| 233 | +
|
| 234 | + Args: |
| 235 | + html_path: Path to the HTML file to render. |
| 236 | + pdf_path: Path for the output PDF. |
| 237 | + browser_path: Full path to msedge.exe or chrome.exe. |
| 238 | +
|
| 239 | + Returns: |
| 240 | + PDF file size in bytes, or None on failure. |
| 241 | + """ |
| 242 | + abs_html = os.path.abspath(html_path).replace("\\", "/") |
| 243 | + abs_pdf = os.path.abspath(pdf_path) |
| 244 | + |
| 245 | + cmd = [ |
| 246 | + browser_path, |
| 247 | + "--headless", |
| 248 | + f"--print-to-pdf={abs_pdf}", |
| 249 | + "--no-pdf-header-footer", |
| 250 | + f"file:///{abs_html}", |
| 251 | + ] |
| 252 | + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) |
| 253 | + if result.returncode == 0 and os.path.exists(abs_pdf): |
| 254 | + return os.path.getsize(abs_pdf) |
| 255 | + return None |
| 256 | + |
| 257 | + |
| 258 | +# --------------------------------------------------------------------------- |
| 259 | +# CLI entry point |
| 260 | +# --------------------------------------------------------------------------- |
| 261 | + |
| 262 | +def main(): |
| 263 | + parser = argparse.ArgumentParser( |
| 264 | + description="Markdown -> HTML -> PDF pipeline (v3.0)", |
| 265 | + formatter_class=argparse.RawDescriptionHelpFormatter, |
| 266 | + epilog=""" |
| 267 | +Examples: |
| 268 | + python build_pdf.py --input paper.md |
| 269 | + python build_pdf.py --input paper.md --output out.pdf |
| 270 | + python build_pdf.py --input paper.md --style modern |
| 271 | + python build_pdf.py --input paper.md --style minimal --no-math |
| 272 | + python build_pdf.py --input paper.md --css custom.css |
| 273 | + python build_pdf.py --input paper.md --html-only |
| 274 | + python build_pdf.py --input paper.md --title "My Paper Title" |
| 275 | + """, |
| 276 | + ) |
| 277 | + parser.add_argument( |
| 278 | + "--input", "-i", required=True, help="Input Markdown file (.md)" |
| 279 | + ) |
| 280 | + parser.add_argument( |
| 281 | + "--output", "-o", default=None, help="Output PDF path (default: input name + .pdf)" |
| 282 | + ) |
| 283 | + parser.add_argument( |
| 284 | + "--style", choices=["academic", "modern", "minimal"], default=None, |
| 285 | + help="CSS style preset: academic (default), modern, or minimal" |
| 286 | + ) |
| 287 | + parser.add_argument( |
| 288 | + "--css", default=None, help="Custom CSS file (overrides --style)" |
| 289 | + ) |
| 290 | + parser.add_argument( |
| 291 | + "--title", default=None, help="Override title from YAML frontmatter" |
| 292 | + ) |
| 293 | + parser.add_argument( |
| 294 | + "--no-math", action="store_true", help="Skip MathJax/LaTeX rendering" |
| 295 | + ) |
| 296 | + parser.add_argument( |
| 297 | + "--html-only", action="store_true", help="Stop after HTML generation (no PDF)" |
| 298 | + ) |
| 299 | + parser.add_argument( |
| 300 | + "--working-dir", default=None, |
| 301 | + help="Directory for intermediate HTML file (default: output file directory)" |
| 302 | + ) |
| 303 | + |
| 304 | + args = parser.parse_args() |
| 305 | + |
| 306 | + # Validate input exists |
| 307 | + if not os.path.exists(args.input): |
| 308 | + print(f"ERROR: Input file not found: {args.input}", file=sys.stderr) |
| 309 | + sys.exit(1) |
| 310 | + |
| 311 | + # Determine output path |
| 312 | + if args.output: |
| 313 | + pdf_path = args.output |
| 314 | + else: |
| 315 | + base = os.path.splitext(os.path.basename(args.input))[0] |
| 316 | + pdf_path = os.path.join(os.path.dirname(args.input) or ".", f"{base}.pdf") |
| 317 | + |
| 318 | + pdf_dir = os.path.dirname(os.path.abspath(pdf_path)) |
| 319 | + work_dir = args.working_dir or pdf_dir |
| 320 | + |
| 321 | + # Intermediate HTML: alongside PDF with same base name |
| 322 | + html_name = os.path.splitext(os.path.basename(pdf_path))[0] + ".html" |
| 323 | + html_path = os.path.join(work_dir, html_name) |
| 324 | + |
| 325 | + print("=" * 60) |
| 326 | + print(" Markdown -> HTML -> PDF Pipeline v3.0") |
| 327 | + print("=" * 60) |
| 328 | + print(f" Input: {args.input}") |
| 329 | + print(f" Output: {pdf_path}") |
| 330 | + |
| 331 | + # Step 1: Load CSS (style preset, custom file, or embedded default) |
| 332 | + try: |
| 333 | + css_content, css_source = resolve_css(style=args.style, css_path=args.css) |
| 334 | + print(f" CSS: {css_source}") |
| 335 | + except FileNotFoundError as e: |
| 336 | + print(f"ERROR: {e}", file=sys.stderr) |
| 337 | + sys.exit(2) |
| 338 | + |
| 339 | + # Step 2: Markdown -> HTML |
| 340 | + try: |
| 341 | + title, html_size = build_html( |
| 342 | + args.input, html_path, css_content, |
| 343 | + use_math=not args.no_math, |
| 344 | + title_override=args.title, |
| 345 | + ) |
| 346 | + print(f" [1/2] HTML: {html_size:,} bytes -> {html_path}") |
| 347 | + except ImportError: |
| 348 | + print("ERROR: 'markdown' library not installed. Run: pip install markdown", file=sys.stderr) |
| 349 | + sys.exit(3) |
| 350 | + except Exception as e: |
| 351 | + print(f"ERROR: HTML build failed: {e}", file=sys.stderr) |
| 352 | + sys.exit(4) |
| 353 | + |
| 354 | + if args.html_only: |
| 355 | + print(f" [DONE] HTML only (--html-only). No PDF generated.") |
| 356 | + print(f" Title: {title}") |
| 357 | + return |
| 358 | + |
| 359 | + # Step 3: HTML -> PDF via headless browser |
| 360 | + browser = find_browser() |
| 361 | + if not browser: |
| 362 | + print(" [SKIP] No browser found for PDF step.") |
| 363 | + print(f" Open {html_path} manually -> Ctrl+P -> Save as PDF") |
| 364 | + sys.exit(5) |
| 365 | + |
| 366 | + try: |
| 367 | + pdf_size = html_to_pdf(html_path, pdf_path, browser) |
| 368 | + if pdf_size: |
| 369 | + print(f" [2/2] PDF: {pdf_size:,} bytes -> {pdf_path}") |
| 370 | + else: |
| 371 | + print(f" [FAIL] PDF step returned no output.") |
| 372 | + sys.exit(6) |
| 373 | + except subprocess.TimeoutExpired: |
| 374 | + print(f" [FAIL] Browser PDF conversion timed out (30s).", file=sys.stderr) |
| 375 | + sys.exit(7) |
| 376 | + except Exception as e: |
| 377 | + print(f" [FAIL] PDF conversion error: {e}", file=sys.stderr) |
| 378 | + sys.exit(8) |
| 379 | + |
| 380 | + print(f" >> PIPELINE COMPLETE <<") |
| 381 | + |
| 382 | + |
| 383 | +if __name__ == "__main__": |
| 384 | + main() |
0 commit comments