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:
co-authored by
Claude Opus 5
parent
e3cc8f418b
commit
156289fe2d
+170
@@ -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]
|
||||
Reference in New Issue
Block a user