Skip to content

Commit 394fa88

Browse files
committed
Add build_pdf.py for legacy paper PDF generation
1 parent 8a4acf5 commit 394fa88

1 file changed

Lines changed: 384 additions & 0 deletions

File tree

scripts/build_pdf.py

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
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

Comments
 (0)