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>
171 lines
5.4 KiB
Python
171 lines
5.4 KiB
Python
"""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]
|