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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +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 redact_url_for_log, validate_url
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
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 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 )
57 try:
58 from weasyprint.urls import URLFetcherResponse as _Response
59 except ImportError: # WeasyPrint < 68
60 _Response = None
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")
72def weasyprint_available() -> bool:
73 """Return True when WeasyPrint and its system libraries can be imported.
75 Triggers the lazy import on first call.
76 """
77 _ensure_weasyprint()
78 return bool(WEASYPRINT_AVAILABLE)
81_WEASYPRINT_DOCS_URL = (
82 "https://doc.courtbouillon.org/weasyprint/stable/first_steps.html"
83)
86class UnsafePDFResourceURLError(ValueError):
87 """Raised to refuse a blocked resource fetch.
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 """
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
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"
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
124class _SafeUrlFetcher:
125 """WeasyPrint url_fetcher that blocks SSRF targets (GHSA-fj2m-qvh9-jq4q)."""
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
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)
168_safe_url_fetcher = _SafeUrlFetcher()
171def _empty_resource_for(url):
172 """The empty-resource response a skipped fetch hands WeasyPrint.
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.
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 }
221class _SkippingUrlFetcher:
222 """As `_safe_url_fetcher`, but any failure yields an empty resource.
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.
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.
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 """
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
269 def __init__(self):
270 self.skipped: list[str] = []
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)
298class MissingPDFDependencyError(RuntimeError):
299 """Raised when WeasyPrint system libraries are unavailable.
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 """
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 )
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}
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}
371table {
372 border-collapse: collapse;
373 width: 100%;
374 margin: 0.5em 0;
375}
377th, td {
378 border: 1px solid #ccc;
379 padding: 6px;
380 text-align: left;
381}
383th {
384 background-color: #f0f0f0;
385}
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; }
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}
403code {
404 padding: 1px 3px;
405}
407pre {
408 padding: 8px;
409 overflow-x: auto;
410}
412a {
413 color: #0066cc;
414 text-decoration: none;
415}
416"""
419class PDFService:
420 """Service for converting markdown to PDF using WeasyPrint."""
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 )
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.
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.
454 Returns:
455 PDF file as bytes
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 )
471 # url_fetcher blocks SSRF targets reachable via body/citation URLs.
472 html_doc = HTML(string=html_content, url_fetcher=_safe_url_fetcher)
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 )
528 # Generate PDF
529 pdf_bytes = self._render_pdf(html_content, html_doc, css_list)
531 logger.info(f"Generated PDF, size: {len(pdf_bytes)} bytes")
532 return pdf_bytes
534 except Exception:
535 logger.exception("Error generating PDF")
536 raise
538 def _render_pdf(self, html_content, html_doc, css_list) -> bytes:
539 """Render, and retry once with refusals skipped rather than raised.
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.
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()
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
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.
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 )
636 html_body = md.convert(markdown_content)
638 # Build complete HTML document
639 html_parts = ["<!DOCTYPE html><html><head>"]
640 html_parts.append('<meta charset="utf-8">')
642 if title:
643 html_parts.append(f"<title>{escape(title)}</title>")
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 )
651 html_parts.append("</head><body>")
653 # Add the markdown content directly without any extra title or metadata
654 html_parts.append(html_body)
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 """)
663 html_parts.append("</body></html>")
665 return "".join(html_parts)
668# Singleton instance
669_pdf_service = None
672def get_pdf_service() -> PDFService:
673 """Get or create the PDF service singleton.
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