Coverage for src/local_deep_research/web/services/pdf_service.py: 91%

146 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +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 redact_url_for_log, validate_url 

20 

21 

22# WeasyPrint pulls in Pango/Cairo/fontTools — a heavy, multi-second import. 

23# This module is imported eagerly at router import time 

24# (web/routers/research.py -> 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 global URLFetchingError, URLFetcherResponse 

48 if WEASYPRINT_AVAILABLE is not None: 

49 return 

50 try: 

51 from weasyprint import HTML as _HTML, CSS as _CSS 

52 from weasyprint.urls import ( 

53 URLFetcher, 

54 URLFetchingError as _URLFetchingError, 

55 ) 

56 

57 try: 

58 from weasyprint.urls import URLFetcherResponse as _Response 

59 except ImportError: # WeasyPrint < 68 

60 _Response = None 

61 

62 HTML, CSS = _HTML, _CSS 

63 URLFetchingError = _URLFetchingError 

64 URLFetcherResponse = _Response 

65 _URL_FETCHER = URLFetcher(allow_redirects=False) 

66 WEASYPRINT_AVAILABLE = True 

67 except (OSError, ImportError): 

68 WEASYPRINT_AVAILABLE = False 

69 logger.warning("WeasyPrint not available — PDF export will be disabled") 

70 

71 

72def weasyprint_available() -> bool: 

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

74 

75 Triggers the lazy import on first call. 

76 """ 

77 _ensure_weasyprint() 

78 return bool(WEASYPRINT_AVAILABLE) 

79 

80 

81_WEASYPRINT_DOCS_URL = ( 

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

83) 

84 

85 

86class UnsafePDFResourceURLError(ValueError): 

87 """Raised to refuse a blocked resource fetch. 

88 

89 WeasyPrint's ``fetch()`` (``weasyprint/urls.py``) wraps any 

90 ``Exception`` the ``url_fetcher`` raises as ``URLFetchingError``; 

91 whether that skips the resource or aborts the render depends on the 

92 call site. ``@import`` is fetched inside a try/except in 

93 ``css/__init__.py`` and is skipped; ``@color-profile`` is fetched 

94 outside one, which is why ``markdown_to_pdf`` catches the error 

95 around the custom stylesheet itself. Subclassing ``ValueError`` here 

96 is a conventional choice, not what triggers the skip. 

