From a922117b3d8df924fb541b235463ee52afad118b Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:03:10 +0200 Subject: [PATCH] fix --- app/config.py | 2 +- app/main.py | 73 ++++++++- app/models.py | 278 +++++++++++++++++++++++++++++------ app/pdf/document.py | 52 +++++++ app/routers/convert.py | 23 +++ app/routers/jobs.py | 56 ++++++- app/services/pipeline.py | 3 + documentation/README.md | 4 +- documentation/api.md | 48 +++++- documentation/konfigurace.md | 2 +- documentation/provoz.md | 5 + documentation/zmeny.md | 21 +++ tests/test_images.py | 75 ++++++++++ 13 files changed, 583 insertions(+), 59 deletions(-) create mode 100644 tests/test_images.py diff --git a/app/config.py b/app/config.py index ae954d1..cefc064 100644 --- a/app/config.py +++ b/app/config.py @@ -34,7 +34,7 @@ def _list(name: str) -> list[str]: @dataclass(frozen=True) class Settings: app_name: str = field(default_factory=lambda: os.getenv("APP_NAME", "html-to-pdf")) - app_version: str = field(default_factory=lambda: os.getenv("APP_VERSION", "1.0.0")) + app_version: str = field(default_factory=lambda: os.getenv("APP_VERSION", "1.0.1")) root_path: str = field( default_factory=lambda: os.getenv("ROOT_PATH") or os.getenv("BASE_PATH") or "" ) diff --git a/app/main.py b/app/main.py index b70a94a..304880d 100644 --- a/app/main.py +++ b/app/main.py @@ -36,12 +36,75 @@ 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: +## 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 +- **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. """ diff --git a/app/models.py b/app/models.py index 0e2deb7..415489f 100644 --- a/app/models.py +++ b/app/models.py @@ -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): diff --git a/app/pdf/document.py b/app/pdf/document.py index 3237c33..fb007fb 100644 --- a/app/pdf/document.py +++ b/app/pdf/document.py @@ -13,6 +13,9 @@ logger = logging.getLogger(__name__) HEADING_TAGS = ("h1", "h2", "h3", "h4", "h5", "h6") ID_SAFE = re.compile(r"[^a-zA-Z0-9_-]+") +# Matches a width declaration in percent, but never max-width or min-width. +PERCENT_WIDTH = re.compile(r"(? int: + """Replace percentage widths of images inside table cells by auto. + + WeasyPrint resolves such a percentage against a cell width that is not + known yet while the table is being laid out. The image comes out zero + wide and disappears from the PDF without any error, so it is not even + reported as a missing asset. Rendering the image at its intrinsic size + capped by max-width gives the result the document intended. + + Only relevant for WeasyPrint, Chromium lays these images out correctly. + Returns the number of images that were changed. + """ + changed = 0 + for cell in self.body.iter("td", "th"): + for image in cell.iter("img"): + if self._relax_image_width(image): + changed += 1 + + if changed: + logger.info( + "Percentage width of images inside table cells replaced by auto", + extra={"images": changed}, + ) + return changed + + @staticmethod + def _relax_image_width(image) -> bool: + touched = False + + if (image.get("width") or "").strip().endswith("%"): + del image.attrib["width"] + touched = True + + style = image.get("style") or "" + if PERCENT_WIDTH.search(style): + style = PERCENT_WIDTH.sub("width: auto", style) + touched = True + + if not touched: + return False + + if "max-width" not in style.lower(): + stripped = style.strip().rstrip(";") + style = f"{stripped}; max-width: 100%" if stripped else "max-width: 100%" + + image.set("style", style) + return True + # -- headings ------------------------------------------------------- def collect_headings(self, max_depth: int) -> list[Heading]: """Assign ids to headings that lack one and return them in document order.""" diff --git a/app/routers/convert.py b/app/routers/convert.py index 57eb51a..69db61c 100644 --- a/app/routers/convert.py +++ b/app/routers/convert.py @@ -22,9 +22,32 @@ logger = logging.getLogger(__name__) router = APIRouter(tags=["konverze"]) +CONVERT_DESCRIPTION = """ +Prevede dokument a rovnou vrati `application/pdf`. + +Urceno pro mensi dokumenty. Konverze, ktera presahne `SYNC_TIMEOUT_SECONDS` +(vychozi 60 s), se zrusi a sluzba vrati 413 s odkazem na POST /jobs. + +**Uchovavani souboru.** Vysledek se nikam neuklada. Vznikne v docasnem adresari, +odesle se v teto odpovedi a hned po jejim odeslani se i s pracovnim adresarem +smaze. Neexistuje zadna adresa, ze ktere by sel stahnout znovu, opakovane +stazeni znamena novou konverzi. Pokud vysledek potrebujete pozdeji, pouzijte +POST /jobs. + +Hlavicky odpovedi: + +- `X-Page-Count` pocet stranek vysledku +- `X-Engine-Used` engine, ktery dokument vyrenderoval +- `X-Missing-Assets` pocet assetu, ktere se nepodarilo nacist, hlavicka je jen + pri nenulove hodnote. Cely seznam vraci jen asynchronni cesta. +- `X-Warnings` pocet varovani, hlavicka je jen pri nenulove hodnote +""" + + @router.post( "/convert", summary="Synchronni prevod HTML na PDF", + description=CONVERT_DESCRIPTION, response_class=Response, responses={ 200: {"content": {"application/pdf": {}}, "description": "Hotove PDF."}, diff --git a/app/routers/jobs.py b/app/routers/jobs.py index e45c133..c106585 100644 --- a/app/routers/jobs.py +++ b/app/routers/jobs.py @@ -21,11 +21,29 @@ def _result_url(job_id: str) -> str: return f"{root}/jobs/{job_id}/result" +CREATE_DESCRIPTION = """ +Zaradi konverzi do fronty a hned vrati 202 s identifikatorem jobu. Telo je +stejne jako u POST /convert. + +Postup sledujte pres GET /jobs/{job_id}, hotove PDF stahnete z +GET /jobs/{job_id}/result. Volitelny `callback_url` dostane po dokonceni POST +se stejnym telem, jake vraci GET /jobs/{job_id}. + +**Uchovavani souboru.** Hotove PDF zustava na disku sluzby, aby slo stahnout. +Meziprodukty renderu se po dokonceni smazou, zustava jen vysledek. Dostupny je +`JOB_RESULT_TTL_SECONDS` (vychozi 1 hodina) od dokonceni jobu, presny cas je +v poli `expires_at`. Pak se soubor i zaznam jobu smazou a dalsi dotaz vraci 404. +Stahovat lze opakovane. Neuspesny job se maze hned. Uloziste je docasny adresar +procesu, restart sluzby vysledky ztrati. +""" + + @router.post( "", response_model=JobAccepted, status_code=status.HTTP_202_ACCEPTED, summary="Zaradi konverzi do fronty", + description=CREATE_DESCRIPTION, ) async def create_job(request: ConvertRequest) -> JobAccepted: job = get_container().jobs.submit(request) @@ -37,7 +55,26 @@ async def create_job(request: ConvertRequest) -> JobAccepted: ) -@router.get("/{job_id}", response_model=JobState, summary="Stav jobu") +STATE_DESCRIPTION = """ +Stav jobu, postup renderu a vysledek kontrol. + +Stavy: `queued`, `running`, `done`, `failed`, `cancelled`, `expired`. + +`progress.pass_number` rozlisuje prvni a druhy pruchod. Druhy nastava jen +u dokumentu s generovanym obsahem, takze ukazatel postupu probehne dvakrat. + +`expires_at` je cas, do ktereho je vysledek ke stazeni. `missing_assets` je +seznam assetu, ktere se nepodarilo nacist, `warnings` obsahuje veci, na ktere +sluzba upozornuje, aniz by kvuli nim konverze selhala. +""" + + +@router.get( + "/{job_id}", + response_model=JobState, + summary="Stav jobu", + description=STATE_DESCRIPTION, +) async def job_state(job_id: str) -> JobState: job = get_container().jobs.get(job_id) state = job.state.model_copy() @@ -49,6 +86,12 @@ async def job_state(job_id: str) -> JobState: @router.get( "/{job_id}/result", summary="Stahne hotove PDF", + description=( + "Stahne vysledek jobu. Soubor se streamuje, nenacita se cely do pameti. " + "Stahovat lze opakovane, dokud vysledek nevyprsi. Doba dostupnosti je " + "JOB_RESULT_TTL_SECONDS od dokonceni jobu, presny cas je v poli expires_at " + "u GET /jobs/{job_id}." + ), response_class=Response, responses={ 200: {"content": {"application/pdf": {}}, "description": "Hotove PDF."}, @@ -85,6 +128,15 @@ async def job_result(job_id: str): ) -@router.delete("/{job_id}", response_model=JobState, summary="Zrusi job nebo smaze jeho vysledek") +@router.delete( + "/{job_id}", + response_model=JobState, + summary="Zrusi job nebo smaze jeho vysledek", + description=( + "Zrusi bezici job nebo smaze hotovy vysledek hned, bez cekani na expiraci. " + "Soubor se z uloziste odstrani okamzite, zaznam jobu zustava jeste po dobu " + "TTL, aby bylo videt, co se s nim stalo." + ), +) async def delete_job(job_id: str) -> JobState: return get_container().jobs.cancel(job_id).state diff --git a/app/services/pipeline.py b/app/services/pipeline.py index 404fd18..bb86860 100644 --- a/app/services/pipeline.py +++ b/app/services/pipeline.py @@ -174,6 +174,9 @@ class ConversionPipeline: ) document = SourceDocument(raw_html) + if engine_name == "weasyprint": + # WeasyPrint drops such images without a word, see the method docstring. + document.relax_percentage_image_widths() document.set_base_url(base_url) document.append_stylesheet( build_page_css( diff --git a/documentation/README.md b/documentation/README.md index ead78fb..b438d87 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -13,7 +13,7 @@ v tele requestu a vrati soubor PDF. Zvlada dokumenty o tisicich stranek. ## Aktualni stav -Verze 1.0.0, stav development. +Verze 1.0.1, stav development. Hotovo: @@ -28,6 +28,8 @@ Hotovo: - fronta s omezenym poctem paralelnich workeru - volitelny callback po dokonceni jobu - strukturovane JSON logovani s job_id +- obchazeni chyby WeasyPrintu u obrazku s procentualni sirkou v bunce tabulky +- Swagger popisuje moznosti strankovani i uchovavani souboru Neni hotovo a neni ani v zadani: diff --git a/documentation/api.md b/documentation/api.md index 9496753..c16a230 100644 --- a/documentation/api.md +++ b/documentation/api.md @@ -25,6 +25,11 @@ Hlavicky odpovedi: Pokud je `X-Missing-Assets` nenulovy, v PDF neco chybi. Detaily jsou dostupne jen u asynchronni cesty, kde se vraci cely seznam. +Vysledek se nikam neuklada. Vznikne v docasnem adresari, odesle se v odpovedi +a hned po jejim odeslani se i s pracovnim adresarem smaze. Neexistuje adresa, +ze ktere by sel stahnout znovu. Kdo potrebuje vysledek pozdeji, pouzije +`POST /jobs`. + ## POST /jobs Zaradi konverzi do fronty. Vraci HTTP 202. @@ -73,14 +78,38 @@ dokumentu ukazatel postupu probehne dvakrat. ## GET /jobs/{job_id}/result -Stahne hotove PDF. Soubor se streamuje, nenacita se cely do pameti. +Stahne hotove PDF. Soubor se streamuje, nenacita se cely do pameti. Stahovat +lze opakovane, dokud vysledek nevyprsi. - 404 job neexistuje nebo uz expiroval - 409 job jeste nedobehl nebo skoncil chybou ## DELETE /jobs/{job_id} -Zrusi bezici job nebo smaze hotovy vysledek. +Zrusi bezici job nebo smaze hotovy vysledek hned, bez cekani na expiraci. +Soubor z uloziste zmizi okamzite, zaznam jobu zustava jeste po dobu TTL, aby +bylo videt, co se s nim stalo. + +## Uchovavani souboru + +Nic se neuklada trvale. Vsechno zije jen v docasnem adresari `STORAGE_DIR` +a v pameti procesu. + +| Co | Kde skonci | Jak dlouho | +|---|---|---| +| zdrojove HTML | jen v pameti behem konverze | do konce konverze | +| vysledek `POST /convert` | docasny adresar jobu | do odeslani odpovedi, pak se maze | +| vysledek `POST /jobs` | docasny adresar jobu | `JOB_RESULT_TTL_SECONDS` od dokonceni, vychozi 1 hodina | +| meziprodukty renderu | docasny adresar jobu | do dokonceni jobu, pak se mazou | +| vysledek neuspesneho jobu | nikde | pracovni adresar se maze hned | + +Presny cas expirace je v poli `expires_at` u `GET /jobs/{job_id}`. Po nem se +smaze soubor i zaznam jobu a dalsi dotaz vraci 404 `job_not_found`. + +Uloziste neni trvale. Restart sluzby znamena ztratu rozpracovanych jobu +i hotovych vysledku, ktere jeste nikdo nestahl. Rozpracovane joby se oznaci +jako failed s kodem `service_restarted`. Pri startu se navic smazou adresare +jobu, ktere po restartu zustaly bez zaznamu. ## GET /health @@ -207,6 +236,21 @@ Delici body jsou elementy `section`, `article`, `h1`, elementy s atributem `break-before`. Pokud dokument zadny takovy nema, renderuje se vcelku a sluzba to zaloguje. +### obrazky v tabulkach + +WeasyPrint neumi vyresit procentualni sirku obrazku uvnitr bunky tabulky. +Sirku bunky v tu chvili jeste nezna, obrazek vyjde nulove siroky a z PDF zmizi +bez jakekoliv chyby, takze se neobjevi ani v `missing_assets`. + +Sluzba proto u enginu `weasyprint` u obrazku uvnitr `td` a `th` prepise +`width: %` na `width: auto` a procentualni atribut `width` odstrani. Pokud +obrazek nema zadne `max-width`, doplni se `max-width: 100%`, aby z bunky +nevystoupil. Obrazek se tim vykresli ve sve vlastni velikosti omezene bunkou, +coz je to, co dokument zamyslel. + +Chromia se to netyka, ten takove obrazky rozvrhne spravne, a uprava se u nej +neprovadi. + ### wait_for Pouziva jen Chromium. `state` je `load`, `domcontentloaded` nebo `networkidle`, diff --git a/documentation/konfigurace.md b/documentation/konfigurace.md index cd8f976..a5bc5dc 100644 --- a/documentation/konfigurace.md +++ b/documentation/konfigurace.md @@ -8,7 +8,7 @@ runtime `.env`. Zadna promenna neni povinna, sluzba nastartuje i bez nich. | Promenna | Vychozi | Vyznam | |---|---|---| | `APP_NAME` | `html-to-pdf` | nazev v dokumentaci a v odpovedi /version | -| `APP_VERSION` | `1.0.0` | verze | +| `APP_VERSION` | `1.0.1` | verze | | `ROOT_PATH` | prazdne | prefix reverse proxy, napriklad `/apps/html-to-pdf` | | `BASE_PATH` | prazdne | pouzije se, kdyz `ROOT_PATH` neni nastavene | | `LOG_LEVEL` | `INFO` | uroven logovani | diff --git a/documentation/provoz.md b/documentation/provoz.md index f1b9dbe..4723f8e 100644 --- a/documentation/provoz.md +++ b/documentation/provoz.md @@ -77,6 +77,10 @@ Secrets se do logu nezapisuji. konverze. - WeasyPrint nespousti JavaScript. Pro dokumenty dokreslovane skripty je nutne Chromium. +- WeasyPrint neumi procentualni sirku obrazku uvnitr bunky tabulky. Sluzba to + obchazi prepsanim na `width: auto`, viz api.md. Podobne vlastnosti se mohou + objevit i jinde, protoze WeasyPrint neni prohlizec. Kdyz neco v PDF chybi + a `missing_assets` je prazdne, stoji za to zkusit `engine: chromium`. ## Testy @@ -97,4 +101,5 @@ Co je pokryte: - spravnost cisel stranek po slouceni - obsah ukazuje na skutecne stranky - nedostupny asset render nezastavi a objevi se v odpovedi +- procentualni sirka obrazku v bunce tabulky se prepise na auto - zruseni beziciho jobu, plna fronta, chovani pri ukonceni sluzby diff --git a/documentation/zmeny.md b/documentation/zmeny.md index f1315b8..52d7541 100644 --- a/documentation/zmeny.md +++ b/documentation/zmeny.md @@ -1,5 +1,26 @@ # Zaznam zmen +## 1.0.1 + +Opraveno: + +- obrazek uvnitr bunky tabulky, ktery ma sirku v procentech, uz z PDF nemizi. + WeasyPrint takovou sirku neumi vyresit proti bunce, jejiz sirku jeste nezna, + obrazek vysel nulove siroky a zmizel bez chyby, takze se neobjevil ani + v `missing_assets`. Sluzba nove u enginu `weasyprint` prepise u obrazku + v `td` a `th` `width: %` na `width: auto`, odstrani procentualni atribut + `width` a doplni `max-width: 100%`, pokud zadne nema. Chromia se to netyka. + Typicky pripad je produktovy list, kde je fotka v levem sloupci tabulky. + +Zmeneno: + +- Swagger a OpenAPI popisuji moznosti strankovani, tedy `page`, `page_numbers`, + `chunking` a `toc`, vcetne omezeni jednotlivych rezimu cislovani +- Swagger a OpenAPI popisuji uchovavani souboru u `POST /convert` i u `POST /jobs` + vcetne doby dostupnosti vysledku a chovani po restartu sluzby +- pole odpovedi `JobState` a `JobProgress` maji v OpenAPI popis +- pribyl priklad tela requestu s cislovanim stranek na sirku stranky + ## 1.0.0 Prvni implementace sluzby. diff --git a/tests/test_images.py b/tests/test_images.py new file mode 100644 index 0000000..c078328 --- /dev/null +++ b/tests/test_images.py @@ -0,0 +1,75 @@ +"""Images inside table cells. + +WeasyPrint resolves a percentage width of an image in a table cell against a +width it does not know yet and the image comes out zero wide. It then vanishes +from the PDF without any error, so it is not even reported as a missing asset. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("lxml") + +from app.pdf.document import SourceDocument # noqa: E402 + +CELL_IMAGE = """ + + + + + + + +
+ + Popis produktu
+ +""" + +FREE_IMAGE = """ + + +
+ +""" + +WIDTH_ATTRIBUTE = """ + +
+ +""" + + +def _image_style(document: SourceDocument) -> str: + return document.tree.find(".//img").get("style") or "" + + +def test_percentage_width_in_a_cell_becomes_auto() -> None: + document = SourceDocument(CELL_IMAGE) + + assert document.relax_percentage_image_widths() == 1 + + style = _image_style(document) + assert "width: auto" in style + # The cap the document asked for has to survive, it is what limits the size now. + assert "max-width: 300px" in style + assert "height: auto" in style + + +def test_image_outside_a_table_is_left_alone() -> None: + document = SourceDocument(FREE_IMAGE) + + assert document.relax_percentage_image_widths() == 0 + assert "width: 100%" in _image_style(document) + + +def test_percentage_width_attribute_is_dropped() -> None: + document = SourceDocument(WIDTH_ATTRIBUTE) + + assert document.relax_percentage_image_widths() == 1 + + image = document.tree.find(".//img") + assert image.get("width") is None + assert "max-width: 100%" in (image.get("style") or "")