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
+170
View File
@@ -0,0 +1,170 @@
"""Request and response models.
Only `source` is mandatory. Everything else has a working default so that
{"source": {"url": "..."}} produces a usable PDF.
"""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, model_validator
from .config import get_settings
EngineName = Literal["auto", "weasyprint", "chromium"]
PagePosition = Literal[
"top-left", "top-center", "top-right",
"bottom-left", "bottom-center", "bottom-right",
]
JobStatus = Literal["queued", "running", "done", "failed", "cancelled", "expired"]
class Source(BaseModel):
url: str | None = Field(default=None, description="Adresa HTML dokumentu ke konverzi.")
html: str | None = Field(default=None, description="HTML poslane primo v tele requestu.")
base_url: str | None = Field(
default=None,
description="Zaklad pro relativni cesty. Pouziva se hlavne spolu s polem html.",
)
@model_validator(mode="after")
def exactly_one_source(self) -> "Source":
if bool(self.url) == bool(self.html):
raise ValueError("Vyplnte prave jedno z poli source.url a source.html.")
return self
class Margin(BaseModel):
top: str = "20mm"
right: str = "15mm"
bottom: str = "20mm"
left: str = "15mm"
class PageSettings(BaseModel):
format: str = Field(default="A4", description="Nazev formatu (A4, A5, Letter) nebo rozmer 210mm 297mm.")
orientation: Literal["portrait", "landscape"] = "portrait"
margin: Margin = Field(default_factory=Margin)
class PageNumbers(BaseModel):
enabled: bool = False
format: str = Field(default="{page} / {pages}", description="Zastupne symboly {page} a {pages}.")
position: PagePosition = "bottom-center"
mode: Literal["auto", "css", "overlay"] = Field(
default="auto",
description=(
"auto zvoli css u necleneneho dokumentu a overlay u clenene nebo u Chromia. "
"css pouziva CSS countery, overlay dopisuje cisla do hotoveho PDF."
),
)
start_at: int = 1
class TocSettings(BaseModel):
enabled: bool = False
depth: int = Field(default=3, ge=1, le=6)
title: str = "Obsah"
class AssetSettings(BaseModel):
allow_remote: bool = True
timeout_seconds: int = Field(default_factory=lambda: get_settings().asset_timeout_seconds, ge=1)
class ChunkSettings(BaseModel):
enabled: bool = True
pages_per_chunk: int = Field(default=50, ge=1)
class WaitFor(BaseModel):
"""Chromium only. Ignored by the WeasyPrint engine."""
state: Literal["load", "domcontentloaded", "networkidle"] = "load"
selector: str | None = None
timeout_seconds: int = Field(default=30, ge=1)
class ConvertRequest(BaseModel):
source: Source
engine: EngineName = Field(default_factory=lambda: get_settings().default_engine) # type: ignore[arg-type]
page: PageSettings = Field(default_factory=PageSettings)
page_numbers: PageNumbers = Field(default_factory=PageNumbers)
toc: TocSettings = Field(default_factory=TocSettings)
outline: bool = Field(default=True, description="Generovat zalozky PDF z nadpisu h1 az h6.")
pdf_profile: Literal["pdf/a-1b", "pdf/a-2b", "pdf/a-3b", "pdf/a-4b", "pdf/ua-1"] | None = None
assets: AssetSettings = Field(default_factory=AssetSettings)
chunking: ChunkSettings = Field(default_factory=ChunkSettings)
wait_for: WaitFor = Field(default_factory=WaitFor)
filename: str | None = Field(default=None, description="Nazev souboru ve Content-Disposition.")
callback_url: str | None = Field(
default=None,
description="Volitelna adresa, na kterou se po dokonceni jobu posle POST se stavem jobu.",
)
model_config = {
"json_schema_extra": {
"examples": [
{"source": {"url": "https://example.com/dokument.html"}},
{
"source": {"url": "https://example.com/velky-dokument.html"},
"engine": "weasyprint",
"page": {"format": "A4", "orientation": "portrait"},
"page_numbers": {"enabled": True, "format": "{page} / {pages}"},
"toc": {"enabled": True, "depth": 3, "title": "Obsah"},
"chunking": {"enabled": True, "pages_per_chunk": 50},
},
]
}
}
class MissingAsset(BaseModel):
url: str
reason: str
class JobProgress(BaseModel):
pages_rendered: int = 0
chunks_done: int = 0
chunks_total: int = 0
pass_number: int = 0
class ErrorInfo(BaseModel):
error_code: str
message: str
detail: dict | None = None
class JobState(BaseModel):
job_id: str
status: JobStatus
created_at: datetime
started_at: datetime | None = None
finished_at: datetime | None = None
expires_at: datetime | None = None
progress: JobProgress = Field(default_factory=JobProgress)
engine_used: str | None = None
page_count: int | None = None
missing_assets: list[MissingAsset] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
error: ErrorInfo | None = None
result_url: str | None = None
class JobAccepted(BaseModel):
job_id: str
status: JobStatus
created_at: datetime
result_url: str
class HealthResponse(BaseModel):
status: Literal["ok", "degraded"]
app: str
version: str
engines: dict[str, bool]
queue: dict[str, int]