Files
2026-09-03 10:52:54 +02:00

345 lines
13 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):
"""Okraje stranky. Kazda hodnota je CSS delka, tedy mm, cm, in, pt nebo px.
Okraje plati pro cely dokument. Pokud si zdrojove HTML nastavi vlastni
pravidlo @page, ma prednost jeho hodnota.
"""
top: str = Field(default="20mm", description="Horni okraj, CSS delka.")
right: str = Field(default="15mm", description="Pravy okraj, CSS delka.")
bottom: str = Field(default="20mm", description="Dolni okraj, CSS delka.")
left: str = Field(default="15mm", description="Levy okraj, CSS delka.")
class PageSettings(BaseModel):
"""Rozmer stranky vysledneho PDF."""
format: str = Field(
default="A4",
description=(
"Nazev formatu (A4, A5, A3, Letter, Legal) nebo explicitni rozmer "
"ve tvaru sirka vyska, napriklad 210mm 297mm."
),
)
orientation: Literal["portrait", "landscape"] = Field(
default="portrait",
description=(
"Uplatni se jen u nazvu formatu. U explicitniho rozmeru urcuje "
"orientaci poradi hodnot."
),
)
margin: Margin = Field(default_factory=Margin)
class PageNumbers(BaseModel):
"""Cislovani stranek. Ve vychozim stavu je vypnute.
Cisla se kresli do okraje stranky, takze na ne musi byt v `page.margin`
misto. Pri nulovem okraji se cislo nema kam vejit.
"""
enabled: bool = Field(default=False, description="Zapne cislovani stranek.")
format: str = Field(
default="{page} / {pages}",
description=(
"Sablona popisku. {page} je cislo aktualni stranky, {pages} celkovy "
"pocet stranek. Priklady: '{page}', 'Strana {page} z {pages}'."
),
)
position: PagePosition = Field(
default="bottom-center",
description="Ktery okrajovy box stranky cislo dostane.",
)
mode: Literal["auto", "css", "overlay"] = Field(
default="auto",
description=(
"css pouzije CSS countery pri renderu. Nejcistsi vysledek, ale vyzaduje "
"engine weasyprint a chunking.enabled false, protoze v kazde casti se "
"citac stranek restartuje. Jinak vraci 400 unsupported_combination. "
"overlay dopise cisla do hotoveho PDF jako pruhlednou vrstvu, funguje "
"vzdy. auto zvoli css u necleneneho dokumentu renderovaneho "
"WeasyPrintem, jinak overlay."
),
)
start_at: int = Field(
default=1,
description=(
"Cislo prvni stranky. Uplatni se jen v rezimu overlay, v rezimu css "
"se vychazi z citace dokumentu."
),
)
class TocSettings(BaseModel):
"""Automaticky generovany obsah se skutecnymi cisly stranek.
Vyzaduje engine weasyprint, protoze jen ten umi rict, na ktere strance
nadpis skoncil. Jina kombinace konci chybou 400 unsupported_combination.
Obsah se vklada na zacatek dokumentu a dokument se kvuli nemu renderuje
dvakrat. Prvni pruchod zjisti cisla stranek, druhy je doplni.
"""
enabled: bool = Field(default=False, description="Zapne generovani obsahu.")
depth: int = Field(
default=3, ge=1, le=6, description="Do jake urovne nadpisu obsah saha, h1 az h6."
)
title: str = Field(default="Obsah", description="Nadpis nad tabulkou obsahu.")
class AssetSettings(BaseModel):
"""Stahovani obrazku, stylu a fontu, na ktere se dokument odkazuje."""
allow_remote: bool = Field(
default=True,
description=(
"false zakaze stahovani externich assetu. Dokument se vyrenderuje bez "
"nich a vsechny se objevi v missing_assets."
),
)
timeout_seconds: int = Field(
default_factory=lambda: get_settings().asset_timeout_seconds,
ge=1,
description=(
"Timeout stazeni jednoho assetu. Nedostupny asset render nezastavi, "
"jen se objevi v missing_assets."
),
)
class ChunkSettings(BaseModel):
"""Deleni velkeho dokumentu na casti renderovane samostatne.
Bez deleni drzi render cely strom stranek v pameti, coz je u tisicistrankoveho
dokumentu ten limitujici faktor. Casti se po renderu opet slouci do jednoho
PDF, vysledek je jeden souvisly soubor.
Rez vznika vzdy jen mezi primymi potomky hlavniho kontejneru, takze tabulka
ani odstavec se nikdy nerozdeli. Delici body jsou section, article, h1,
elementy s atributem data-chunk a elementy se stylem obsahujicim
page-break-before nebo break-before. Dokument, ktery zadny takovy nema, se
vyrenderuje vcelku i pri zapnutem deleni.
Deleni vylucuje rezim cislovani css a u cleneneho dokumentu prestanou byt
klikatelne odkazy v obsahu. Cisla stranek zustavaji spravna.
"""
enabled: bool = Field(default=True, description="Zapne deleni dokumentu na casti.")
pages_per_chunk: int = Field(
default=50,
ge=1,
description=(
"Cilova velikost jedne casti ve strankach. Skutecny pocet je znamy az "
"po renderu, deleni proto vychazi z odhadu podle mnozstvi textu, "
"obrazku a radku tabulek."
),
)
class WaitFor(BaseModel):
"""Cekani na dokresleni stranky. Pouziva jen Chromium, WeasyPrint to ignoruje."""
state: Literal["load", "domcontentloaded", "networkidle"] = Field(
default="load",
description=(
"Kdy se stranka povazuje za nactenou. networkidle ceka, az utichne "
"sitovy provoz, coz je nejspolehlivejsi u stranek dokreslovanych skripty."
),
)
selector: str | None = Field(
default=None,
description="Volitelny CSS selektor, na jehoz vyskyt se navic pocka.",
)
timeout_seconds: int = Field(
default=30, ge=1, description="Limit cekani. Pri prekroceni vraci 504 render_timeout."
)
class ConvertRequest(BaseModel):
"""Telo pozadavku. Stejne pro POST /convert i POST /jobs.
Povinne je pouze `source`, vsechno ostatni ma pouzitelnou vychozi hodnotu.
Strankovani je rozdelene do ctyr bloku. `page` resi rozmer stranky a okraje,
`page_numbers` cislovani, `chunking` deleni velkeho dokumentu na renderovane
casti a `toc` obsah se skutecnymi cisly stranek. `assets` ridi stahovani
externich obrazku a stylu, `wait_for` cekani na dokresleni stranky
a pouziva ho jen Chromium.
"""
source: Source
engine: EngineName = Field(
default_factory=lambda: get_settings().default_engine, # type: ignore[arg-type]
description=(
"weasyprint ma spravne strankovani a nizkou pametovou narocnost, ale "
"nespousti JavaScript. chromium zvladne i dokumenty dokreslovane "
"skripty, nema ale pouzitelne CSS countery ani pozice nadpisu. "
"auto zvoli chromium, pokud dokument obsahuje aktivni skripty, jinak "
"weasyprint, a pri selhani WeasyPrintu render zopakuje pres Chromium."
),
)
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},
},
{
"source": {"url": "https://example.com/smlouva.html"},
"engine": "weasyprint",
"page": {
"format": "A4",
"orientation": "landscape",
"margin": {
"top": "25mm",
"right": "20mm",
"bottom": "25mm",
"left": "20mm",
},
},
"page_numbers": {
"enabled": True,
"format": "Strana {page} z {pages}",
"position": "bottom-right",
"mode": "css",
"start_at": 1,
},
"chunking": {"enabled": False},
},
]
}
}
class MissingAsset(BaseModel):
url: str
reason: str
class JobProgress(BaseModel):
"""Postup renderu. Chunky jsou casti, na ktere byl dokument rozdelen."""
pages_rendered: int = Field(default=0, description="Pocet dosud vyrenderovanych stranek.")
chunks_done: int = Field(default=0, description="Pocet hotovych casti.")
chunks_total: int = Field(default=0, description="Celkovy pocet casti v tomto pruchodu.")
pass_number: int = Field(
default=0,
description=(
"Cislo pruchodu. Druhy pruchod nastava jen u dokumentu s generovanym "
"obsahem, kde se zastupna cisla stranek nahrazuji skutecnymi."
),
)
class ErrorInfo(BaseModel):
error_code: str
message: str
detail: dict | None = None
class JobState(BaseModel):
"""Stav jobu vcetne postupu renderu a doby dostupnosti vysledku."""
job_id: str
status: JobStatus = Field(
description="queued, running, done, failed, cancelled nebo expired."
)
created_at: datetime = Field(description="Cas zarazeni do fronty.")
started_at: datetime | None = Field(default=None, description="Cas, kdy job zacal bezet.")
finished_at: datetime | None = Field(default=None, description="Cas dokonceni nebo selhani.")
expires_at: datetime | None = Field(
default=None,
description=(
"Cas, do ktereho je vysledek ke stazeni. Potom se soubor i zaznam jobu "
"smazou a dalsi dotaz vraci 404. Odvozuje se z JOB_RESULT_TTL_SECONDS."
),
)
progress: JobProgress = Field(default_factory=JobProgress)
engine_used: str | None = Field(default=None, description="Engine, ktery dokument vyrenderoval.")
page_count: int | None = Field(default=None, description="Pocet stranek vysledku.")
missing_assets: list[MissingAsset] = Field(
default_factory=list,
description="Assety, ktere se nepodarilo nacist. V PDF na jejich miste neco chybi.",
)
warnings: list[str] = Field(
default_factory=list,
description="Upozorneni, ktera konverzi nezastavila.",
)
error: ErrorInfo | None = Field(default=None, description="Vyplnene jen u stavu failed.")
result_url: str | None = Field(
default=None, description="Adresa ke stazeni, vyplnena jen u stavu done."
)
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]