This commit is contained in:
JiriUhlir
2026-09-03 10:03:10 +02:00
parent 156289fe2d
commit a922117b3d
13 changed files with 583 additions and 59 deletions
+231 -47
View File
@@ -37,67 +37,203 @@ class Source(BaseModel):
class Margin(BaseModel):
top: str = "20mm"
right: str = "15mm"
bottom: str = "20mm"
left: str = "15mm"
"""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):
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)
"""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):
enabled: bool = False
format: str = Field(default="{page} / {pages}", description="Zastupne symboly {page} a {pages}.")
position: PagePosition = "bottom-center"
"""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=(
"auto zvoli css u necleneneho dokumentu a overlay u clenene nebo u Chromia. "
"css pouziva CSS countery, overlay dopisuje cisla do hotoveho PDF."
"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."
),
)
start_at: int = 1
class TocSettings(BaseModel):
enabled: bool = False
depth: int = Field(default=3, ge=1, le=6)
title: str = "Obsah"
"""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):
allow_remote: bool = True
timeout_seconds: int = Field(default_factory=lambda: get_settings().asset_timeout_seconds, ge=1)
"""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):
enabled: bool = True
pages_per_chunk: int = Field(default=50, ge=1)
"""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):
"""Chromium only. Ignored by the WeasyPrint engine."""
"""Cekani na dokresleni stranky. Pouziva jen Chromium, WeasyPrint to ignoruje."""
state: Literal["load", "domcontentloaded", "networkidle"] = "load"
selector: str | None = None
timeout_seconds: int = Field(default=30, ge=1)
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]
page: PageSettings = Field(default_factory=PageSettings)
page_numbers: PageNumbers = Field(default_factory=PageNumbers)
toc: TocSettings = Field(default_factory=TocSettings)
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)
chunking: ChunkSettings = Field(default_factory=ChunkSettings)
wait_for: WaitFor = Field(default_factory=WaitFor)
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,
@@ -116,6 +252,28 @@ class ConvertRequest(BaseModel):
"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},
},
]
}
}
@@ -127,10 +285,18 @@ class MissingAsset(BaseModel):
class JobProgress(BaseModel):
pages_rendered: int = 0
chunks_done: int = 0
chunks_total: int = 0
pass_number: int = 0
"""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):
@@ -140,19 +306,37 @@ class ErrorInfo(BaseModel):
class JobState(BaseModel):
"""Stav jobu vcetne postupu renderu a doby dostupnosti vysledku."""
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
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):