Coverage for src/local_deep_research/web/services/pdf_service.py: 90%
89 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
1"""
2PDF generation service using WeasyPrint.
4Based on deep research findings, WeasyPrint is the optimal choice for
5production Flask applications due to:
6- Pure Python (no external binaries except Pango)
7- Modern CSS3 support
8- Active maintenance (v66.0 as of July 2025)
9- Good paged media features
10"""
12import io
13import platform
14from html import escape
15from typing import Optional, Dict, Any
16import markdown # type: ignore[import-untyped]
17from loguru import logger
19from ...security import validate_url
22# WeasyPrint pulls in Pango/Cairo/fontTools — a heavy, multi-second import.
23# This module is imported eagerly during blueprint registration
24# (research_routes -> pdf_service), so importing WeasyPrint at module load
25# blocked web-server cold start by ~20s on CPU-constrained CI runners
26# (issue #4431: "cold heavy-import under CI 2-core starvation"). PDF export is
27# a rare, on-demand operation, so the import is deferred to first use.
28#
29# These names stay module-level (filled in by _ensure_weasyprint) so the
30# render path — and the tests that patch them — keep working as before; they
31# are just populated lazily instead of at import time.
32HTML = None # type: ignore[assignment,misc]
33CSS = None # type: ignore[assignment,misc]
34# None until the import is attempted; True/False once determined.
35WEASYPRINT_AVAILABLE: Optional[bool] = None
38def _ensure_weasyprint() -> None:
39 """Import WeasyPrint on first use, populating the module-level globals.
41 Idempotent: the import is attempted once and the outcome cached in
42 ``WEASYPRINT_AVAILABLE``. Handles the same ``(OSError, ImportError)``
43 failure modes (e.g. missing Pango/Cairo system libraries) the original
44 module-level guard did.
45 """
46 global HTML, CSS, WEASYPRINT_AVAILABLE, _URL_FETCHER
47 if WEASYPRINT_AVAILABLE is not None:
48 return
49 try:
50 from weasyprint import HTML as _HTML, CSS as _CSS
51 from weasyprint.urls import URLFetcher
53 HTML, CSS = _HTML, _CSS
54 _URL_FETCHER = URLFetcher(allow_redirects=False)
55 WEASYPRINT_AVAILABLE = True
56 except (OSError, ImportError):
57 WEASYPRINT_AVAILABLE = False
58 logger.warning("WeasyPrint not available — PDF export will be disabled")
61def weasyprint_available() -> bool:
62 """Return True when WeasyPrint and its system libraries can be imported.
64 Triggers the lazy import on first call.
65 """
66 _ensure_weasyprint()
67 return bool(WEASYPRINT_AVAILABLE)
70_WEASYPRINT_DOCS_URL = (
71 "https://doc.courtbouillon.org/weasyprint/stable/first_steps.html"
72)
75class UnsafePDFResourceURLError(ValueError):
76 """Subclasses ValueError so WeasyPrint skips the resource instead of aborting the render."""
79# Populated by _ensure_weasyprint() on first PDF use. The URLFetcher preserves
80# the allow_redirects=False posture that default_url_fetcher hard-coded.
81# Redirects disabled keeps the SSRF guard airtight — validate_url only inspects
82# the initial URL, so a 30x to a cloud metadata endpoint (see
83# ssrf_validator.ALWAYS_BLOCKED_METADATA_IPS) would otherwise slip past.
84_URL_FETCHER = None
87def _safe_url_fetcher(url):
88 """WeasyPrint url_fetcher that blocks SSRF targets (GHSA-fj2m-qvh9-jq4q)."""
89 if not validate_url(url):
90 logger.warning(f"Blocked unsafe URL in PDF rendering: {url}")
91 raise UnsafePDFResourceURLError(
92 f"Blocked unsafe URL in PDF rendering: {url}"
93 )
94 _ensure_weasyprint()
95 return _URL_FETCHER.fetch(url)
98class MissingPDFDependencyError(RuntimeError):
99 """Raised when WeasyPrint system libraries are unavailable.
101 Distinct from generic RuntimeError so the web layer can surface this
102 message to users without also exposing unrelated RuntimeErrors
103 (e.g., pandoc subprocess stderr from ODT export).
104 """
107def get_weasyprint_install_instructions() -> str:
108 """Return platform-specific install instructions for WeasyPrint system deps."""
109 system = platform.system()
110 if system == "Darwin": 110 ↛ 111line 110 didn't jump to line 111 because the condition on line 110 was never true
111 return (
112 "PDF export requires WeasyPrint system libraries (Pango, Cairo, GLib).\n"
113 "Install with: brew install weasyprint\n"
114 f"See: {_WEASYPRINT_DOCS_URL}#macos"
115 )
116 if system == "Linux": 116 ↛ 121line 116 didn't jump to line 121 because the condition on line 116 was always true
117 return (
118 "PDF export requires WeasyPrint system libraries (Pango, Cairo, GLib).\n"
119 f"See: {_WEASYPRINT_DOCS_URL}#linux"
120 )
121 if system == "Windows":
122 return (
123 "PDF export requires Pango system libraries.\n"
124 f"See: {_WEASYPRINT_DOCS_URL}#windows"
125 )
126 return (
127 "PDF export requires WeasyPrint system libraries (Pango, Cairo, GLib).\n"
128 f"See: {_WEASYPRINT_DOCS_URL}"
129 )
132# Default stylesheet for PDF export. Exposed as a module-level constant so
133# tests can assert against the source string (WeasyPrint's CSS object does
134# not retain its input).
135#
136# CJK families are listed as fallbacks so WeasyPrint substitutes a
137# glyph-bearing font when the primary stack lacks coverage. Without
138# this, Chinese/Japanese/Korean text disappears silently from the
139# PDF even though it renders fine in the HTML view (issue #4055).
140# Glyphs still require the corresponding system font (e.g.
141# fonts-noto-cjk) to actually be installed.
142#
143# Emoji are deliberately NOT listed in these stacks. They render
144# through Pango/fontconfig per-character fallback to whichever emoji
145# font is installed (fonts-noto-color-emoji is bundled in the official
146# Docker image; docs/faq.md covers other platforms). Listing an emoji
147# family explicitly (as #4730 did) makes Pango route every digit 0-9
148# plus '#' and '*' — codepoints that carry the Unicode Emoji property
149# and have glyphs in Noto Color Emoji — to the emoji font, even though
150# earlier families in the stack cover them. The result: all numbers in
151# exported reports rendered as wide, square emoji glyphs ("2 0 2 6"
152# instead of "2026"). Regression test:
153# test_minimal_css_excludes_emoji_font_families.
154MINIMAL_CSS = """
155@page {
156 size: A4;
157 margin: 1.5cm;
158}
160body {
161 font-family: Arial, "Noto Sans CJK SC", "Noto Sans CJK TC",
162 "Noto Sans CJK JP", "Noto Sans CJK KR", "Noto Sans SC",
163 "PingFang SC", "PingFang TC", "Hiragino Sans",
164 "Hiragino Kaku Gothic ProN", "Apple SD Gothic Neo",
165 "Microsoft YaHei", "Microsoft JhengHei",
166 "Yu Gothic", "Malgun Gothic", "SimSun", sans-serif;
167 font-size: 10pt;
168 line-height: 1.4;
169}
171table {
172 border-collapse: collapse;
173 width: 100%;
174 margin: 0.5em 0;
175}
177th, td {
178 border: 1px solid #ccc;
179 padding: 6px;
180 text-align: left;
181}
183th {
184 background-color: #f0f0f0;
185}
187h1 { font-size: 16pt; margin: 0.5em 0; }
188h2 { font-size: 14pt; margin: 0.5em 0; }
189h3 { font-size: 12pt; margin: 0.5em 0; }
190h4 { font-size: 11pt; margin: 0.5em 0; font-weight: bold; }
191h5 { font-size: 10pt; margin: 0.5em 0; font-weight: bold; }
192h6 { font-size: 10pt; margin: 0.5em 0; }
194code, pre {
195 font-family: monospace, "Noto Sans Mono CJK SC",
196 "Noto Sans Mono CJK TC", "Noto Sans Mono CJK JP",
197 "Noto Sans Mono CJK KR", "Noto Sans CJK SC",
198 "PingFang SC", "Hiragino Sans", "Apple SD Gothic Neo",
199 "Microsoft YaHei", "SimSun";
200 background-color: #f5f5f5;
201}
203code {
204 padding: 1px 3px;
205}
207pre {
208 padding: 8px;
209 overflow-x: auto;
210}
212a {
213 color: #0066cc;
214 text-decoration: none;
215}
216"""
219class PDFService:
220 """Service for converting markdown to PDF using WeasyPrint."""
222 def __init__(self):
223 """Initialize PDF service with minimal CSS for readability."""
224 # Defer-load WeasyPrint (lazy import) before using CSS, then
225 # build the stylesheet from the module-level MINIMAL_CSS constant.
226 _ensure_weasyprint()
227 self.minimal_css = CSS(string=MINIMAL_CSS)
229 def markdown_to_pdf(
230 self,
231 markdown_content: str,
232 title: Optional[str] = None,
233 metadata: Optional[Dict[str, Any]] = None,
234 custom_css: Optional[str] = None,
235 ) -> bytes:
236 """
237 Convert markdown content to PDF.
239 Args:
240 markdown_content: The markdown text to convert
241 title: Optional title for the document
242 metadata: Optional metadata dict (author, date, etc.)
243 custom_css: Optional CSS string layered on top of the default
244 stylesheet. Rules here win on equal specificity via the
245 cascade, but the default's CJK font fallbacks and
246 page setup are always applied.
248 Returns:
249 PDF file as bytes
251 Note:
252 WeasyPrint memory usage can spike with large documents.
253 Production deployments should implement:
254 - Memory limits (ulimit)
255 - Timeouts (30-60 seconds)
256 - Worker recycling after 100 requests
257 """
258 _ensure_weasyprint()
259 try:
260 # Convert markdown to HTML
261 html_content = self._markdown_to_html(
262 markdown_content, title, metadata
263 )
265 # url_fetcher blocks SSRF targets reachable via body/citation URLs.
266 html_doc = HTML(string=html_content, url_fetcher=_safe_url_fetcher)
268 # Always apply the default stylesheet first, then layer any
269 # caller-provided custom_css on top. WeasyPrint resolves
270 # conflicts by cascade order, so later stylesheets win on
271 # equal specificity — this preserves the CJK font fallbacks
272 # in MINIMAL_CSS even when a caller supplies their own CSS,
273 # while still letting them override any default.
274 css_list = [self.minimal_css]
275 if custom_css:
276 css_list.append(CSS(string=custom_css))
278 # Generate PDF
279 # Use BytesIO to get bytes instead of writing to file
280 pdf_buffer = io.BytesIO()
281 html_doc.write_pdf(pdf_buffer, stylesheets=css_list)
283 # Get the PDF bytes
284 pdf_bytes = pdf_buffer.getvalue()
285 pdf_buffer.close()
287 logger.info(f"Generated PDF, size: {len(pdf_bytes)} bytes")
288 return pdf_bytes
290 except Exception:
291 logger.exception("Error generating PDF")
292 raise
294 def _markdown_to_html(
295 self,
296 markdown_content: str,
297 title: Optional[str] = None,
298 metadata: Optional[Dict[str, Any]] = None,
299 ) -> str:
300 """
301 Convert markdown to HTML with proper structure.
303 Uses Python-Markdown with extensions for:
304 - Tables
305 - Fenced code blocks
306 - Table of contents
307 - Footnotes
308 """
309 # Parse markdown with extensions
310 md = markdown.Markdown(
311 extensions=[
312 "tables",
313 "fenced_code",
314 "footnotes",
315 "toc",
316 "nl2br", # Convert newlines to <br>
317 "sane_lists",
318 "meta",
319 ]
320 )
322 html_body = md.convert(markdown_content)
324 # Build complete HTML document
325 html_parts = ["<!DOCTYPE html><html><head>"]
326 html_parts.append('<meta charset="utf-8">')
328 if title:
329 html_parts.append(f"<title>{escape(title)}</title>")
331 if metadata:
332 for key, value in metadata.items():
333 html_parts.append(
334 f'<meta name="{escape(str(key))}" content="{escape(str(value))}">'
335 )
337 html_parts.append("</head><body>")
339 # Add the markdown content directly without any extra title or metadata
340 html_parts.append(html_body)
342 # Add footer with LDR attribution
343 html_parts.append("""
344 <div style="margin-top: 2em; padding-top: 1em; border-top: 1px solid #ddd; font-size: 9pt; color: #666; text-align: center;">
345 Generated by <a href="https://github.com/LearningCircuit/local-deep-research" style="color: #0066cc;">LDR - Local Deep Research</a> | Open Source AI Research Assistant
346 </div>
347 """)
349 html_parts.append("</body></html>")
351 return "".join(html_parts)
354# Singleton instance
355_pdf_service = None
358def get_pdf_service() -> PDFService:
359 """Get or create the PDF service singleton.
361 Raises:
362 MissingPDFDependencyError: If WeasyPrint system libraries are not
363 available, with platform-specific installation instructions.
364 """
365 _ensure_weasyprint()
366 if not WEASYPRINT_AVAILABLE:
367 raise MissingPDFDependencyError(get_weasyprint_install_instructions())
368 global _pdf_service
369 if _pdf_service is None:
370 _pdf_service = PDFService()
371 return _pdf_service