97 """ 

98 

99 

100# Populated by _ensure_weasyprint() on first PDF use. The URLFetcher preserves 

101# the allow_redirects=False posture that default_url_fetcher hard-coded. 

102# Redirects disabled keeps the SSRF guard airtight — validate_url only inspects 

103# the initial URL, so a 30x to a cloud metadata endpoint (see 

104# ssrf_validator.ALWAYS_BLOCKED_METADATA_IPS) would otherwise slip past. 

105_URL_FETCHER = None 

106# Populated alongside the fetcher; see `_render_pdf` for why it is needed. 

107URLFetchingError = None 

108# The response type WeasyPrint 68 introduced. 69+ accepts nothing else, so the 

109# empty resource is built through this whenever it exists; the dict shape is 

110# used only on WeasyPrint < 68 (see `_empty_resource_for`). 

111URLFetcherResponse = None 

112 

113# What the retry hands WeasyPrint in place of a resource it may not have. An 

114# empty body of an opaque type: every consumer treats it as nothing usable and 

115# moves on, which is the outcome the raising fetcher was already producing for 

116# every at-rule but one. 

117_EMPTY_RESOURCE_MIME_TYPE = "application/octet-stream" 

118 

119# How many (redacted) skipped URLs the retry's summary warning lists; the 

120# rest are counted. Every skipped URL also gets its own warning. 

121_SKIPPED_SUMMARY_LIMIT = 20 

122 

123 

124class _SafeUrlFetcher: 

125 """WeasyPrint url_fetcher that blocks SSRF targets (GHSA-fj2m-qvh9-jq4q).""" 

126 

127 # WeasyPrint 70's fetch() context manager (weasyprint/urls.py) reads 

128 # ``_fail_on_errors`` off the *url_fetcher itself* after catching an 

129 # exception it raised — a callable without that attribute turns every 

130 # blocked URL into an ``AttributeError`` mid-render. Carrying it as a 

131 # class attribute makes it part of this type's definition: every 

132 # instance gets it for free, with no separate import-time attachment 

133 # step (the bare-function equivalent, ``fn._fail_on_errors = ...`` 

134 # after ``def fn(url): ...``) to forget on a future rewrite. That is 

135 # not the same as being un-droppable — a rebind through a lambda, 

136 # ``functools.partial``, or an un-attribute-aware wrapper would still 

137 # lose it, and ``functools.wraps`` -- which copies a *function's* 

138 # ``__dict__`` and so kept the old bare-function attribute -- does 

139 # not copy this class attribute. The actual guard is 

140 # ``test_fetchers_expose_the_fail_on_errors_contract`` in 

141 # ``tests/web/services/test_pdf_service.py``, which asserts 

142 # ``getattr(fetcher, "_fail_on_errors", None) is False`` on the wired 

143 # module-level ``_safe_url_fetcher`` and on a ``_SkippingUrlFetcher()`` 

144 # instance as the retry builds one. ``False`` mirrors the 

145 # ``URLFetcher(fail_on_errors=False)`` default: the refusal surfaces 

146 # as ``URLFetchingError``, which WeasyPrint's callers warn-and-skip. 

147 # That keeps the 68.x ValueError-as-skip posture (pinned end-to-end 

148 # by ``test_render_succeeds_when_body_url_is_blocked``) while the 

149 # version floor is ~=70.0 for the GHSA-jf6q-chmf-3h3v 

150 # url_fetcher-bypass fix. 

151 _fail_on_errors = False 

152 

153 def __call__(self, url): 

154 if not validate_url(url): 

155 # A rejected URL is adversarial-shaped and may carry the 

156 # operator's real credentials (RFC 3986 userinfo) or secret 

157 # query tokens; log and raise only scheme://host:port, the 

158 # same discipline every ssrf_validator site follows. 

159 redacted = redact_url_for_log(url) 

160 logger.warning(f"Blocked unsafe URL in PDF rendering: {redacted}") 

161 raise UnsafePDFResourceURLError( 

162 f"Blocked unsafe URL in PDF rendering: {redacted}" 

163 ) 

164 _ensure_weasyprint() 

165 return _URL_FETCHER.fetch(url) 

166 

167 

168_safe_url_fetcher = _SafeUrlFetcher() 

169 

170 

171def _empty_resource_for(url): 

172 """The empty-resource response a skipped fetch hands WeasyPrint. 

173 

174 Built through ``URLFetcherResponse`` when WeasyPrint 68+ provides it. 

175 WeasyPrint 69+ accepts nothing else (``weasyprint.urls.fetch`` asserts 

176 ``isinstance(resource, URLFetcherResponse)``), so if the full 

177 construction is refused -- an upstream signature change -- the fallback 

178 is the bare ``URLFetcherResponse(url)`` (empty body), not a dict. The 

179 dict is reached only when ``URLFetcherResponse`` does not exist, i.e. 

180 WeasyPrint < 68, which still reads that shape. 

181 

182 The full construction (with a body and headers) is guarded so a 

183 *skipped* resource becomes a render failure only if 

184 ``URLFetcherResponse`` itself cannot be constructed at all -- the bare, 

185 no-body fallback below raises too. That bare call is deliberately left 

186 unguarded rather than caught with a dict as a last resort: on 

187 WeasyPrint 69+, ``weasyprint.urls.fetch()`` asserts 

188 ``isinstance(resource, URLFetcherResponse)`` *outside* the 

189 try/except that turns a raising ``url_fetcher`` into a skippable 

190 ``URLFetchingError``, so a dict would fail that assertion uncaught -- 

191 strictly worse than letting the original exception propagate, since 

192 some WeasyPrint call sites (e.g. ``@import``) catch ``URLFetchingError`` 

193 but none catch a bare ``AssertionError``. Letting the bare call's own 

194 exception propagate keeps it inside that try/except instead, so this 

195 already-vanishingly-rare double failure is handled exactly like any 

