fix
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 "")
|
||||
Reference in New Issue
Block a user