Files
html-to-pdf/app/main.py
T
2026-09-03 10:03:10 +02:00

198 lines
7.0 KiB
Python

"""Service entry point.
The application runs behind the AppFactory reverse proxy under
/apps/<app-id>. The prefix is stripped before the request reaches the
container, so only the OpenAPI document has to know about it. That is what
root_path does.
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from .config import get_settings
from .deps import Container, set_container
from .engines.chromium import ChromiumEngine
from .engines.weasy import WeasyPrintEngine
from .errors import ConversionError
from .logging_setup import setup_logging
from .routers import convert as convert_router
from .routers import health as health_router
from .routers import jobs as jobs_router
from .services.jobs import JobManager
from .services.pipeline import ConversionPipeline
from .services.storage import Storage
logger = logging.getLogger(__name__)
DESCRIPTION = """
Sluzba prevadi HTML dokument na PDF. Prijme adresu dokumentu nebo HTML primo
v tele requestu a vrati soubor PDF.
Pro dokumenty o stovkach az tisicich stranek pouzijte asynchronni endpoint
POST /jobs. Synchronni POST /convert je urceny pro mensi dokumenty.
## Render enginy
- **weasyprint** je vychozi, ma spravne strankovani a nizkou pametovou
narocnost, nespousti JavaScript
- **chromium** zvladne i dokumenty dokreslovane JavaScriptem, ale nema
pouzitelne CSS countery, takze cisla stranek se dopisuji do hotoveho PDF
## Strankovani
Nastaveni je rozdelene do ctyr bloku tela requestu.
**page** rozmer a okraje. `format` je nazev formatu (`A4`, `A5`, `A3`,
`Letter`, `Legal`) nebo explicitni rozmer `210mm 297mm`. `orientation` se
uplatni jen u nazvu formatu. `margin` jsou ctyri CSS delky. Pokud si zdrojove
HTML nastavi vlastni `@page`, ma prednost jeho hodnota.
**page_numbers** cislovani stranek, vychozi stav je vypnuto. `format` je
sablona se zastupnymi symboly `{page}` a `{pages}`, `position` je jeden
z sesti okrajovych boxu stranky. Rezimy:
- `css` cisla resi CSS countery pri renderu. Nejcistsi vysledek, vyzaduje ale
`engine: weasyprint` a `chunking.enabled: false`, protoze v kazde casti se
citac stranek restartuje. Jina kombinace vraci 400 `unsupported_combination`.
- `overlay` cisla se dopisi do hotoveho PDF jako pruhledna vrstva. Funguje
vzdy, jedina moznost u clenenych dokumentu a u Chromia. Jen v tomto rezimu
se uplatni `start_at`.
- `auto` zvoli `css` u necleneneho dokumentu renderovaneho WeasyPrintem,
jinak `overlay`.
Cisla se kresli do okraje stranky, pri nulovem `margin` se nemaji kam vejit.
**chunking** deleni velkeho dokumentu na casti renderovane samostatne. Drzi
spotrebu pameti nizko, casti se pak slouci do jednoho souvisleho PDF. Rez
vznika jen mezi primymi potomky hlavniho kontejneru, 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 bez takovych bodu se vyrenderuje vcelku.
**toc** obsah se skutecnymi cisly stranek. Vyzaduje `engine: weasyprint`.
Dokument se kvuli nemu renderuje dvakrat, ukazatel postupu proto probehne
dvakrat. U cleneneho dokumentu nejsou odkazy v obsahu klikatelne, cisla
stranek jsou spravna a sluzba na to upozorni ve `warnings`.
Rucni zalomeni se resi v samotnem HTML pres CSS `break-before: page`, sluzba
do neho nezasahuje.
## Uchovavani souboru
Zadny vstup ani vysledek se neuklada trvale. Vsechno zije jen v docasnem
adresari sluzby a v pameti procesu.
- **POST /convert** vysledek vznikne na disku, odesle se klientovi a hned po
odeslani odpovedi se soubor i pracovni adresar smazou. Nic ke stazeni
nezustava, opakovane stazeni znamena novou konverzi.
- **POST /jobs** vysledek zustava na disku, aby sel stahnout pres
GET /jobs/{job_id}/result. Meziprodukty se po dokonceni smazou, zustava jen
hotove PDF. Doba dostupnosti je `JOB_RESULT_TTL_SECONDS`, vychozi 1 hodina,
a odpocitava se od dokonceni jobu. Presny cas je v poli `expires_at`.
- Po expiraci se soubor smaze i se zaznamem jobu. Dalsi dotaz na job vraci 404
`job_not_found`.
- **DELETE /jobs/{job_id}** zrusi bezici job nebo smaze hotovy vysledek hned,
bez cekani na expiraci.
- Neuspesny job svuj pracovni adresar maze okamzite, na disku po nem nezustane
nic.
- Fronta i evidence jobu jsou v pameti procesu. Restart sluzby znamena ztratu
rozpracovanych jobu i hotovych vysledku, ktere jeste nikdo nestahl.
Rozpracovane joby se oznaci jako failed s kodem `service_restarted`.
- Stahovani neni jednorazove. Dokud vysledek nevyprsi, jde ho stahnout
opakovane.
"""
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
setup_logging(settings.log_level)
engines: dict = {}
weasy = WeasyPrintEngine()
if await weasy.available():
engines["weasyprint"] = weasy
else:
logger.error("WeasyPrint engine is unavailable, the service will rely on Chromium only")
chromium = ChromiumEngine()
if settings.chromium_enabled:
engines["chromium"] = chromium
else:
logger.info("Chromium engine is disabled by configuration")
if not engines:
logger.error("No render engine is available, conversion requests will fail")
storage = Storage(settings.storage_dir)
pipeline = ConversionPipeline(engines, settings)
manager = JobManager(pipeline, storage, settings)
set_container(
Container(
settings=settings,
storage=storage,
pipeline=pipeline,
jobs=manager,
engines=engines,
)
)
await manager.start()
logger.info(
"Service started",
extra={"engines": sorted(engines), "root_path": settings.root_path},
)
try:
yield
finally:
await manager.stop()
for engine in engines.values():
await engine.shutdown()
logger.info("Service stopped")
settings = get_settings()
setup_logging(settings.log_level)
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
description=DESCRIPTION,
root_path=settings.root_path,
lifespan=lifespan,
)
@app.exception_handler(ConversionError)
async def conversion_error_handler(request: Request, exc: ConversionError) -> JSONResponse:
logger.warning(
"Request failed",
extra={"error_code": exc.error_code, "path": request.url.path, "status_code": exc.status_code},
)
return JSONResponse(status_code=exc.status_code, content=exc.to_dict())
@app.exception_handler(Exception)
async def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
logger.exception("Unhandled error", extra={"path": request.url.path})
return JSONResponse(
status_code=500,
content={
"error_code": "internal_error",
"message": "Doslo k neocekavane chybe sluzby.",
},
)
app.include_router(health_router.router)
app.include_router(convert_router.router)
app.include_router(jobs_router.router)