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(
+3 -1
View File
@@ -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:
+46 -2
View File
@@ -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: <n>%` 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`,
+1 -1
View File
@@ -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 |
+5
View File
@@ -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
+21
View File
@@ -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: <n>%` 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.
+75
View File
@@ -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 = """
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body>
<table width="100%">
<tr>
<td width="40%">
<img src="https://example.com/foto.jpg"
style="width: 100%; max-width: 300px; height: auto; display: block;">
</td>
<td>Popis produktu</td>
</tr>
</table>
</body></html>
"""
FREE_IMAGE = """
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body>
<div><img src="https://example.com/foto.jpg" style="width: 100%; max-width: 300px;"></div>
</body></html>
"""
WIDTH_ATTRIBUTE = """
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body><table><tr><td><img src="https://example.com/foto.jpg" width="100%"></td></tr></table></body>
</html>
"""
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 "")