196 other resource failure (#6588): skipped wherever the caller already 

197 tolerates one. 

198 """ 

199 if URLFetcherResponse is not None: 199 ↛ 214line 199 didn't jump to line 214 because the condition on line 199 was always true

200 try: 

201 return URLFetcherResponse( 

202 url, 

203 body=b"", 

204 headers={"Content-Type": _EMPTY_RESOURCE_MIME_TYPE}, 

205 ) 

206 except Exception as exc: 

207 # Type name only: the message of an arbitrary exception is not 

208 # vetted and could echo the URL (with its userinfo) back. 

209 logger.warning( 

210 f"URLFetcherResponse construction failed " 

211 f"({type(exc).__name__}); retrying with an empty body only" 

212 ) 

213 return URLFetcherResponse(url) 

214 return { 

215 "string": b"", 

216 "mime_type": _EMPTY_RESOURCE_MIME_TYPE, 

217 "redirected_url": url, 

218 } 

219 

220 

221class _SkippingUrlFetcher: 

222 """As `_safe_url_fetcher`, but any failure yields an empty resource. 

223 

224 The fetcher the retry in `_render_pdf` renders with: it builds a fresh 

225 instance per retry, so ``skipped`` lists exactly the URLs that render 

226 dropped. No fetch happens for a URL the guard refuses -- every call goes 

227 through `_safe_url_fetcher`, so `validate_url` still runs first and 

228 still decides, and the SSRF posture is byte for byte the normal path's. 

229 The difference is only what WeasyPrint is told afterwards: nothing, 

230 rather than an exception it handles inconsistently. 

231 

232 ``__call__`` never raises for a resource failure, unless the WeasyPrint 

233 response class itself cannot be constructed for the empty-resource 

234 fallback (see `_empty_resource_for`) -- an upstream signature change, 

235 not a resource failure. The retry exists to skip whatever the first 

236 pass could not survive, and the failure set of 

237 a remote fetch is open-ended: besides the refusal 

238 (`UnsafePDFResourceURLError`) and ``OSError``/``ValueError``, a hostile 

239 server can make ``http.client`` raise ``HTTPException`` subclasses 

240 (``BadStatusLine``, ``LineTooLong``, too many headers, ``InvalidURL``) 

241 and a bad ``deflate`` body raises ``zlib.error``. An exception 

242 escaping here would abort the retried render -- on WeasyPrint 70 its 

243 ``fetch()`` would then also read ``_fail_on_errors`` off this object -- 

244 and turn a completed export into a 500 (#6588). A programming error is 

245 not hidden: its exception *type* is logged at warning level, which is 

246 what distinguishes an SSRF refusal from an unreachable resource from a 

247 bug. The exception *message* is never logged -- ``InvalidURL`` and 

248 friends embed the raw URL, userinfo included. 

249 

250 Scope: only failures raised *inside* the fetch are skipped. WeasyPrint 

251 70 returns an identity or ``gzip`` body as a lazy stream that its 

252 consumers read after the fetcher has returned, so an ``IncompleteRead``, 

253 a corrupt gzip stream or a read timeout part-way through the body is 

254 raised outside this object and can still fail the export. 

255 """ 

256 

257 # WeasyPrint 70's fetch() context manager reads ``_fail_on_errors`` off 

258 # the url_fetcher itself when it catches an exception from it. This 

259 # fetcher never raises, so that branch is dead today; the attribute is 

260 # kept so a future edit that makes ``__call__`` raise inherits the safe 

261 # warn-and-skip default instead of an ``AttributeError``, and so both 

262 # fetchers satisfy the same contract test 

263 # (``test_fetchers_expose_the_fail_on_errors_contract``). There is 

264 # deliberately no module-level instance of this class: ``skipped`` is 

265 # per-render state, and a shared instance would accumulate every 

266 # skipped URL across requests and users. 

267 _fail_on_errors = False 

268 

269 def __init__(self): 

270 self.skipped: list[str] = [] 

271 

272 def __call__(self, url): 

273 # Settle the lazy import first, so the empty resource below is built 

274 # as the installed WeasyPrint expects even when the guard refuses 

275 # the URL before `_safe_url_fetcher` would have imported it. 

276 _ensure_weasyprint() 

277 try: 

278 return _safe_url_fetcher(url) 

279 except Exception as exc: 

280 self.skipped.append(url) 

281 # Same redaction discipline as the fetcher's own block path: a 

282 # refused/unavailable URL is adversarial-shaped and may carry 

283 # credentials in userinfo or secret query tokens. Exception type 

284 # only (see the class docstring); never logger.exception here, 

285 # whose traceback would carry both the message and the raw URL. 

286 logger.warning( 

287 f"Skipping unavailable resource in PDF rendering " 

288 f"({type(exc).__name__}): {redact_url_for_log(url)}" 

289 ) 

290 # Built outside the except block so that, if even the bare 

291 # URLFetcherResponse construction were refused, the new exception 

292 # does not chain to the fetch error, whose message can carry the raw 

293 # URL into a later traceback. `_render_pdf` likewise runs the retry 

294 # outside its own except, so nothing chains to the first pass either. 

295 return _empty_resource_for(url) 

296 

297 

298class MissingPDFDependencyError(RuntimeError): 

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

300 

301 Distinct from generic RuntimeError so the web layer can surface this 

302 message to users without also exposing unrelated RuntimeErrors 

303 (e.g., pandoc subprocess stderr from ODT export). 

304 """ 

305 

306 

307def get_weasyprint_install_instructions() -> str: 

308 """Return platform-specific install instructions for WeasyPrint system deps.""" 

309 system = platform.system() 

310 if system == "Darwin": 310 ↛ 311line 310 didn't jump to line 311 because the condition on line 310 was never true

311 return ( 

312 "PDF export requires WeasyPrint system libraries (Pango, Cairo, GLib).\n" 

313 "Install with: brew install weasyprint\n" 

314 f"See: {_WEASYPRINT_DOCS_URL}#macos" 

315 ) 

316 if system == "Linux": 316 ↛ 321line 316 didn't jump to line 321 because the condition on line 316 was always true

317 return ( 

318 "PDF export requires WeasyPrint system libraries (Pango, Cairo, GLib).\n" 

319 f"See: {_WEASYPRINT_DOCS_URL}#linux" 

320 ) 

321 if system == "Windows": 

322 return ( 

323 "PDF export requires Pango system libraries.\n" 

324 f"See: {_WEASYPRINT_DOCS_URL}#windows" 

325 ) 

326 return ( 

327 "PDF export requires WeasyPrint system libraries (Pango, Cairo, GLib).\n" 

328 f"See: {_WEASYPRINT_DOCS_URL}" 

329 ) 

330 

331 

332# Default stylesheet for PDF export. Exposed as a module-level constant so 

333# tests can assert against the source string (WeasyPrint's CSS object does 

334# not retain its input). 

335# 

336# CJK families are listed as fallbacks so WeasyPrint substitutes a 

337# glyph-bearing font when the primary stack lacks coverage. Without 

338# this, Chinese/Japanese/Korean text disappears silently from the 

339# PDF even though it renders fine in the HTML view (issue #4055). 

340# Glyphs still require the corresponding system font (e.g. 

341# fonts-noto-cjk) to actually be installed. 

342# 

343# Emoji are deliberately NOT listed in these stacks. They render 

344# through Pango/fontconfig per-character fallback to whichever emoji 

345# font is installed (fonts-noto-color-emoji is bundled in the official 

346# Docker image; docs/faq.md covers other platforms). Listing an emoji 

347# family explicitly (as #4730 did) makes Pango route every digit 0-9 

348# plus '#' and '*' — codepoints that carry the Unicode Emoji property 

349# and have glyphs in Noto Color Emoji — to the emoji font, even though 

350# earlier families in the stack cover them. The result: all numbers in 

351# exported reports rendered as wide, square emoji glyphs ("2 0 2 6" 

352# instead of "2026"). Regression test: 

353# test_minimal_css_excludes_emoji_font_families. 

354MINIMAL_CSS = """ 

355@page { 

356 size: A4; 

357 margin: 1.5cm; 

358} 

359 

360body { 

361 font-family: Arial, "Noto Sans CJK SC", "Noto Sans CJK TC", 

362 "Noto Sans CJK JP", "Noto Sans CJK KR", "Noto Sans SC", 

363 "PingFang SC", "PingFang TC", "Hiragino Sans", 

364 "Hiragino Kaku Gothic ProN", "Apple SD Gothic Neo", 

365 "Microsoft YaHei", "Microsoft JhengHei", 

366 "Yu Gothic", "Malgun Gothic", "SimSun", sans-serif; 

367 font-size: 10pt; 

368 line-height: 1.4; 

369} 

370 

371table { 

372 border-collapse: collapse; 

373 width: 100%; 

374 margin: 0.5em 0; 

375} 

376 

377th, td { 

378 border: 1px solid #ccc; 

379 padding: 6px; 

380 text-align: left; 

381} 

382 

383th { 

384 background-color: #f0f0f0; 

385} 

386 

387h1 { font-size: 16pt; margin: 0.5em 0; } 

388h2 { font-size: 14pt; margin: 0.5em 0; } 

389h3 { font-size: 12pt; margin: 0.5em 0; } 

390h4 { font-size: 11pt; margin: 0.5em 0; font-weight: bold; } 

391h5 { font-size: 10pt; margin: 0.5em 0; font-weight: bold; } 

392h6 { font-size: 10pt; margin: 0.5em 0; } 

393 

394code, pre { 

395 font-family: monospace, "Noto Sans Mono CJK SC", 

396 "Noto Sans Mono CJK TC", "Noto Sans Mono CJK JP", 

397 "Noto Sans Mono CJK KR", "Noto Sans CJK SC", 

398 "PingFang SC", "Hiragino Sans", "Apple SD Gothic Neo", 

399 "Microsoft YaHei", "SimSun"; 

400 background-color: #f5f5f5; 

401} 

402 

403code { 

404 padding: 1px 3px; 

405} 

406 

407pre { 

408 padding: 8px; 

409 overflow-x: auto; 

410} 

411 

412a { 

413 color: #0066cc; 

414 text-decoration: none; 

415} 

416""" 

417 

418 

419class PDFService: 

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

421 

422 def __init__(self): 

423 """Initialize PDF service with minimal CSS for readability.""" 

424 # Defer-load WeasyPrint (lazy import) before using CSS, then 

425 # build the stylesheet from the module-level MINIMAL_CSS 

426 # constant. MINIMAL_CSS has no @import/@color-profile/url() 

427 # references to fetch, so url_fetcher is passed only for 

428 # consistency with every other CSS() construction in this 

429 # module -- not because anything here is reachable. 

430 _ensure_weasyprint() 

431 self.minimal_css = CSS( 

432 string=MINIMAL_CSS, url_fetcher=_safe_url_fetcher 

433 ) 

434 

435 def markdown_to_pdf( 

436 self, 

437 markdown_content: str, 

438 title: Optional[str] = None, 

439 metadata: Optional[Dict[str, Any]] = None, 

440 custom_css: Optional[str] = None, 

441 ) -> bytes: 

442 """ 

443 Convert markdown content to PDF. 

444 

445 Args: 

446 markdown_content: The markdown text to convert 

447 title: Optional title for the document 

448 metadata: Optional metadata dict (author, date, etc.) 

449 custom_css: Optional CSS string layered on top of the default 

450 stylesheet. Rules here win on equal specificity via the 

451 cascade, but the default's CJK font fallbacks and 

452 page setup are always applied. 

453 

454 Returns: 

455 PDF file as bytes 

456 

457 Note: 

458 WeasyPrint memory usage can spike with large documents. 

459 Production deployments should implement: 

460 - Memory limits (ulimit) 

461 - Timeouts (30-60 seconds) 

462 - Worker recycling after 100 requests 

463 """ 

464 _ensure_weasyprint() 

465 try: 

466 # Convert markdown to HTML 

467 html_content = self._markdown_to_html( 

468 markdown_content, title, metadata 

469 ) 

470 

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

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

473 

474 # Always apply the default stylesheet first, then layer any 

475 # caller-provided custom_css on top. WeasyPrint resolves 

476 # conflicts by cascade order, so later stylesheets win on 

477 # equal specificity — this preserves the CJK font fallbacks 

478 # in MINIMAL_CSS even when a caller supplies their own CSS, 

479 # while still letting them override any default. 

480 css_list = [self.minimal_css] 

481 if custom_css: 

482 # With no font_config passed to CSS() at this call 

483 # site, @import and @color-profile src are the 

484 # references WeasyPrint fetches while parsing a 

485 # stylesheet -- @font-face src would join them if a 

486 # FontConfiguration were ever supplied here. Parsing 

487 # this stylesheet must go through the same SSRF-guarded 

488 # fetcher as the HTML document. Property-level url() 

489 # values (e.g. background-image) are not parsed here -- 

490 # WeasyPrint resolves those later, at render time, 

491 # through html_doc's fetcher above, which is already 

492 # _safe_url_fetcher regardless of which stylesheet 

493 # declared them. A refused parse-time fetch (e.g. an 

494 # @color-profile src WeasyPrint resolves outside the 

495 # try/except that guards @import) surfaces here as 

496 # URLFetchingError; a relative @import WeasyPrint 

497 # cannot resolve without a base_url (since 

498 # CSS(string=...) has none) raises ValueError directly. 

499 # Either way: drop the custom stylesheet and keep 

500 # rendering with the default one rather than aborting 

501 # the whole export. 

502 try: 

503 css_list.append( 

504 CSS(string=custom_css, url_fetcher=_safe_url_fetcher) 

505 ) 

506 except (URLFetchingError, ValueError) as exc: 

507 # URLFetchingError wraps the fetcher's exception 

508 # without `from`, so __cause__ is always None; the 

509 # original exception (e.g. the 

510 # UnsafePDFResourceURLError a refusal raises) is on 

511 # __context__ instead. A plain ValueError (a 

512 # stylesheet WeasyPrint could not parse) carries 

513 # neither -- unless markdown_to_pdf() is itself 

514 # called from inside a caller's `except` block, in 

515 # which case Python's implicit chaining sets 

516 # __context__ to that in-flight exception rather 

517 # than the parse failure. The logged type is 

518 # therefore a best-effort diagnostic, not a 

519 # guarantee. 

520 cause = type( 

521 exc.__cause__ or exc.__context__ or exc 

522 ).__name__ 

523 logger.warning( 

524 "Custom stylesheet rejected ({}); rendering without it", 

525 cause, 

526 ) 

527 

528 # Generate PDF 

529 pdf_bytes = self._render_pdf(html_content, html_doc, css_list) 

530 

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

532 return pdf_bytes 

533 

534 except Exception: 

535 logger.exception("Error generating PDF") 

536 raise 

537 

538 def _render_pdf(self, html_content, html_doc, css_list) -> bytes: 

539 """Render, and retry once with refusals skipped rather than raised. 

540 

541 WeasyPrint does not treat a refused fetch the same way everywhere. An 

542 `@import` it cannot fetch is skipped and the render completes; an 

543 `@color-profile` it cannot fetch is not, because the `fetch()` call in 

544 `weasyprint/css/__init__.py` sits outside the `try`, so the 

545 `URLFetchingError` escapes `CSS.__init__`, escapes this method, and the 

546 export route answers a generic 500 (#6466). Report content is LLM- and 

547 web-derived, so a `<style>` block carrying that at-rule is reachable and 

548 python-markdown passes raw HTML straight through. 

549 

550 The retry restores the behaviour the other at-rules already have, 

551 rather than renaming the failure. Nothing about the SSRF guard moves: 

552 the retry's `_SkippingUrlFetcher` still calls `validate_url` first and 

553 still performs no fetch for a URL it refuses. Only the normal path 

554 raises, so an export that works today is rendered exactly once and 

555 unchanged. 

556 """ 

557 # The except below names the lazily-populated URLFetchingError 

558 # global; ensure the import has run before any except clause 

559 # evaluates it, or a render error surfaces as TypeError (catching 

560 # None) instead of the real failure (#6588). 

561 _ensure_weasyprint() 

562 

563 pdf_buffer = io.BytesIO() 

564 needs_retry = False 

565 try: 

566 html_doc.write_pdf(pdf_buffer, stylesheets=css_list) 

567 except URLFetchingError: 

568 # Only record the failure here. The retry runs *after* this 

569 # block: inside it, any exception the retried render raised 

570 # would chain (``__context__``) to this first-pass error, whose 

571 # WeasyPrint-built message embeds the fetch exception's text -- 

572 # e.g. ``InvalidURL: nonnumeric port: '<password>@host'`` for a 

573 # userinfo URL the guard allows -- and ``logger.exception`` in 

574 # the callers would print that chain. 

575 needs_retry = True 

576 if needs_retry: 

577 logger.warning( 

578 "PDF render aborted on an unavailable resource; retrying with " 

579 "unavailable resources skipped" 

580 ) 

581 pdf_buffer = io.BytesIO() 

582 # A fresh fetcher per retry: its `skipped` list is exactly what 

583 # this render dropped, surfaced below so a subtly incomplete 

584 # export is diagnosable instead of silent (#6588). 

585 skipping_fetcher = _SkippingUrlFetcher() 

586 HTML(string=html_content, url_fetcher=skipping_fetcher).write_pdf( 

587 pdf_buffer, stylesheets=css_list 

588 ) 

589 skipped = skipping_fetcher.skipped 

590 if skipped: 590 ↛ 604line 590 didn't jump to line 604 because the condition on line 590 was always true

591 # Capped: a report with thousands of refused resources would 

592 # otherwise put all of them on one line (each one already 

593 # has its own per-resource warning). 

594 shown = [ 

595 redact_url_for_log(u) 

596 for u in skipped[:_SKIPPED_SUMMARY_LIMIT] 

597 ] 

598 more = len(skipped) - len(shown) 

599 suffix = f" (+{more} more)" if more else "" 

600 logger.warning( 

601 f"PDF render completed with {len(skipped)} skipped " 

602 f"resource(s): {shown}{suffix}" 

603 ) 

604 pdf_bytes = pdf_buffer.getvalue() 

605 pdf_buffer.close() 

606 return pdf_bytes 

607 

608 def _markdown_to_html( 

609 self, 

610 markdown_content: str, 

611 title: Optional[str] = None, 

612 metadata: Optional[Dict[str, Any]] = None, 

613 ) -> str: 

614 """ 

615 Convert markdown to HTML with proper structure. 

616 

617 Uses Python-Markdown with extensions for: 

618 - Tables 

619 - Fenced code blocks 

620 - Table of contents 

621 - Footnotes 

622 """ 

623 # Parse markdown with extensions 

624 md = markdown.Markdown( 

625 extensions=[ 

626 "tables", 

627 "fenced_code", 

628 "footnotes", 

629 "toc", 

630 "nl2br", # Convert newlines to <br> 

631 "sane_lists", 

632 "meta", 

633 ] 

634 ) 

635 

636 html_body = md.convert(markdown_content) 

637 

638 # Build complete HTML document 

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

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

641 

642 if title: 

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

644 

645 if metadata: 

646 for key, value in metadata.items(): 

647 html_parts.append( 

648 f'<meta name="{escape(str(key))}" content="{escape(str(value))}">' 

649 ) 

650 

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

652 

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

654 html_parts.append(html_body) 

655 

656 # Add footer with LDR attribution 

657 html_parts.append(""" 

658 <div style="margin-top: 2em; padding-top: 1em; border-top: 1px solid #ddd; font-size: 9pt; color: #666; text-align: center;"> 

659 Generated by <a href="https://github.com/LearningCircuit/local-deep-research" style="color: #0066cc;">LDR - Local Deep Research</a> | Open Source AI Research Assistant 

660 </div> 

661 """) 

662 

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

664 

665 return "".join(html_parts) 

666 

667 

668# Singleton instance 

669_pdf_service = None 

670 

671 

672def get_pdf_service() -> PDFService: 

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

674 

675 Raises: 

676 MissingPDFDependencyError: If WeasyPrint system libraries are not 

677 available, with platform-specific installation instructions. 

678 """ 

679 _ensure_weasyprint() 

680 if not WEASYPRINT_AVAILABLE: 

681 raise MissingPDFDependencyError(get_weasyprint_install_instructions()) 

682 global _pdf_service 

683 if _pdf_service is None: 

684 _pdf_service = PDFService() 

685 return _pdf_service