"""Service entry point. The application runs behind the AppFactory reverse proxy under /apps/. 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)