355 lines
13 KiB
Python
355 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, description="Okraje stranky.")
|
|
|
|
|
|
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.
|
|
Nastaveni tykajici se strankovani je rozdelene do ctyr bloku: `page` resi
|
|
rozmer a okraje, `page_numbers` cislovani, `chunking` deleni velkeho
|
|
dokumentu na renderovane casti a `toc` obsah s cisly stranek.
|
|
"""
|
|
|
|
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, description="Rozmer stranky a okraje."
|
|
)
|
|
page_numbers: PageNumbers = Field(
|
|
default_factory=PageNumbers, description="Cislovani stranek, vychozi je vypnute."
|
|
)
|
|
toc: TocSettings = Field(
|
|
default_factory=TocSettings, description="Obsah se skutecnymi cisly stranek."
|
|
)
|
|
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, description="Stahovani externich assetu."
|
|
)
|
|
chunking: ChunkSettings = Field(
|
|
default_factory=ChunkSettings,
|
|
description="Deleni velkeho dokumentu na casti renderovane samostatne.",
|
|
)
|
|
wait_for: WaitFor = Field(
|
|
default_factory=WaitFor, description="Cekani na dokresleni stranky, jen pro Chromium."
|
|
)
|
|
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, description="Postup renderu.")
|
|
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]
|