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

1""" 

2PDF generation service using WeasyPrint. 

3 

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""" 

11 

12import io 

13import platform 

14from html import escape 

15from typing import Optional, Dict, Any 

16import markdown # type: ignore[import-untyped] 

17from loguru import logger 

18 

19from ...security import validate_url 

20 

21 

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 

36 

37 

38def _ensure_weasyprint() -> None: 

39 """Import WeasyPrint on first use, populating the module-level globals. 

40 

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 

52 

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") 

59 

60 

61def weasyprint_available() -> bool: 

62 """Return True when WeasyPrint and its system libraries can be imported. 

63 

64 Triggers the lazy import on first call. 

65 """ 

66 _ensure_weasyprint() 

67 return bool(WEASYPRINT_AVAILABLE) 

68 

69 

70_WEASYPRINT_DOCS_URL = ( 

71 "https://doc.courtbouillon.org/weasyprint/stable/first_steps.html" 

72) 

73 

74 

75class UnsafePDFResourceURLError(ValueError): 

76 """Subclasses ValueError so WeasyPrint skips the resource instead of aborting the render.""" 

77 

78 

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 

85 

86 

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) 

96 

97 

98class MissingPDFDependencyError(RuntimeError): 

99 """Raised when WeasyPrint system libraries are unavailable. 

100 

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 """ 

105 

106 

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 ) 

130 

131 

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} 

159 

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} 

170 

171table { 

172 border-collapse: collapse; 

173 width: 100%; 

174 margin: 0.5em 0; 

175} 

176 

177th, td { 

178 border: 1px solid #ccc; 

179 padding: 6px; 

180 text-align: left; 

181} 

182 

183th { 

184 background-color: #f0f0f0; 

185} 

186 

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; } 

193 

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} 

202 

203code { 

204 padding: 1px 3px; 

205} 

206 

207pre { 

208 padding: 8px; 

209 overflow-x: auto; 

210} 

211 

212a { 

213 color: #0066cc; 

214 text-decoration: none; 

215} 

216""" 

217 

218 

219class PDFService: 

220 """Service for converting markdown to PDF using WeasyPrint.""" 

221 

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) 

228 

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. 

238 

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. 

247 

248 Returns: 

249 PDF file as bytes 

250 

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 ) 

264 

265 # url_fetcher blocks SSRF targets reachable via body/citation URLs. 

266 html_doc = HTML(string=html_content, url_fetcher=_safe_url_fetcher) 

267 

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)) 

277 

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) 

282 

283 # Get the PDF bytes 

284 pdf_bytes = pdf_buffer.getvalue() 

285 pdf_buffer.close() 

286 

287 logger.info(f"Generated PDF, size: {len(pdf_bytes)} bytes") 

288 return pdf_bytes 

289 

290 except Exception: 

291 logger.exception("Error generating PDF") 

292 raise 

293 

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. 

302 

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 ) 

321 

322 html_body = md.convert(markdown_content) 

323 

324 # Build complete HTML document 

325 html_parts = ["<!DOCTYPE html><html><head>"] 

326 html_parts.append('<meta charset="utf-8">') 

327 

328 if title: 

329 html_parts.append(f"<title>{escape(title)}</title>") 

330 

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 ) 

336 

337 html_parts.append("</head><body>") 

338 

339 # Add the markdown content directly without any extra title or metadata 

340 html_parts.append(html_body) 

341 

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 """) 

348 

349 html_parts.append("</body></html>") 

350 

351 return "".join(html_parts) 

352 

353 

354# Singleton instance 

355_pdf_service = None 

356 

357 

358def get_pdf_service() -> PDFService: 

359 """Get or create the PDF service singleton. 

360 

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