Implementace prevodu HTML na PDF

Sluzba prijme adresu HTML dokumentu nebo HTML v tele requestu a vrati PDF.
Navrzena pro dokumenty o stovkach az tisicich stranek.

Rendering:
- WeasyPrint jako vychozi engine, spravne CSS Paged Media, nizka pametova
  narocnost, bez JavaScriptu
- Chromium pres Playwright pro dokumenty dokreslovane skripty
- rezim auto s detekci skriptu a fallbackem pri selhani WeasyPrintu

Velke dokumenty:
- deleni na casti na strukturalnich hranicich, rez nikdy uvnitr tabulky
  nebo odstavce
- dvoupruchodovy render obsahu se skutecnymi cisly stranek, pozice nadpisu
  se ctou z kotev hlasenych u kazde stranky
- cislovani stranek bud pres CSS countery, nebo pres cislovaci vrstvu
  nastampovanou na hotove PDF, rozmer stranky se cte z vysledneho souboru
- Chromium se restartuje po N jobech, nikdy vsak behem beziciho renderu

API:
- POST /convert synchronne, POST /jobs asynchronne se sledovanim stavu,
  stahovanim vysledku, rusenim a volitelnym callbackem
- GET /health s overenim dostupnosti obou enginu a stavem fronty
- OpenAPI respektuje prefix reverse proxy pres root_path

Bezpecnost a provoz:
- SSRF kontrola po DNS resolvu, na kazdem presmerovani a u vsech pozadavku
  prohlizece
- nedostupne assety render nezastavi, ale hlasi se v odpovedi i v logu
- fronta s omezenym poctem workeru, rozpracovane joby se pri ukonceni
  oznaci jako failed, nezmizi potichu
- strukturovane JSON logovani s job_id
- vsechny limity vypnute ve vychozim stavu

Dockerfile je dvoufazovy, obsahuje zavislosti WeasyPrintu, Chromium
a fonty s ceskou diakritikou.

Autentizace zamerne neni implementovana, zpusob predavani neni domluveny.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-27 14:50:10 +02:00
co-authored by Claude Opus 5
parent e3cc8f418b
commit 156289fe2d
48 changed files with 4043 additions and 24 deletions
+142
View File
@@ -0,0 +1,142 @@
"""Splitting of a large document into renderable chunks.
A thousand page document rendered in one pass keeps the whole page tree in
memory. Splitting it on structural boundaries keeps memory flat at the cost of
having to reassemble the page numbering afterwards.
Cuts are only ever made between direct children of the container element, so a
table or a paragraph is never torn in half.
"""
from __future__ import annotations
import logging
from lxml import html as lxml_html
from .document import SourceDocument
logger = logging.getLogger(__name__)
SPLIT_TAGS = {"section", "article", "h1"}
BREAK_KEYWORDS = ("page-break-before", "break-before")
# Rough page size heuristic. The real page count is only known after rendering,
# so this only has to be good enough to keep chunks roughly even.
CHARS_PER_PAGE = 2200
IMAGE_CHAR_WEIGHT = 900
TABLE_ROW_CHAR_WEIGHT = 120
def is_split_point(element) -> bool:
if not isinstance(element.tag, str):
return False
if element.tag.lower() in SPLIT_TAGS:
return True
if element.get("data-chunk") is not None:
return True
style = (element.get("style") or "").lower()
return any(keyword in style for keyword in BREAK_KEYWORDS)
def estimate_pages(element) -> float:
text_length = len(element.text_content())
text_length += IMAGE_CHAR_WEIGHT * len(element.findall(".//img"))
text_length += TABLE_ROW_CHAR_WEIGHT * len(element.findall(".//tr"))
return max(text_length / CHARS_PER_PAGE, 0.01)
def split_document(document: SourceDocument, pages_per_chunk: int) -> list[str]:
"""Return one complete HTML document per chunk.
Falls back to a single chunk when the document has no usable split points.
"""
container = document.container
children = [child for child in container if isinstance(child.tag, str)]
split_indexes = [index for index, child in enumerate(children) if is_split_point(child)]
if len(split_indexes) < 2:
logger.info(
"Document has no usable split points, rendering in one pass",
extra={"split_points": len(split_indexes), "children": len(children)},
)
return [document.to_html()]
groups = _group_children(children, set(split_indexes), pages_per_chunk)
if len(groups) < 2:
logger.info("Document fits into a single chunk", extra={"children": len(children)})
return [document.to_html()]
prefix, suffix = _skeleton(document, container)
child_html = [lxml_html.tostring(child, encoding="unicode") for child in children]
chunks = ["".join((prefix, *(child_html[index] for index in group), suffix)) for group in groups]
logger.info(
"Document split into chunks",
extra={"chunks": len(chunks), "children": len(children), "pages_per_chunk": pages_per_chunk},
)
return chunks
def _group_children(children, split_indexes: set[int], pages_per_chunk: int) -> list[list[int]]:
groups: list[list[int]] = []
current: list[int] = []
current_pages = 0.0
for index, child in enumerate(children):
starts_chunk = index in split_indexes and current and current_pages >= pages_per_chunk
if starts_chunk:
groups.append(current)
current = []
current_pages = 0.0
current.append(index)
current_pages += estimate_pages(child)
if current:
groups.append(current)
return groups
def _skeleton(document: SourceDocument, container) -> tuple[str, str]:
"""Opening and closing markup shared by every chunk.
The whole ancestor chain is recreated with its attributes so CSS selectors
that depend on it keep matching inside a chunk.
"""
head_html = lxml_html.tostring(document.head, encoding="unicode")
chain = []
node = container
while node is not None and node is not document.tree:
chain.append(node)
node = node.getparent()
chain.reverse()
opens = [_open_tag(document.tree)]
opens.append(head_html)
closes = ["</html>"]
for node in chain:
opens.append(_open_tag(node))
closes.append(f"</{node.tag}>")
closes.reverse()
return "<!DOCTYPE html>\n" + "".join(opens), "".join(closes)
def _open_tag(element) -> str:
attributes = "".join(
f' {name}="{_escape(value)}"' for name, value in element.attrib.items()
)
return f"<{element.tag}{attributes}>"
def _escape(value: str) -> str:
return (
value.replace("&", "&amp;")
.replace('"', "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)