This commit is contained in:
JiriUhlir
2026-09-03 10:03:10 +02:00
parent 156289fe2d
commit a922117b3d
13 changed files with 583 additions and 59 deletions
+1 -1
View File
@@ -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 ""
)
+68 -5
View File
@@ -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.
"""
+231 -47
View File
@@ -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):
+52
View File
@@ -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"(?<![\w-])width\s*:\s*\d+(?:\.\d+)?%", re.IGNORECASE)
TOC_PLACEHOLDER = "\u2007\u2007\u2007" # figure spaces, keeps the pass 1 layout stable
@@ -70,6 +73,55 @@ class SourceDocument:
base.set("href", base_url)
self.head.insert(0, base)
# -- images ---------------------------------------------------------
def relax_percentage_image_widths(self) -> 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."""
+23
View File
@@ -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."},
+54 -2
View File
@@ -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
+3
View File
@@ -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(