Files
html-to-pdf/app/main.py
T
JiriUhlirandClaude Opus 5 156289fe2d 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>
2026-08-27 14:50:10 +02:00

135 lines
3.9 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.
Dva 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
"""
@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)