mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-21 07:41:28 +02:00
Audited every path an image can reach the renderer by, on the principle that uploads are the only source. Four of the five were already closed, and the one that mattered most turned out to be closed for a reason worth recording: an SVG logo cannot beacon or read a file, because references made from inside the SVG go through the same fetcher, and WeasyPrint's SVG parser does not resolve XML entities. An <image href> pointing at a listener got zero hits; an XXE entity leaked nothing. So SVG stays. Rasterising a logo to avoid a risk that is already closed would cost real print quality for nothing. The gap was not a URL at all. WeasyPrint passes a PNG stream straight into the PDF, so the output stays small, but it still decodes the image: inside the 500 KB logo limit, a 182 KB file decodes to 64M pixels and cost a measured +594 MB of RSS, and Pillow's 89M default only warns below twice the limit. Across eight workers that is several gigabytes from a handful of small uploads. The render now caps decoded pixels, which takes that same payload to +0 MB, and a dropped image is surfaced as a report warning instead of vanishing. Two things this exposed about the previous commit. The fetcher was built on default_url_fetcher, deprecated in WeasyPrint 69; it is now a URLFetcher subclass pinned to the data and file protocols. And the first attempt at the pixel cap silently missed, because the edit was written against a function ruff had already reformatted: the guard existed, nothing called it, and the probe reported success by rendering the bomb. Measuring the test harness separately from the renderer is what caught it. cover_image was a validated field nothing rendered. It is wired now, uploaded and embedded like the logo, so it is no longer dead configuration. Verified after the rewrite: 11/11 on the fetcher matrix, a 124-page report with the uploaded typeface still embedded, and screenshots still embedding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017f2Mf2HGwFaTRnui4UxpRd
70 lines
2.0 KiB
Python
70 lines
2.0 KiB
Python
"""WeasyPrint is imported here and nowhere else, so the api can read the catalog without it."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass, field
|
|
|
|
from shared.definitions.reports import MAX_IMAGE_PIXELS
|
|
|
|
|
|
@dataclass
|
|
class PdfResult:
|
|
data: bytes
|
|
pages: int
|
|
warnings: list[str] = field(default_factory=list)
|
|
|
|
|
|
# a warning about an embedded image must not repeat the whole image
|
|
_DATA_URI = re.compile(r"data:([\w./+-]+);base64,[A-Za-z0-9+/=]+")
|
|
|
|
|
|
class _Collector(logging.Handler):
|
|
"""WeasyPrint reports a dropped image or a missing face here; a report should say so."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__(level=logging.WARNING)
|
|
self.messages: list[str] = []
|
|
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
text = _DATA_URI.sub(
|
|
lambda m: f"{m.group(1)} data, {len(m.group(0)) // 1024} KB",
|
|
record.getMessage(),
|
|
)
|
|
text = text[:300]
|
|
if text not in self.messages:
|
|
self.messages.append(text)
|
|
|
|
|
|
@contextmanager
|
|
def render_limits():
|
|
"""A small image may decode to a huge canvas, so the ceiling is on pixels, not bytes."""
|
|
from PIL import Image # noqa: PLC0415
|
|
|
|
previous = Image.MAX_IMAGE_PIXELS
|
|
Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS
|
|
weasy = logging.getLogger("weasyprint")
|
|
collector = _Collector()
|
|
weasy.addHandler(collector)
|
|
try:
|
|
yield collector
|
|
finally:
|
|
weasy.removeHandler(collector)
|
|
Image.MAX_IMAGE_PIXELS = previous
|
|
|
|
|
|
def to_pdf(html: str, *, base_url: str) -> PdfResult:
|
|
from weasyprint import HTML # noqa: PLC0415
|
|
|
|
from reports.render.fetcher import build_fetcher # noqa: PLC0415
|
|
|
|
with render_limits() as collected:
|
|
document = HTML(
|
|
string=html, base_url=base_url, url_fetcher=build_fetcher()
|
|
).render()
|
|
data = document.write_pdf()
|
|
pages = len(document.pages)
|
|
return PdfResult(data=data, pages=pages, warnings=list(collected.messages))
|